Skip to content

test(e2e): a deterministic visual baseline, and the hostile content Phase 7 could not seed - #776

Merged
YaelAnaya merged 4 commits into
mainfrom
test/deterministic-visual-regression
Aug 25, 2026
Merged

test(e2e): a deterministic visual baseline, and the hostile content Phase 7 could not seed#776
YaelAnaya merged 4 commits into
mainfrom
test/deterministic-visual-regression

Conversation

@YaelAnaya

Copy link
Copy Markdown
Collaborator

Ten reference images and five hostile-content scenarios, both built on one typed
fixture layer that answers every request the surfaces make.

These were the two things Phase 7 deferred on purpose. It declined to add
screenshots because the product renders live thumbnails, relative timestamps and
generated ids, and a suite that fails on those is noise rather than protection; and
it could not seed a long class name or a long model reference because the write
path refuses those shapes. Both objections are answered by making the data typed
and fixed rather than by relaxing what a screenshot is allowed to contain.

New dependencies: 0. Playwright's own comparison does the work.

The canonical environment

A screenshot compares two renderings, so everything the renderer reads is stated
rather than inherited.

image              mcr.microsoft.com/playwright:v1.62.1-noble
os                 Ubuntu 24.04 LTS
node               v24.18.1
pnpm               10.30.2
playwright         1.62.1
chromium           151.0.7922.34
deviceScaleFactor  1
locale             en-US
timezone           UTC

The visual project owns those and is excluded from the default project, so the
ordinary suite does not compare images. Baselines are generated in this container
and nowhere else: font rasterisation differs between operating systems, and a
baseline captured on one and compared on another reports that difference as a
product regression.

What earns a baseline

Five surfaces in both themes, chosen for the variety of design system each puts on
screen rather than to cover routes.

Surface Why it earns one
Projects (page) The shell and a data table at once — rail, header, row rhythm, thumbnail column
Schema (desktop dark, narrow light) The form vocabulary, and the wrapped action row Phase 7 fixed
Models Cards, badges and technical text, where a grid's geometry regresses unnoticed
An open menu The only surface with a deliberately inverted palette
The annotator The densest chrome in the product and its highest icon count

Home, Batches, Dataset, Gallery and Ingest add no vocabulary these five do not
already carry, and a baseline that mostly photographs empty space fails for reasons
nobody can read.

Most are locator screenshots. Projects and the annotator are page screenshots
because the composition between shell and content is itself the subject. Nothing
uses fullPage, which would photograph a scroll position as well as a layout.

maxDiffPixels is 0 and stayed 0. The first run was already stable here, and a
tolerance added before a failure is understood is a tolerance that hides the next
regression.

Determinism

run 1   10 passed
run 2   10 passed
run 3   10 passed

Three consecutive runs, no baseline update between them.

What it rests on: identifiers are literals; timestamps sit in 2024, past
formatWhen's one-week relative window, so a "Created" cell prints Mar 14, 2024
instead of an age that changes while you read it; images are one inlined 1×1 PNG;
fonts are awaited before capture; motion is settled by animations: "disabled" at
capture time.

Not by asking the context for reduced motion, which was the first attempt and is
wrong twice over: the type does not exist on use in this version, and the product
honours that setting by collapsing its animations — so a baseline captured under it
would describe a mode most people do not have.

Hostile content

Case Covered
long project name yes
long project description yes
long class name yes
long model reference yes
long connection name yes
large annotation count yes, at 120
long refusal prose yes
long technical identifier yes, as the content hash

These assert layout rather than appearance — whether the page scrolls sideways,
whether a card escapes its column, whether the annotations region still owns its
own scrolling. A picture is a poor way to ask any of those, and the baselines next
door cover what a picture is better at.

Both halves were proved by breaking them. Removing the description clamp fails with
a long description must not stretch its row; removing the schema row's wrap fails
with Schema at 320px with a long class name scrolls the document sideways.

No new product defect was found. Every hostile case passes against current
main, which is the expected result after Phase 7 fixed the responsive floor and is
worth stating rather than leaving as an absence.

Three things the work turned up

The annotator would not render, and the fixture was at fault. All eight requests
answered 200 with no console error, and the screen stayed on "Loading the job".
AnnotationOut.id is format: uuid and job_id is required; readable ids like
annotation-0007 are rejected by the generated runtime checks before the screen
sees them — and a rejected page is indistinguishable from a slow one. An
as unknown as cast is what let the wrong shape through, so the annotations are
typed now.

The first Projects baseline was mostly empty. One row renders the same
components but protects a rhythm it never shows. The list is four rows deep, and the
image carries row height, dividers, the thumbnail column in both its states, an
absent description and a sort column with something to be right about.

A failing stress test that was not a defect. The long-class-name scenario failed
while the page overflow measured 0: the text matches two nodes, the rendered one and
a zero-sized span beside it, and the assertion had picked the measurement node. It
anchors on the card now.

Test plan

Every command ran inside Docker/Linux; no repository validation ran natively on the
host.

install (pnpm install --frozen-lockfile)  PASS
build (pnpm -r build)                     PASS
@visionset/ui-core lint (incl. typecheck) PASS
@visionset/ui-core test                   PASS
@visionset/app build                      PASS
@visionset/app lint (incl. typecheck:e2e) PASS
@visionset/annotator test                 PASS
pnpm test:scripts                         PASS
pnpm version:check                        PASS

