๐ฏ End-to-End Tests (e2e)
These tests verify the full application stack, from the frontend UI down to the database.
๐ฏ Purpose
To ensure that user flows (e.g., logging in, viewing the dashboard) work correctly in a real browser environment.
โ Prerequisites
None to arrange by hand: the runner starts the backend and the frontend build for you. The one thing
to check is that port 6041 is free โ lsof -ti:6041 must come back empty.
๐ Key Tests
- Login Flow: Verifies that a user can log in and is redirected to the dashboard.
- Dashboard: Verifies that the dashboard loads and displays data.
๐ Running
โ The semantics: verify, never assume
Every spec shares one database and one backend with every other spec, and increasingly shares
them at the same time. A spec that assumes it is alone is not simpler โ it is broken, and running
one worker was only hiding it. The normative form of the rules lives in
.github/instructions/frontend-testing.instructions.md; what follows is why they exist.
Position
Never .first() on an unfiltered locator, never .nth(0), never "the first row". Filter to
something the spec itself created โ a name carrying a timestamp โ and if the table pages, walk
the pages until you find it. .first() on an already-filtered locator is fine: it resolves to one
element by construction.
Count
toHaveCount(4) is a claim about everyone else's data. toHaveCount(1) on a locator filtered to
your own row is a claim about yours.
Time
waitForTimeout() is forbidden. It is a bet on machine speed that concurrency loses, and it is the
single largest source of dead time in the suite โ 885 calls declaring 410 seconds of sleep per
run when they were first counted.
Wait for the condition that matters, not for the clock:
// โ waits for the field to exist, not to be filled
await page.waitForTimeout(2000);
await expect(input).toBeVisible();
// โ
await expect(input).not.toHaveValue('');
And when there is nothing to wait for
That is the interesting case, and it is not a prompt to find a cleverer selector. If no observable signal says the operation finished, the user cannot tell either: the sleep is the symptom, the diagnosis is that a state of the system is invisible.
The fix serves both at once โ publish the state the component already has:
<!-- the list loads in two waves: rows first, then prices per row -->
<div data-testid="assets-page" aria-busy={busy} data-busy={busy ? 'true' : 'false'}>
The same contract exists for charts. ECharts emits finished when a render pass โ
animations included โ completes, but nothing in the DOM says so. attachChartReady()
(frontend/src/lib/utils/chartReady.ts) publishes it on the chart container:
data-chart-ready flips to 'true' after the first render, and data-chart-renders counts
completed passes so a re-render (new range, new series) can be awaited without a stale
true letting the reader through too early.
On the spec side, expectChartCanvas(page, testId) (frontend/e2e/fixtures/charts.ts)
asserts the container holds a rendered chart โ container and <canvas> visible, non-zero
CSS box and non-zero bitmap โ with both size checks under expect.poll because ECharts
attaches and sizes the canvas asynchronously after mount. Asserting the container alone stays
green while ECharts never draws inside it; that is the exact regression shape this helper
exists to catch.
A signal can exist and still be lying
ImageEditModal sets data-cropper-ready as soon as the cropper can paint โ but the modal
keeps discarding change events for another ~500 ms while it runs its own reset pass. Edits
made in that window disappear, for the spec and for the user, who can then close the modal and
lose them without a warning. The spec had answered with waitForTimeout(1500). The fix was to
publish the state that actually decides (data-edit-ready), not to sleep longer.
When you write "extra settle time", "let it load", "wait for X to finish" in a comment, you have just named a state the product does not expose.
๐งต Parallelism: earned, not assumed
The suite runs fully parallel, and the unit of parallelism is the test, not the file: one worker interleaves tests from many spec files against one backend and one database. That was not the starting point โ it was earned, category by category, by running each at four workers until it was green and fixing what came out.
| what | means |
|---|---|
--workers N |
how many browsers. The backend follows: one uvicorn worker per two browsers, never fewer than one |
fullyParallel: true |
the default. Tests are distributed individually; a worker does not own a file |
test.describe.configure({mode: 'serial'}) |
this block opts out โ and says why in a comment |
The order that got us here still applies to any new area: first run it parallel, then read the reds, then fix them, and only then declare an exception. A declaration added without a run behind it is a guess with better syntax.
E2E_FORCE_PARALLEL is gone
It existed to override fullyParallel: false, which no longer exists. And it never could have
overridden a mode: 'serial' block โ Playwright gives the config no such knob. Re-testing an
exception means commenting its declaration out, running the category, and deciding with the
result in hand.
What the reds actually turned out to be
Across every category promoted, not one red was a write conflict between two specs. They were all specs asking a question whose answer happened to be stable at one worker:
- "is it on the first page?" asked as if it meant "does it exist?" โ the tables paginate client-side over the whole dataset, so a neighbour's row changes the answer;
- "has it finished?" asked with
waitForTimeoutโ a bet on machine speed that four workers lose; - "did the write succeed?" asked as
response.ok()โPOST /transactions/commitanswers 200 with a rolled-back batch when a business rule fails, and reports the ids the rows would have had. Checkcommitted, and treat per-itemstatus: "simulated"as "this row does not exist"; - "is the row still gone?" asked by id โ SQLite reuses the highest rowid the moment you delete it, so an id you freed is very likely already someone else's row. Assert absence on a marker you generated;
- "do I still own this position?" asked by selling a fixture holding โ
SELL create โ commitsold a quantity a neighbour had already consumed. A test that needs a precondition creates it: the batch now deposits, buys and sells in one commit; - "is this feature supported?" asked by looking for the section that a capability catalog gates โ
the section is absent while the catalog is in flight too, so the question silently became "has
the fetch landed yet?". The panel now publishes
data-catalog="pending|ready"and the test waits on that instead.
A conditional render driven by a fetch must publish that the fetch landed
This one is a product defect, not a test defect. Whenever {#if someCapability} depends on data
that arrives asynchronously, absence has two meanings โ "not supported" and "not loaded yet"
โ and nothing in the DOM separates them. Every observer, test or human, is then measuring
network latency and calling it functionality. Publish the distinction (data-catalog,
data-busy, a skeleton) and the ambiguity disappears for everyone.
Cleanup is where interleaving bites hardest
A fixture that deletes "everything created since I opened this file" was correct exactly while a
worker owned the file from start to finish. It no longer does, so the same sentence now describes
another worker's in-flight rows โ and the repopulate path would wipe the database under three
running tests. e2e/fixtures/playwright.ts therefore disables transaction hygiene above one worker,
and any new cleanup must either do the same or delete only ids the test itself created.
Staying serial is a legitimate answer โ but it is the second-best one
multi-user.spec.ts shares two browser contexts across its tests. tx-brim-import.spec.ts parses
the same two sample files in every test, and parsing rewrites the file's metadata JSON. Neither is
fixable with a better timeout, and both say so in a comment above the declaration โ the frontend
twin of the backend catalogue's exclusive_because.
asset-event-delete.spec.ts was the third, and is the instructive one. Its first test deleted one
of Apple's two unlinked mock events, so it worked exactly twice: on the third run the oldest row
was a linked event, the API answered in_use, and the suite went red for a reason unrelated to any
code change. Serialising the block hid the interference but not the expiry date. The test now
creates the event it deletes, and the declaration is gone.
A test that consumes fixture data is a test with an expiry date. Declaring it serial buys time; making it create what it needs removes the problem.
๐ When a spec fails
Use the test-triage skill. Its first hypothesis is "was it the shape of the response?" โ
the data was there but elsewhere, the page held something else, the count differed. flaky is not
a verdict.