--- name: writing-e2e-tests description: Canonical rules for writing, rewriting, or reviewing Shift desktop Playwright E2E tests and visual goldens under `apps/desktop/e2e/`. Use whenever you add or change a `.spec.ts`, a fixture, `EditorDriver`, a screenshot baseline, or Playwright project membership, and whenever you investigate a flaky or failing E2E test. Covers waits, oracles, goldens, fixtures, projects, and flake verification. --- # /writing-e2e-tests — How desktop E2E tests are written An E2E test is worth its cost only when it fails for exactly one reason: the user-visible behavior it names is broken. It must not fail because the machine was slow, the theme changed, a sidebar moved, or a retry happened to pass. Read `/writing-tests` first for the general rules (observable state, no mocks, migration ledgers). Read `apps/desktop/e2e/README.md` for the full fixture and project reference. This skill is the checklist that turns those into a test that stays green for the right reasons. ## Choose the layer before writing anything | The behavior is… | Owning test | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | Pure computation (layout math, latch rules, range selection) | Pure unit test — extract the function first | | Observable through editor state after a click, drag, or key | `TestEditor` tool or command test | | A real browser or Electron contract (DOM events, focus, native menus, dialogs, windows, processes, files) | Semantic Playwright test | | Appearance: colours, strokes, handles, layout | One focused golden, after a semantic precondition | | Hardware rendering, GPU residency, WebGPU presentation | `gpu` project test | | Save, quit, crash, recovery, file activation, cross-OS paths | `platform` project test | If most assertions in a draft E2E test read editor state after `page.evaluate`, the behavior probably belongs in `TestEditor`. Keep the E2E test as a thin proof that the real UI reaches it. ## Waiting: never sleep `waitForTimeout` is banned. Wait for the condition that makes the next step valid: | Before you… | Wait for | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | Read geometry, project coordinates, or send pointer input after setup changed geometry or camera | `editor.waitForCanvasRender()` | | Assert after raw `page.mouse` moves | `editor.flushPointerMoves()`, then `waitForCanvasRender()` or `waitForIdle()` | | Act on an editor route | `waitForEditorReady()` / `openCatalogGlyph()` (works for authored and preview sessions) | | Click a catalog cell by coordinates | `clickFirstCatalogGlyph()` — it waits for a laid-out, settled Grid | | Act on a workspace | `waitForWorkspaceReady()` | | Assert after quit or close | `dirtyDocumentDecisions()` / `dirtyDocumentRequests()` — main-process records, not elapsed time | | Compare Grid frames after an edit | Published Grid state (`data-grid-readiness`, `data-atlas-build-count`, active location), armed after setup settles | When elapsed time is itself the contract (debounce, momentum, idle timeouts), put the rule in a pure function driven by timestamps and unit-test it. If the E2E test must remain, dispatch events inside the page and wait on the page clock (`performance.now()`) that stamps them. `glyph-view.spec.ts` zoom momentum is the template. Negative assertions ("nothing else happened") need a positive anchor: wait for the event that would have triggered the unwanted outcome, then assert the outcome is absent. ## Oracles: assert what the editor decided, not what pixels look like Never count or sample hard-coded colours from a canvas. Colour oracles break under themes, alpha, anti-aliasing, device scale, and renderer changes, and many pass when the feature is broken (an empty canvas has zero "wrong" pixels). Use published state instead: - geometry: `editor.outline()`, `pointPosition()`, `selectionIds()`, `selectionBounds()`; - interaction: `toolState()`, `hoverId()`, `activeSnapGuides()`; - rendering decisions: `visibleOutlines(nodeId)` and `handleStates(node)` on the glyph node definition; - UI state: roles, accessible names, `aria-pressed`, `aria-checked`, `aria-disabled`, `data-*` state published for tests; - persistence: `savedGlyphNames()` for `.shift` files and `exportedGlyphNames()` for exported fonts — never `existsSync`, byte inequality, or `size > 0`. If the state you need is not published, publish it from the production code that renders it (same computation, not a parallel reimplementation), add a unit test for it, then assert it. That is how `visibleOutlines` and `handleStates` were added. Assertions must be exact enough to fail on the wrong answer: - assert the exact set or order, not `count() > N` or `not.toEqual(before)`; - derive expectations from the fixture or the UI (for example, read the ordered category buttons and compute the expected range) instead of hard-coding one neighbour; - run the mental-deletion check: if the feature were a no-op, would this assertion fail? ## Goldens: one contract, one focused capture All goldens go through `fixtures/snapshots.ts`; `scripts/check-e2e-projects.mjs` rejects `toHaveScreenshot` or `toMatchSnapshot` anywhere else. - `expectCanvasSnapshot(editor, name)` — the editor canvas stack, exact comparison, after the canvas has rendered. - `expectPanelSnapshot(locator, name)` — one panel, menu, toolbar, or dialog. - `expectPageSnapshot(page, name)` — full window; use sparingly, only for overall chrome. Before every golden: 1. Assert the semantic precondition that makes the image meaningful: the point count, selection, tool state, outline targets, handle states, zoom, active theme (`data-color-theme`), or `devicePixelRatio`. 2. Park the pointer off the captured element unless hover is the contract. 3. Capture the smallest element that shows the contract. Do not use a full-page golden to protect a toolbar button. Rules: - One golden per distinct visual contract. Do not add near-duplicate images; if two captures are byte-identical, one of them is redundant. - Canvas goldens use `maxDiffPixels: 0` with `threshold: 0.02` on every host: enough for antialiasing differences between hosts (≤ 0.009), far below a real colour-token change (~0.07). Never raise either to make a mismatch pass; the default threshold of 0.2 silently accepts token changes. - Interface goldens (`expectPanelSnapshot`, `expectPageSnapshot`) are exact but compared on CI only, with baselines generated on the runner via the `ci: update visual snapshots` label. Never commit a locally generated interface baseline. Prefer semantic assertions and keep interface goldens few. - Goldens never pass on retry. The helpers throw on a retry attempt, so explain the first failure. - Use canvas-local positions (`dragCanvas()`, `canvasPagePoint()`) so layout changes cannot redraw geometry. - Theme goldens select the theme through the product and assert `data-color-theme` first. HiDPI goldens use `test.use({ deviceScaleFactor: 2 })` and assert `devicePixelRatio` first. Add one golden per palette branch or rendering path, not one per theme. Updating baselines: only for an intentional appearance change. Run the focused spec with `--update-snapshots -g ""`, open every changed PNG and describe what changed, then rerun without update mode and with `--retries=0 --repeat-each=3`. Canvas baselines may be generated locally. Interface baselines are generated only by the `ci: update visual snapshots` label; inspect the runner-generated images before merging. ## Fixtures and isolation - Use the shared `editor` fixture; construct `EditorDriver` directly only for additional windows. - Relaunch through the `relaunch` fixture, never raw `electron.launch`. The process registry owns every launched tree, attaches diagnostics, and kills it even when the test times out. - Every launch environment comes from `shiftTestEnvironment()`. Never spread `process.env` into a launch. - Script native dialogs with the fixture options (`dirtyDocumentChoice(s)`, `saveShiftPaths`, `openFontPath`). An unexpected renderer dialog fails the test; opt in with `allowRendererDialogs` only when the dialog is the behavior. - Put shared setup in fixtures or `EditorDriver` (`openScratchGlyph()`, `commitInputValue()`, `toolButton()`), not copied helpers in specs. Do not grow `EditorDriver` with one-off helpers. - Prefer `getByRole()` / `getByLabel()` and locators from `fixtures/appLocators.ts`. No parent traversal (`locator("..")`), no styling classes, no `toHaveCSS()` unless the style value is the product contract. ## Structure - One behavior per test. Split long flows when an early failure would hide unrelated coverage. - Do not accumulate state across unrelated steps; start each test from a fixture. - Setup may use `page.evaluate` or `insertContent`; the behavior under test must go through the user surface. - Name tests after the behavior: "shift-selects glyph categories as a visible range", not "category test". ## Projects Membership is explicit in `apps/desktop/playwright.config.ts`. Add a new spec to the right list — `visual`, `platform`, `gpu`, or `perf` — and run `node scripts/check-e2e-projects.mjs`. A spec that needs native lifecycle behavior on Windows and Linux belongs in `platform`. A spec whose only GPU dependency is incidental belongs in `visual`. ## Large-data performance regressions - The `perf` project runs nightly or manually, not in the normal correctness suite. Protect a large-list UI change with a small `visual` interaction test that reaches an offscreen row, preserves selection and keyboard focus, and bounds the number of mounted rows. - In a real-app performance test, time the whole user-visible operation through confirmed edit settlement and canvas readiness with the relevant panels mounted. Timing only a local mutation, Rust apply, or IPC handler misses renderer main-thread stalls. Keep hardware-sensitive time ceilings in `perf`, not `visual`; verify the test fails for the original regression rather than only when it times out. - Use `EditorDriver` for navigation and edit settling. Keep the performance fixture responsible for the hardware launch and generated data, not editor behavior. ## Proving a test is not flaky Before committing a new or changed E2E test: ```sh pnpm test:e2e:visual e2e/<spec>.spec.ts -g "<title>" --repeat-each=10 --retries=0 ``` For timing-sensitive flows, repeat under CPU load (for example, several `yes > /dev/null &` processes, killed afterwards), because CI runners are slower than development machines. For platform behavior, rely on the Windows and Linux CI jobs and say so in the pull request. When a test fails or flakes: 1. Read the trace and `electron-diagnostics` before changing anything. 2. Classify it: product regression, test oracle, or infrastructure. Do not call a single failure flaky without a retry pass or a rerun on the same commit. 3. Fix the oracle or the wait. Never add a sleep, a retry, or tolerance, and never regenerate a baseline without understanding the diff. The CI "E2E Report" job lists tests that passed only on retry. Treat every entry as a bug to fix, not noise. ## Review checklist - [ ] The layer is right; logic that `TestEditor` can observe is tested there. - [ ] No `waitForTimeout`, no fixed delays, and no polling without a semantic condition. - [ ] No colour counting, pixel sampling, byte inequality, or "something changed" oracle. - [ ] Assertions are exact and fail when the feature is a no-op. - [ ] Every golden goes through `fixtures/snapshots.ts`, captures one contract at the smallest element, and follows a semantic precondition. - [ ] Changed baselines were inspected and verified with `--retries=0`. - [ ] Launches, relaunches, and dialogs go through fixtures. - [ ] The spec belongs to the intended project, and the project check passes. - [ ] The test passed `--repeat-each=10 --retries=0`, and the pull request lists the exact E2E commands and any coverage not run.