full browser suite (all projects)         276 passed
  261 pre-existing · 5 hostile-content · 10 visual
visual project, three consecutive runs    10 / 10 / 10

The update command is in
docs/content/architecture/frontend/visual-baselines.md, and there is no
host-native path to it.

Behavioural coverage

Nothing was deleted to make room for a screenshot. Navigation, accessible names,
focus, keyboard, requests, sorting, mutation and overflow assertions all remain
where they were; the baselines are additional.

Overlap

No file overlap with any open PR. One semantic note worth carrying: the frontend
dependency bump (#775) raises shadcn from 4.18.0 to 4.19.0, and that package is a
runtime CSS dependency here — styles.css imports shadcn/tailwind.css. It does
not touch Playwright, so the environment is unaffected, but if the bump moves base
styles this suite will go red on merge. That is the gate working. Read the diff
images before regenerating.

Scope

Routes changed:            No
Product IA changed:        No
Backend / wire / domain:   No
Design tokens changed:     No
Primitive API changed:     No
Annotator behaviour:       No
Production UI changes:     None

Remaining debt

Test infrastructure: the baseline covers five surfaces, and Batches, Dataset,
Gallery and Ingest could earn one later if they grow vocabulary these do not hold.
Nothing here is design-system debt.

A screenshot is only worth keeping if the same code draws the same picture
tomorrow, so this answers every request the reference surfaces make and none of it
reaches a workspace. Identifiers are literals. Timestamps sit in 2024, past
`formatWhen`'s one-week relative window, so a "Created" cell prints a fixed date
rather than an age that changes while you read it - the product's own formatting
is exercised and no clock is patched. Images are one inlined 1x1 PNG.

The hostile values Phase 7 deferred are here as literals too: the write path
refuses some of those shapes and the rest need a workspace nobody has, which is
why that pass could only ever measure a long project name.

No React component is mocked and no screen is replaced. The application under the
snapshot has to be the application or the snapshot is worth nothing.

Annotations are typed rather than cast. `AnnotationOut.id` is `format: uuid` and
`job_id` is required, and the generated runtime checks enforce both - a readable
`annotation-0007` is rejected before the screen sees it, and a rejected page is
indistinguishable from a slow one because the workspace simply never leaves
"Loading the job". The cast that hid that is gone.
…onment

Five surfaces in both themes, chosen for the variety of design system each puts on
screen rather than to cover routes: the shell over a data table, a form, a card
grid, an open menu, and the annotation workspace. Between them they hold the
tokens, both bundled fonts, the icon set, the radius scale, the card and dialog
geometry and the deliberately inverted menu palette - which is the one surface
where a `menuColor` regression can show at all. Home, Batches, Dataset, Gallery and
Ingest add no vocabulary these five do not already carry.

The `visual` project owns the environment because a screenshot compares two
renderings and everything the renderer reads has to be stated: locale decides what
a "Created" cell says, timezone decides which day it lands on, and
`deviceScaleFactor` decides how many pixels a CSS pixel becomes. It is excluded
from the default project, so the ordinary suite does not compare images.

`maxDiffPixels` is 0 and stayed 0. The first run was already stable in this
container, and a tolerance added before a failure is understood is a tolerance that
hides the next regression.

Motion is settled by `animations: "disabled"` at capture rather than by asking the
context for reduced motion: the product honours that setting by collapsing its
animations, so a baseline captured under it would describe a mode most people do
not have.

Every image was opened and read before it was committed. The first Projects
reference was seven parts empty space to one part table, which protects a rhythm it
never shows; the list is four rows deep now, and the picture carries row height,
dividers, the thumbnail column in both its states, an absent description and a
sort column with something to be right about.
Five scenarios over layout rather than appearance: a long project name and
description, a long class name, a long model reference, a hundred and twenty
annotations, and a long refusal. Each asserts what a picture is bad at answering -
whether the *page* scrolls sideways, whether a card escapes its column, whether the
panel still owns its own scrolling - and leaves the look of it to the baselines
next door.

Phase 7 measured the responsive floor against whatever data happened to exist,
which is how a long description reached production and stretched a table row to
five hundred pixels. These are the shapes it could not reach: the schema write path
refuses one of them and the rest need a workspace nobody has.

Both halves were proved by breaking them. Removing the description clamp fails
"a long description must not stretch its row"; removing the schema row's wrap fails
"Schema at 320px with a long class name scrolls the document sideways".

The class-name scenario anchors on the card rather than on the text. Matching the
name reaches two nodes - the rendered one and a zero-sized span beside it - and
asking the first of those to be visible asserts something about a measurement node
while the page underneath is perfectly fine.
Where the reference images come from, what pins the environment they are produced
in, and the one command that updates them. Baselines are not generated on macOS or
Windows: font rasterisation differs between operating systems, and a baseline
captured on one and compared on another reports that difference as a product
regression.

The update path is deliberately a separate command rather than a flag an ordinary
run can reach, and the note says the part that matters - look at the images, then
compare again. A regenerated baseline nobody opened is a regression written down as
the new truth.
@YaelAnaya
YaelAnaya merged commit f099914 into main Aug 25, 2026
15 checks passed
@YaelAnaya
YaelAnaya deleted the test/deterministic-visual-regression branch August 25, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant