Visual assertions and network config
Visual assertions, web network mocks, browser config, and pinning randomness and the clock for determinism.
- assert_screenshot:
name: dashboard # baseline PNG name (no path, no extension)
mask: ["css:.clock", "Sync"] # optional: selectors blanked before compare
threshold: 0.001 # optional: fraction of pixels allowed to differ (default 0)record captures the surface, blanks each mask's element rect, and mints
<spec-stem>.baselines/<name>.png next to the trace — re-recording (or
record --reuse) is how baselines refresh. Replay captures with the
same masks and compares pixel-exact (up to threshold); on failure
the run bundle gains visual/<name>.actual.png and visual/<name>.diff.png
(differing pixels in red) and the message names the diff percentage.
Masks take the same forms as quoted labels (text anchor, css:, id:)
and every mask must resolve — a silently-unmasked volatile region would
mint a flaky baseline. Pin the viewport with browser: so capture
dimensions stay stable across machines.
Network mocks (web flows; spec-level, not steps)
mock:
- url_contains: /api/rates # substring match on the request URL
method: GET # optional; any method when absent
status: 200 # optional; default 200
body: # any YAML: string served verbatim
rate: 1.23 # (text/plain), anything else as
source: mocked # JSON; content_type: overridesRequests matching a rule are answered inside the browser — the real host
is never contacted (it need not even exist). The rules travel in the
trace header and apply identically at record and replay: what was
mocked once is mocked always, which is what keeps the two executions
equivalent. Mocked responses carry permissive CORS headers and answer
preflights, so cross-origin fetch() calls just work. The tool for
third-party calls (payments, analytics) and hard-to-provoke server
states; for asserting on real APIs, use assert_api instead.
Browser config (web flows; spec-level, not steps)
browser:
viewport: # device emulation, applied before navigation
width: 390
height: 844
device_scale_factor: 3 # optional; default 1
mobile: true # optional; mobile layout + meta-viewport
touch: true # optional; emulate a touch screen
user_agent: my-agent # optional; navigator.userAgent override
args: ["--lang=en-US"] # optional; extra Chrome flags
clock: # optional; pin the clock (GAP-P)
at: "2026-01-15T12:00:00Z" # required; RFC 3339, a mid-day time
timezone: "Europe/Berlin" # optional but recommended; IANA idThe config travels in the trace header and applies identically at
record and replay — a flow recorded on an emulated phone never replays
on a desktop viewport. This is how *.mobile test variants and
deterministic-seeding user agents (previously an env-var wrapper around
Chrome) become first-class. args forces a private (non-shared) browser
for the flow, since flags only apply at process start — expect its cold
start. A suite's suite.yaml may carry the same browser: block as a
default for every flow; a flow's own block wins outright.
Pinning randomness
browser.random replaces the page's Math.random with a seeded PRNG,
injected before any page script for the same reason the clock's shim is: a
page that has already drawn a random number cannot be un-randomised
afterwards.
browser:
random:
seed: 1234Same argument as the clock, applied to the other source of per-run drift. A
page that mints a value from Math.random shows something different every
run, so the only honest thing to write against it is another read — and for
a value the flow must ENTER rather than compare, there is nothing to read.
Pinned, the value is a constant you can write by hand, and record and every
replay see the same one.
seed is a literal, never a ${VAR}: a seed resolved from the
environment would make one trace mean different things on different
machines, which is the drift pinning exists to remove. Web-only; a
random: block on any other app kind is a spec error naming the
restriction, because there is no Math.random to pin on a desktop window
or an OCR frame.
Deliberately narrow, and stated rather than discovered:
crypto.getRandomValues is untouched (it is a security primitive, not a
convenience), web workers get their own real Math.random, and
server-side randomness is mock:'s job.
The seed pins the SEQUENCE, not the position. The page draws the same series of numbers every run; which of them reaches the value you care about depends on how many draws the page made first. A page whose earlier scripts draw a variable number of times — an animation that fires once or twice depending on timing — can still hand you a different value, taken from the same series one place along. Observed once in the wild against a page that generates on focus: the value was stable across six runs and shifted by exactly one draw on a seventh.
So assert the drawn value before you use it:
- assert: the "id:no1" shows 91 # the draw itself
- assert: the "id:no2" shows 98
- Type 189 into the "id:result" field # the constant derived from itWithout the first two lines a shifted sequence types a confidently wrong answer and fails somewhere else entirely, or worse, passes. With them, the shift is the failure — which is the whole reason a pinned value is worth asserting even though it is "constant".
Pinning the clock
browser.clock freezes what the page reads as "now", so a date-dependent
flow is deterministic — a "last 7 days" filter, a "renews in N days"
label, a relative timestamp, a picker that opens on the current month. The
clock STARTS at at and advances at real wall rate (it is a fixed offset
on Date, not a hard freeze), so pick a mid-day at and no step will
straddle a pinned midnight. Both fields are literals, never ${VAR}: a
precondition that varied by environment would not be one. Set timezone
whenever you set at — without it, local dates and week boundaries still
depend on the runner's zone.
What it does NOT cover, by design:
- server-side "today" — a date the SERVER computes (an SSR page, an API
returning a relative window) is untouched; pin those with a
mock:rule instead. - web workers see the real clock; only the main frame's
Dateis pinned. performance.now()and timer scheduling are not shifted.
Clock control is web-only; a clock: block on any other app kind is a
parse error.