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

  1. 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 (marked data-route-not-found), within WAIT_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.
  2. Collects every console message, page error and failed request.
  3. Navigates to the route, if one was given, and asserts it landed there.
  4. Runs the interaction and assertion hooks in their fixed order.
  5. Reads the visible text of main, runs the layout check, and screenshots to scratchpad/<SHOT>.png (app by default).
  6. Prints the URL, the measured viewport, the layout findings, the time to content, an ACTIONS block with ok or FAIL per 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

EnvEffect
PROFILE=chadWrite 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=WxHSet the page’s size
SHOT, WAIT_MS, NAV_DEBUG=1The 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:

EnvEffect
ROUTE=/x, or the first argumentChange the hash, then assert the route landed is the route asked for
SETTLE_MSPause before the interactions, for content that arrives after the first paint (Home’s cards)
PRESS_FIRSTKeys sent before any click: a chord that opens a panel with no button (the appearance panel’s mod+,)
DUMP_LABELS=1List every control’s accessible name and where it came from
CLICK_TEXT, CLICK_TEXT2Click 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_LABELClick by accessible name, for icon-only buttons; re-queried at each step
CLICK_SELClick 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_LASTChoose an option in the first <select>, or in the last one (a list the first choice revealed)
CLICK_LABEL2Click 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_AFTERClick 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=1Click button[type=submit] and report the outcome
RECORD_CREATE=1Fill a record form: the first reference option, every number field 5, then submit
OPEN_PALETTE=1Ctrl+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=1Wait 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:

EnvAsks
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=1Every 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_AFTERWhether 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 ok or FAIL in the ACTIONS block, through the step(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.
  • SUBMIT reports 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 evaluate is 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.
  • ATTR requires the element to be visible. A display: none element 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

queryconsultsblind to
role or accessible name (getByRole, DUMP_LABELS)the accessibility treeanything hidden: it cannot see it at all
selector or attribute (ATTR, INSIDE, querySelector)the DOMwhether 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: no is 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 what SQUEEZED and ESCAPING main catch. The screenshot is still the real check.
  • VIEWPORT sticks. The size set on the attached page persists, so a run without VIEWPORT inherits 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. tsx compiles with esbuild’s keepNames, which wraps const f = () => {} in a __name(...) helper the page does not have, and the whole evaluate dies.
  • From Git Bash, prefix MSYS_NO_PATHCONV=1, for ROUTE= as well as the argument. A bare /trackables becomes C:/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_SEL on one waits forever on a dropdown CDP cannot see; use SELECT.
  • FOCUS presses Tab first. Chromium matches :focus-visible by 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.

Testing · Renderer Dev Loop · Migration Runner · DB Dev Commands · Distribution