The WebView’s console is not forwarded to the terminal, so an error inside the app is invisible from stdout, and a plain browser cannot open the app’s database. WebView2 is Chromium, so it speaks the Chrome DevTools Protocol: the app is launched with remote debugging on, and scripts/drive/probe.ts attaches to it with playwright-core’s connectOverCDP to read, drive, measure and screenshot the real app. It is the real-window layer of Testing.
Running it
Launch the app with remote debugging, and with DATLER_BACKGROUND=1 so the window opens behind others and never takes the keyboard while the probe reloads it:
DATLER_BACKGROUND=1 WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222 pnpm run dev
Then attach:
pnpm run drive # Home
MSYS_NO_PATHCONV=1 pnpm run drive -- /trackables # a route
VIEWPORT=375x812 MSYS_NO_PATHCONV=1 pnpm run drive -- /visualizations # a phone's size
The probe connects to http://localhost:9222 (CDP_URL to change it) and waits for the app on its own, retrying the connection for 90 seconds, so one foreground call rides out the Rust build. Routes are reached by changing the hash, since the app uses hash history. The launch variables stay out of committed config: they are dev-only flags. playwright-core is a root dev dependency.
What a run does
- Applies the session hooks, then loads the app’s base URL and measures time to content: until the routed content (the scroll area’s viewport, else
main) has text, no line reading “Loading…” and no not-found page (markeddata-route-not-found), withinWAIT_MS(8 s by default). A screen that stalls only on its first visit in a dev build may be the dev server bundling a dependency; see Renderer Dev Loop. - Collects every console message, page error and failed request.
- Navigates to the route, if one was given, and asserts it landed there.
- Runs the interaction and assertion hooks in their fixed order.
- Reads the visible text of
main, runs the layout check, and screenshots toscratchpad/<SHOT>.png(appby default). - Prints the URL, the measured viewport, the layout findings, the time to content, an
ACTIONSblock withokorFAILper hook, the visible text and the console. It exits 1 if any hook failed.
It never calls browser.close(): on a CDP-attached WebView2 that closes the app itself. The process exits and lets the socket drop.
The browser build and the phone
The --mode web build drives the same way. Serve it with pnpm --filter @datler/renderer dev:web (port 4280) or build:web then preview:web (4281), start Chrome headless so no window takes focus, and point the probe at it:
chrome --headless=new --remote-debugging-port=9223 --user-data-dir=<scratch dir> http://localhost:4281/
CDP_URL=http://localhost:9223 PROFILE=chad pnpm run drive
A fresh --user-data-dir is a fresh browser whose profiles start empty, so a demo needs CLICK_TEXT="Load the demo data" once. A phone’s Chrome is reachable over USB the same way: adb reverse tcp:4281 tcp:4281, then adb forward tcp:9224 localabstract:chrome_devtools_remote and CDP_URL=http://localhost:9224.
Hooks
Every hook is an environment variable, and they run in a fixed order: that order is how a multi-step flow is written, by choosing hooks rather than scripting. A list inside one hook is separated by |, or by |@| where the values themselves may hold a |.
Before the load
| Env | Effect |
|---|---|
PROFILE=chad | Write the open-profile pointer (datler.activeProfile) to localStorage, so the load opens that profile |
CLEAR_KEYS="a,b" | Remove those localStorage keys (an onboarding flag datler.onboarded.<profile>, say), so a flow can start clean |
SET_KEYS="k=v,k2=v2" | Write localStorage keys, such as a theme axis (datler:theme:data-mode=dark) |
PRUNE_PROFILES="Foo,Bar" | Drop just those entries, by name or label, from the made-in-the-app profile list (datler.userProfiles) |
VIEWPORT=WxH | Set the page’s size |
SHOT, WAIT_MS, NAV_DEBUG=1 | The screenshot’s name; the time-to-content limit; print the URL and the nav links found, once the page has loaded |
The desktop app restores the profile state from its profiles.json over localStorage before it opens a database (datler.activeProfile, datler.userProfiles, datler.demoOrigin, datler.pendingDeletes). So after the setup hooks write localStorage, the probe saves it as the profile state through the app’s own persistProfileState, the way a switch does, and the load reads what the hooks wrote; in the browser build that save does nothing. PROFILE in the desktop app therefore changes which profile the app opens next time too, until something switches back.
Navigation and interaction, in the order they run:
| Env | Effect |
|---|---|
ROUTE=/x, or the first argument | Change the hash, then assert the route landed is the route asked for |
SETTLE_MS | Pause before the interactions, for content that arrives after the first paint (Home’s cards) |
PRESS_FIRST | Keys sent before any click: a chord that opens a panel with no button (the appearance panel’s mod+,) |
DUMP_LABELS=1 | List every control’s accessible name and where it came from |
CLICK_TEXT, CLICK_TEXT2 | Click the element whose trimmed text equals it: a <button> if one matches, else the first link, [role=button] or [role=option]; the second takes a sequence |
CLICK_LABEL | Click by accessible name, for icon-only buttons; re-queried at each step |
CLICK_SEL | Click by CSS selector (>> nth=N picks one of many), for what is deliberately not a control: a chart’s hit band, a grid cell |
SELECT="selector|label" | Choose an option in a native <select>, which no click can drive (its dropdown is drawn by the system, outside CDP’s reach) |
DRAG="Src->Tgt" | A real HTML5 drag, dragstart to dragend with one shared DataTransfer, because a mouse-based drag emits none of the native events the drag-and-drop code listens for |
CHECK="Label" | Click the <label> containing that text, to toggle its checkbox |
DERIVED_CREATE=<name> | Fill and submit the derived-aggregate form, once CLICK_TEXT has chosen it |
SELECT_LABEL, SELECT_LAST | Choose an option in the first <select>, or in the last one (a list the first choice revealed) |
CLICK_LABEL2 | Click by accessible name after the selects, for a control a choice revealed |
FILL="prefix=value" | Fill inputs by placeholder prefix, falling back to the accessible label; the first = splits |
CLICK_AFTER, CLICK_SEL_AFTER | Click by text, or by selector, after the fills: a confirm that is not the form’s submit, an icon button after typing |
PRESS="ArrowDown|Enter" | Keys sent to the focused element |
SUBMIT=1 | Click button[type=submit] and report the outcome |
RECORD_CREATE=1 | Fill a record form: the first reference option, every number field 5, then submit |
OPEN_PALETTE=1 | Ctrl+K; then PALETTE_TYPE types a filter, CLICK_ROW=1 expands the highlighted row with ArrowDown, PALETTE_CLICK clicks a button inside it and PALETTE_PRESS sends keys inside it |
POST_WAIT_MS, DUMP_LABELS_AFTER=1 | Wait for late data; list the accessible names again, now that everything has been opened |
CLICK_ROW uses ArrowDown rather than a click because the palette is a plain scrim with no role=dialog, so an [aria-expanded] selector finds the shell’s toggle behind it and the click waits out its timeout.
Assertions and readers, in the order they run: WHEEL through RECT after SELECT and before DRAG, then ACTIVE_AFTER and TEXT after PRESS and before SUBMIT:
| Env | Asks |
|---|---|
WHEEL="selector" | Hover it, turn the wheel, report scrollTop before and after: whether it really scrolls |
INSIDE="child|ancestor" | Whether the first child is inside the first ancestor, for a popover that must render within its dialog |
ATTR="selector|name=value" | An attribute on the first match, which must be visible (selector|name for presence), reporting the actual value on a mismatch |
COMPUTED="selector|property=value" | A resolved computed style, read off the element that uses it |
LABELLED="selector" | The accessible name, resolved as a browser does: aria-labelledby, aria-label, a wrapping or for label, the contents for a role named by them (a button, a link, a heading), title |
VALUE="selector" | What a form control holds (the value property; checked for a checkbox) |
MEASURE="selector" | The raw scroll numbers of every match |
SCROLLERS=1 | Every element that really scrolls, and whether a keyboard can reach it |
FOCUS="selector" | After a Tab, whether :focus-visible matches and the outline and shadow it draws |
ACTIVE, ACTIVE_AFTER | Whether focus is inside the selector: after opening something, and after PRESS closed it |
TAB_TRAP="selector|n" | Press Tab n times; focus must never leave |
RECT="selector" | The box against the viewport, measured at rest; selector|full-width asserts it spans the width |
TEXT="selector" | An element’s text once the interactions have run, for live regions; empty is reported as empty, the correct state of a live region at rest |
After the hooks, the layout check reports H-OVERFLOW (the page wider than the window), SQUEEZED (a form control under 40 px wide) and ESCAPING main (a control past the main column’s right edge, ignoring anything inside an overflow-x: auto ancestor, since a scrollable wide table is fine).
The probe cannot report a pass for something that did not happen
A check that cannot tell “it worked” from “it did not run” is not a check, and every rule here serves that:
- Every action reports. Each hook records
okorFAILin theACTIONSblock, through thestep(label, fn)wrapper or by calling the same recorders itself, the load and the screenshot included, and a selector that matches nothing is a FAIL. Any FAIL exits non-zero, so a run that did nothing breaks a shell chain instead of reading green. The only waits that may fail silently are the settling waits, since failing to settle is not failing to act. SUBMITreports the outcome, not the click. Every form leaves its page on success, so a submit that stays put is a FAIL and the form’s error text is quoted: an unchanged URL is exactly what a submit blocked by validation looks like.- No
evaluateis swallowed. A silent fallback reports zeros, the false clean a check exists to prevent; a layout probe that throws is a FAIL. - A mistake the probe cannot detect is refused. A route with a query string passed as an argument is refused, because npm cuts it at the first
&before the probe starts, and nothing inside could tell;ROUTE='…'passes it whole. ATTRrequires the element to be visible. Adisplay: noneelement still matches a selector and returns its attributes, but announces nothing, so an ARIA attribute on it is always the wrong assertion.
After the load the probe checks the setup hooks it can: PROFILE is the profile open, and no profile PRUNE_PROFILES named is left; each is an ok or a FAIL. A content wait that runs out is a FAIL, the first one as well as the one after a route change, and a route that renders the app’s not-found page (data-route-not-found) fails as Not Found rather than passing because its address matched.
A change to the probe is shown to fail before it is trusted. PALETTE_CLICK against a label that matches nothing exits 1 with a FAIL line, and against a real one exits 0; a hook that cannot be made to fail has not been shown to work. Equally, a check is chosen for the risk it has to catch, not for what is easy to measure: a table header can line up to the pixel and still expose no column headers to a screen reader, so a check of its geometry passes what is broken.
Waiting for the page to settle
After a click, clickByText, clickByLabel and CLICK_SEL call settleOverlays(), which waits until the DOM has had no mutations for 90 ms, capped at 1.5 s. One rule covers opening (mount, then fade-in) and closing (fade-out, then unmount), and it is right when motion is turned off.
It deliberately knows no duration. The overlay’s animation lives in the component library’s stylesheet, which this repo does not control and which is none under prefers-reduced-motion, so a fixed pause would be tuned against one machine’s animation and wrong on the next. Two other rules fail in opposite ways: waiting for the hidden-page marker to clear never ends while a dialog is legitimately open, since a dialog holds it for its whole life; and waiting for the marker count to stop changing returns before React has committed anything, so the menu has not mounted and the next hook reports it missing.
Closing an overlay leaves the page briefly unreachable. Until the closing animation’s cleanup runs, the rest of the page stays aria-hidden and inert, so a role query fired at once finds nothing. A chain that drives two controls in sequence lands in that window and reports "b" matched nothing, which points at the selector, the one thing that is fine; the settle is what keeps the window shut.
A CSS transform fires no mutations, so the settle returns while a drawer is still sliding. RECT therefore turns off every transition and animation and waits two frames before it measures, which makes the value the resting one by construction, where a mid-slide value would read exactly like a layout fact. Where transitions cannot be killed, the cheaper checks are to assert the resting class (translate-x-0), or to mount a fresh element with the same classes beside the one under test and compare.
Which query answers which question
| query | consults | blind to |
|---|---|---|
role or accessible name (getByRole, DUMP_LABELS) | the accessibility tree | anything hidden: it cannot see it at all |
selector or attribute (ATTR, INSIDE, querySelector) | the DOM | whether the thing is hidden |
Using one where the other is needed gives a confident wrong answer either way, and only one fails loudly: the role query errors, the DOM query passes. That is why the DOM hooks carry the visibility guard. Asserting that something is hidden takes both: a DOM query proving it exists and a role query proving it cannot be reached.
WHEEL and INSIDE answer one question two ways. A dialog’s scroll lock allows only its own subtree, so a popover portalled outside the dialog renders a scrollbar and swallows the wheel, which nothing in the DOM or a screenshot shows. WHEEL proves it when the list overflows; a short list never overflows, so INSIDE asks the structural question instead, since focus traps and inert stop at the same boundary. Overflow is never manufactured with fake data to make WHEEL usable.
DUMP_LABELS prints where each name came from (button: Unit (label[for])): a trigger named only by its own value text can have no name for a moment while it closes, so a name from a label is the durable one.
Gotchas
H-OVERFLOW: nois not a responsive pass. A flex row can crush its children to slivers or push them past a card without the page ever scrolling sideways, which is whatSQUEEZEDandESCAPING maincatch. The screenshot is still the real check.VIEWPORTsticks. The size set on the attached page persists, so a run withoutVIEWPORTinherits the last one’s size; the probe prints the measured size and says when it was inherited. Pass it whenever size matters.- No named functions inside
page.evaluate.tsxcompiles with esbuild’skeepNames, which wrapsconst f = () => {}in a__name(...)helper the page does not have, and the whole evaluate dies. - From Git Bash, prefix
MSYS_NO_PATHCONV=1, forROUTE=as well as the argument. A bare/trackablesbecomesC:/Program Files/Git/trackables, while a longer route with a?survives, so it fails selectively; the route assertion names the cause when it sees it. - A native
<select>cannot be clicked.CLICK_SELon one waits forever on a dropdown CDP cannot see; useSELECT. FOCUSpresses Tab first. Chromium matches:focus-visibleby the last input’s kind, so after a click a programmatic focus shows no ring; the Tab puts the page in keyboard mode, which is the state the question is about.
One-off flows
A flow too specific for the hooks is sometimes written as a scripts/drive/*-flow.ts file that connects over CDP itself. Those files are gitignored and left out of the scripts’ type-check: they are a session’s scratch, not tooling. A flow that recurs becomes a hook in the probe.
Related
Testing · Renderer Dev Loop · Migration Runner · DB Dev Commands · Distribution