Playwright screenshot capture for Isomorphic SmartClient applications, built for the case where the environment you are capturing is one other people are using and the screens contain data you must not publish.
Two things make it different from a folder of Playwright scripts:
- Mutating clicks are blocked by default. Save, Submit, Confirm, Apply, Delete and Upload are cancelled in the DOM before the application's own handler sees them. You opt out deliberately, not in.
- Redaction happens before the shutter. Sensitive regions are covered with opaque overlays in the live DOM, the overlays are verified as painted, and only then is the PNG written. There is no blur pass to forget and no original to leak.
$ node run-modules.js --env staging --modules login,admin
🛡️ READ-ONLY guard active (Save/Submit/Confirm/Apply/Delete clicks blocked)
login 5 steps 5 captured
admin 11 steps 11 captured
[read-only guard] blocked click on "Save" (matched "Save")
screenshots/staging/2026-08-22/
I was capturing several hundred documentation screenshots per release from a SmartClient application. Three failures kept recurring, and none of them announced itself:
A click that reports success and does nothing. When SmartClient raises a modal it also raises a click mask, and that mask eats a click as a silent no-op. Nothing throws, nothing logs, no navigation happens. Three identical clicks produce three identical screenshots, and you cannot tell that apart from a page that ignored you, not from the DOM and not from the image.
A folder of confident screenshots of the wrong screens. A step names a field or menu option the resolver cannot actually find, the run continues, and you review 200 images before noticing. Nothing failed, so nothing told you.
A screenshot that shipped a real person's name. The pixels are in the file the moment you take it. A redaction step that runs afterward is a step you can forget.
The kit is the answer to those three. lib/isc.js asks the framework instead of guessing.
lib/reference-stop.js halts the run rather than guessing. lib/capture-safe.js is the
only door to a PNG, so redaction is not a call a script can omit.
git clone https://github.com/pinklightultra/smartclient-capture-kit
cd smartclient-capture-kit
npm install
npx playwright install chromium
node scripts/smoke-test.jsThe smoke test needs no application, no credentials and no network. It builds a synthetic page, asserts the guard blocks a Save click and allows an ordinary one, asserts the redactor finds and covers regions, and asserts CDP attachment works.
Then point it at something:
node setup.js # writes .env, then optionally adds an environment
node setup.js --add-env # adds an environment to capture.config.json
node setup.js --test staging # logs in read-only and confirms the app loadsNode 20.9 or newer, which is sharp's floor rather than this kit's.
node run-modules.js --list # available modules
node run-modules.js --list-envs # configured environments
node run-modules.js --env staging --modules login,admin
node runner.js --url https://staging.example.com --script scripts/example-screens.js
node scripts/probe-isc-state.js --env staging # what is actually on screen
node scripts/bench-interaction.js --env staging # re-measure click strategies
node repl.js --env staging # one live session, many attempts
node watch.js -- node run-modules.js --env staging --modules login
node diff-runs.js --before ./shots/4.2 --after ./shots/4.3 # what changed between runs
node annotate.js --in shot.png --out doc.png --highlight 120,200,300,48 # crop and mark upPrefer --env over --url, and prefer .env or CAPTURE_USER/CAPTURE_PASS over
--user/--pass. Arguments are visible in the process list to every other user on the
machine; the environment name keeps the hostname out of your shell history.
Writing a selector or a navigation path takes ten or twenty attempts, and a cold launch plus
a login costs 15-20 seconds each time. repl.js holds one authenticated context open so you
pay that once:
CAPTURE_READONLY=1 node repl.js --env staging
capture> await nav.goto(page, 'Admin > Users')
capture> reload('./lib/nav') # after editing lib/nav.js
capture> await shot('users') # ./repl-shots/users.png
capture> await relogin() # cookies went stale; rebinds page and contextpage, context, browser, your libs, and reload/shot/relogin are preloaded.
Credentials are optional only when a saved session exists for that host, since resuming one
never touches the login form.
Two things worth knowing. shot() is not safeShot: it writes an unredacted picture of a
live application, which is correct for looking at what you just did and wrong for anything you
keep, so repl-shots/ is gitignored. And relogin() closes the old context and rebinds page,
rather than only re-priming the saved state, because re-priming alone writes fresh cookies to
disk while the browser in front of you stays logged out.
watch.js wraps a capture, watches for FAILURE-*.json, and hands each one to the CLI
configured under agentHandoff. The wrapped command keeps its own stdout.
node watch.js -- node run-modules.js --env staging --modules login,home,adminIt does not verify a fix. It collects an agent's diagnosis and says so; nothing here re-runs
the failed beat or confirms anything was repaired. --rerun lets the agent drive its own
browser, which is off by default because your capture session may still be open against the
same environment. The agent gets write access to the repo through whatever CLI you configured,
and nothing gates or rolls back an edit, so read the diff.
A release lands, you re-run a module, and now you have forty new screenshots and no idea
which ones moved. diff-runs.js pairs images by filename between two directories and sorts
them by how much of each screen changed:
node diff-runs.js --before ./shots/4.2 --after ./shots/4.3 --strips ./diffs--strips writes a [ before | diff | after ] strip per changed screen. --changed-at
sets the percentage above which a screen counts as changed (default 0.5), and
--threshold is pixelmatch's per-pixel colour tolerance (default 0.1). It uses sharp
and pixelmatch, which the kit already depends on, so this adds nothing to install.
It counts pixels. It does not know which differences matter. A timestamp, one extra grid row, a scrollbar, or a re-rendered chart all register. Read the output as a worklist, never as a verdict.
Three things it deliberately does:
- It pads, it never scales. Full-page captures of the same screen routinely differ in height, and resizing to fit centres the letterbox, which shifts content that did not move. On a 300x200 capture against a 300x350 one whose top 200px is byte-identical, the true answer is 42.86% and resize-with-contain reports 64.29%. Padding from the top-left reports 42.86%.
- An unreadable image fails, loudly. A truncated or zero-byte capture throws rather than returning a null difference, because a null compares false against any threshold and would file a broken capture under "unchanged".
- It tells you what it did not compare. Files present in only one directory are listed and named as uncompared, because a renamed screen otherwise looks like one removal and one addition, and "12 changed" reads like a complete answer when three files never got read.
Exit codes are 0 for no changes, 3 for changes found, and 1 only when the run did not
measure what it claims to have measured: a missing directory, a bad argument, or any failed
comparison. A wrapper script should break on 1, not on 3.
A capture is rarely the thing that ships. What ships is that capture cropped to the relevant panel, with a box around the control and a numbered bubble beside it:
node annotate.js --in screenshots/staging/users.png --out doc/users.png \
--crop 0,56,1440,844 --highlight 120,200,300,48 --callout '270,190,1'--highlight, --arrow and --callout repeat. Marks draw in that order, boxes first and
callouts on top. Colours, stroke width and font size come from the annotate block of
capture.config.json, with --accent and --font-size as per-run overrides.
Every coordinate is post-crop. --crop is applied first and everything else is measured
against the cropped frame, because the alternative — measuring against the original and having
the crop shift your boxes — is a mistake you find by looking at the output. --describe
prints the frame you are working in and writes nothing:
$ node annotate.js --in screenshots/staging/users.png --crop 0,56,1440,844 --describe
users.png is 1440x900
after --crop you are working in 1440x844, origin at the crop's top-left
--focus x,y,w,h blurs everything outside a box to direct the eye. It is not redaction.
Redaction happens in lib/redact.js before the shutter; a blur applied afterwards is a
post-process on a file that already contains the pixels, it blurs whatever happened to be in
the frame rather than what was identified as sensitive, and a mild blur can be partly undone.
Use it for emphasis and nothing else.
Two things it does that the version it came from did not:
- Marks that would fall off the canvas are refused, not clipped. SVG and
sharpboth clip silently, so an off-image highlight used to produce a clean-looking annotated screenshot with no box on it and no error anywhere. Now it exits 1 naming the coordinate and the actual dimensions. - A callout flips below its point when there is no room above. The original always
subtracted the bubble height from
y, so a callout on a toolbar button, a tab or a menu — most of what gets called out — was drawn at a negativey. On a 400x300 frame a callout aty=10drew 1,074 of its 5,620 pixels and lost all of its text, and still printed a success line. Bubbles are clamped horizontally for the same reason: without it, a callout near an edge lost about half its ink.
scripts/smoke-test.js asserts both by differencing the output against the un-annotated base
and measuring where the ink landed. Restore either old behaviour and the suite goes red.
It does not fabricate UI states. The tool this came from could also swap the text in a field so a screenshot showed a value the application never displayed, and that half was left behind deliberately: a capture is evidence of what the application did.
Callout bubbles are sized from a per-character width table rather than real font metrics, since measuring text properly needs a font stack this module does not load. The estimate rounds up, so a bubble comes out slightly wider than it needs to be rather than overflowing its text.
Everything environment-specific lives in capture.config.json. Nothing is hardcoded in
lib/. The blocks are:
| Block | What it holds |
|---|---|
environments |
named hosts, so scripts never embed a URL |
app |
login path, service path, field selectors, loading-overlay text, mask class fragments |
viewport, capture |
window size, output directory, crop box |
redaction |
the identity badge prefix, column labels, label patterns, value regexes, and requireBoxes |
guard |
the forbidden control labels |
navigation |
the shell's menu labels, tab strip geometry, and named A > B > C routes |
resolvers |
the resolution cascade order, Tesseract paths, template directory, optional vision command |
referenceLookup |
optional command run when a target cannot be resolved |
agentHandoff |
optional coding-agent CLI handed a failure report for diagnosis |
annotate |
mark-up colours, stroke width, font family and size, focus blur strength |
Credentials never go in this file.
Nine, and this is all of them:
| Variable | Effect |
|---|---|
CAPTURE_USER, CAPTURE_PASS |
credentials. Both must be set to be used. Preferred over --user/--pass, which are visible in the process list |
CAPTURE_CONFIG |
path to a config file other than capture.config.json |
CAPTURE_ALLOW_WRITES=1 |
disarms the read-only click guard. Nothing else does |
CAPTURE_READONLY=1, CAPTURE_NO_SUBMIT=1 |
force the guard on, overriding CAPTURE_ALLOW_WRITES |
CAPTURE_REFERENCE_STOP=1 |
on an unresolvable target, run the configured referenceLookup command and hard-stop instead of continuing |
CAPTURE_AGENT_HANDOFF=1 |
hand a beat's failure report to the configured agentHandoff CLI for diagnosis |
CAPTURE_AGENT_RERUN=1 |
additionally let that agent launch its own run. Off by default because the capture browser is still open and authenticated when the handoff fires |
TESSERACT_BIN |
path to the Tesseract binary, checked before resolvers.tesseractCandidates |
The guard is armed unless you set CAPTURE_ALLOW_WRITES=1. That default matters more than
it looks: the protection was originally opt-in, which meant it was absent in exactly the
case it was written for, someone running the kit for the first time against a shared
environment without having read lib/guard.js. Forgetting a variable should cost you a
blocked click, not a saved record.
CAPTURE_READONLY and CAPTURE_NO_SUBMIT are redundant with the default and kept because
six entry points set them deliberately. They win over CAPTURE_ALLOW_WRITES rather than
losing to it, so exporting the override in a shell for one purpose cannot silently disarm a
script that asked to be read-only.
lib/nav.js is built on a measured benchmark rather than on preference, and
scripts/bench-interaction.js is the benchmark. It runs four targets of increasing
difficulty (a unique tab label, an ambiguous label that appears in two places, an
async-loaded grid section, and a genuinely fixed-position control) against each applicable
click strategy under both wait strategies. Four targets times two waits is eight trials per
strategy, which is where the denominator below comes from:
| Strategy | Verified | Note |
|---|---|---|
element.click() inside page.evaluate |
0 of 8 | not flaky. It never works |
page.locator('text=...') |
100% of correctly-specified targets | most layout-robust |
page.mouse.click on DOM-center coords |
100% of correctly-specified targets | needed when a label repeats |
| fixed pixel coordinates | reliable only for a genuinely fixed target | a coin flip everywhere else |
The zero is the useful result. SmartClient runs its own event manager and reacts only to trusted input events. A synthetic DOM click is not trusted, so the framework never sees it. Playwright's mouse API dispatches through CDP, which the browser marks trusted, which is the only reason the coordinate paths work.
On waiting: polling isc.Page.isLoaded() was as reliable as a blind waitForTimeout and
about four times faster, 1.7 seconds per step against 7.1. Those two numbers are from one
run against one application, and the run output is not in the repo (bench-results.json is
gitignored, because it names screens). The ratio is the part worth trusting, and it follows
from how the two waits are built: the fixed wait is a hardcoded 6 s (waitFixed, default
ms = 6000) plus overlay dismissal, so it cannot go faster than 6 s no matter how quickly
the page settles, while the signal wait returns as soon as isc.Page.isLoaded() is true.
Re-measure on your own application with node scripts/bench-interaction.js --env <name>
rather than taking these figures as a specification.
So the canonical order is locator, then a positionally-banded DOM-center mouse click, then
the visual cascade. A band (ymin/ymax) is what disambiguates a label that appears twice,
and a locator cannot express one.
Worth its own section because it was the largest single source of wrong captures. SmartClient renders grids into generic divs with volatile ids and virtualizes the rows, so the row you want may not be in the DOM at all until it is scrolled into view. Indexing rows by on-screen position is therefore meaningless, and DOM sweeps, OCR and vision all miss.
lib/isc.js resolves a row by matching a field value against the grid widget's own backing
data, calls scrollRecordIntoView(), then asks the widget for its real page rect. No
coordinate guessing and no dependence on which rows happen to be painted.
It is not a compliance control and it does not implement any named de-identification
standard. It covers exactly what you declare in the redaction block: the operator badge,
leaf text matching your valuePatterns, and values adjacent to labels or under grid columns
you name in labelPatterns and columnLabels.
The shipped patterns match US Social Security numbers, 10-digit phone and fax numbers, email addresses, numeric dates, IPv4 addresses, URLs, and uppercase alphanumeric identifiers of seven characters or more. That list reflects one application's screens. Read it and change it before you trust it on yours.
requireBoxes is the safety net worth understanding: set it to the minimum number of
regions a capture must find and verify, and a capture that finds fewer aborts instead of
writing a file. A redactor that silently finds nothing is indistinguishable from a screen
with nothing to redact, and only one of those is safe.
There are eight places where a capture is abandoned rather than written: seven in
lib/redact.js and one in lib/capture-safe.js. Each throws RedactionError, and none of
them writes a PNG first.
| Condition | Why it is fatal |
|---|---|
| The captured region's size cannot be determined | Overlay bounds cannot be checked against anything |
| A region to cover lies entirely outside the capture | It would be absent from the image with no visible sign |
| A region extends past the capture and would be partly covered | A partly covered value is a legible value |
Fewer regions were found than requireBoxes |
The discovery selectors may have stopped matching this screen |
| Overlays did not attach in the DOM | The cover exists in the plan and not on the page |
| Overlays vanished between attaching and the shot | The application re-rendered mid-capture, so the image is not the page that was verified |
| The configured crop does not fit the capture | Cropping is geometry, not redaction, and a bad crop shifts every box |
| A configured column is visible but none of its values could be located | Indistinguishable from a screen with no such column, which is the case that produces a clean-looking unredacted capture |
The last one is the interesting failure. scripts/smoke-test.js covers it directly: it
builds a page with a redaction.columnLabels header and no values beneath it, asserts
safeShot() throws, and asserts that no file exists afterwards. Delete the check in
capture-safe.js and the smoke test goes red, which is the only reason to trust the
paragraph above.
allowUnredacted: true is the deliberate override for a screen that genuinely has nothing
to cover. It is a per-call argument rather than a config key or an environment variable so
that turning it on is visible in the script that did it.
- Isomorphic SmartClient specific.
lib/isc.jsandlib/isc-nav.jstalk towindow.iscdirectly. Against any other framework they are dead weight, though the guard, the redactor andcapture-safe.jsare framework-agnostic. - Chromium only. The CDP trusted-input path and the redaction verification assume it.
- The
navigationgeometry is per-shell.tabBarYandadminMenuItemPixelare measured pixel values for one application's chrome. They are config keys rather than constants for that reason, but they still have to be measured. - The redactor depends on an authoring convention. It identifies a field's value by
adjacency to its label, and a grid value by the column it sits under. An unusual layout
defeats it, which is what
requireBoxesis there to catch. - English label vocabulary in the shipped
guard.forbiddenTermsand redaction lists. - The OCR and template tiers need setup you must do. Tesseract is frequently installed
without being on
PATH, hencetesseractCandidates. No template crops ship with the kit, because template crops are screenshots of your application. - The vision tier sends an image to whatever you configure. It is
nullby default and absent from the defaulttierslist. Leaving it that way guarantees no capture leaves the machine. Turning it on is a decision about where your screenshots go. - The guard matches control labels, not intent. It blocks a button reading "Save". It cannot block a keyboard shortcut, a form that submits on Enter, or a button labelled something the list does not name. It reduces the risk of mutating a shared environment; it does not remove it. Capture against a non-production environment.
Built while running documentation for a healthcare SaaS platform, where it captured release screenshots against shared non-production environments containing realistic data. The three failure modes in "Why this exists" are all things that actually happened, and most of the odd-looking defensiveness in this codebase is a specific scar.
MIT. See LICENSE.