some more fixes

This commit is contained in:
pj committed 2026-10-03 22:48:49 +05:30
1 parent 7a3c04f170
commit 2b86a407ba
63 files changed
+12172 -44

No files matched your search

+399
View File
@@ -0,0 +1,399 @@
# Testing, fixtures and the dev harness across the four apps
Scope: vitest and playwright config, the `tests/` suites and their helpers, `src/dev/` (the
browser-against-fake-data harness), Rust fixtures and `#[cfg(test)]` conventions, the tsconfig
split. Not build tooling, not CI beyond how it invokes tests.
Paths: margin `/Users/pj/Workspace/projects/python/margin`, margin-calendar
`/Users/pj/Workspace/projects/python/margin-caledar`, margin-docs
`/Users/pj/Workspace/projects/rust/margin-editor`, margin-mail
`/Users/pj/Workspace/projects/rust/margin-mail`. Cites are relative to those roots.
## Findings first
1. **margin has no tests of any kind.** Confirmed plainly: `package.json:5-12` has `dev`, `build`,
`preview`, `tauri`, `dmg`, `fonts:sync`, `fonts:check` and nothing else, and neither `vitest` nor
`@playwright/test` is a dependency. No `tests/`, no `src/dev/`, zero `*.test.ts` under `src/`,
zero `#[cfg(test)]` and zero `#[test]` under `src-tauri/src/`. Its `vite.config.ts` imports from
`vite`, not `vitest/config`, so there is no `test` block to add to, and its `src/ipc.ts` (43
lines) calls `invoke` directly behind an `isDesktop` early-return, so there is no seam a fixture
could plug into. Bringing margin into a shared harness is not a config change, it is building the
dev fixture it never had.
2. **The three playwright configs are one file with the port swapped.** Diffing them leaves the port
(1430/1440/1450), two rewritten comments, and two real differences: margin-docs adds
`globalSetup: "./tests/identity.ts"` and drops `timezoneId`. Everything else is byte identical.
3. **margin-docs is the only app that checks the dev server is serving its own checkout**, and all
three set `reuseExistingServer: true` on a fixed port. `tests/identity.ts` (106 lines) fetches
five source files over `?raw` and byte-compares them against disk. Its own header says the four
suites beside it "were happy to pass against anybody's copy". margin-calendar and margin-mail
still are.
4. **The dev-mode invoke stub is the thing worth sharing and the three apps solved it three
different ways.** All three branch identically in `src/ipc.ts`, but margin-calendar fakes no
events at all, margin-mail fakes them as window `CustomEvent`s with an app-side bridge, and
margin-docs hand-rolls 97 lines of `__TAURI_INTERNALS__` inside a test helper so the real
`@tauri-apps/api` event plugin works in a plain tab. The third is the correct one and it is the
one that is not reusable, because it lives in `tests/disk.ts` rather than in `src/dev/`.
5. **margin-docs has no shared spec helper at all.** 19 of 19 spec files define their own
`async function open()` and inline the same `margindocs-recents` localStorage seed.
margin-calendar and margin-mail both have `tests/app.ts`, and 33 of their 34 spec files import
`openApp` from it. Between those two, `contrastOf` (60 lines), `clockAt`, `MIDDAY`, `settle`,
`box` and `openDialog` are code identical and differ only in doc comments.
## What each app has
| | margin | calendar | docs | mail |
| --- | --- | --- | --- | --- |
| `test` script | none | `vitest run` | `vitest run` | `vitest run` |
| `test:ui` script | none | `playwright test` | `playwright test` | `playwright test` |
| `vitest` dep | no | 3.2.4 | 3.2.4 | 3.2.4 |
| `@playwright/test` dep | no | 1.62.1 | 1.62.1 | 1.62.1 |
| `src/dev/` | absent | 356 lines | 1,040 lines | 3,341 lines |
| spec files / source-level tests | 0 | 10 / 131 | 19 / 116 | 24 / 252 |
| colocated `*.test.ts` / tests | 0 | 12 / 211 | 32 / 655 | 8 / 81 |
| Rust `#[test]` | 0 | 79 | 0 (all integration) | 531 |
| `src-tauri/tests/` | no | no | 8 files + support | no |
| `src-tauri/fixtures/` | no | no | no | 76 files |
Source-level counts are `test(` and `it(` at file scope; several files wrap tests in a
`for (const theme of ["light","dark"])` loop, so the run counts are higher. CI runs `pnpm test` and
`cargo test` in all three (calendar `ci.yml:31,70`, docs `ci.yml:34,58,78`, mail `ci.yml:49,92`) and
Playwright nowhere. `just test` is `pnpm test` plus `cargo test` and `just test-ui` is
`pnpm test:ui`, the three justfiles agreeing line for line on both.
## playwright.config.ts
Shared by all three: `testDir: "./tests"`, `fullyParallel: true`, `retries: 0` (with the same
comment in each saying a test that only passes on the second go is lying), `reporter: [["list"]]`,
`outputDir: "node_modules/.cache/playwright"`, `timeout: 30_000`, `expect.timeout: 5_000`,
`baseURL` off the port, `viewport: {1440, 900}`, `deviceScaleFactor: 1`, `locale: "en-GB"`,
`trace: "retain-on-failure"`, `screenshot: "only-on-failure"`, a single project
`{ name: "chromium", use: { browserName: "chromium" } }` with an identical comment explaining why it
is not `devices["Desktop Chrome"]` (that device pins a Windows user agent and the keymap reads the
platform off it), and a webServer block of `pnpm dev`, `reuseExistingServer: true`,
`timeout: 60_000`, `stdout: "ignore"`, `stderr: "pipe"`.
Differences, in full: port 1430 / 1440 / 1450; `timezoneId: "Asia/Kolkata"` in calendar
(`playwright.config.ts:34`) and mail (`:31`) but not docs, because both anchor their fixture to the
browser's local day; `globalSetup` in docs only (`:14`).
No app configures `toHaveScreenshot`, `snapshotDir` or any visual-regression comparison, and there
are zero snapshot baselines in the three. Every screenshot is a named PNG for a person or the docs.
## The tests directories
margin-calendar, 10 specs plus `app.ts` (370 lines) and `tsconfig.json`. Geometry and layout
(`grid` 26, `compact` 5, `views` 8), input (`keyboard` 14, `interaction` 12, `touch` 22), form
factor (`phone` 10), design-system (`legibility` 10), overlays (7), regression (`bugs` 17). The two
phone files use `test.use({ viewport, hasTouch, isMobile })` at file scope (`touch.spec.ts:19`,
`phone.spec.ts:16`) rather than a Playwright project.
margin-docs, 19 specs plus `caret.ts` (134), `disk.ts` (144), `saving.ts` (52), `identity.ts` (106).
Six of the 19 are `_audit*.spec.ts`, headed "Temporary exploration harness. Deleted once the
findings are written down" (`_audit.spec.ts:1`) and still present. The rest are bytes-on-disk claims
(`bytes` 11, `color` 7, `export-writes-only-the-pdf` 2, `external-changes` 8), editing (`blocks`,
`headings`, `tables`, `clipboard`, `shortcuts`), and smoke (9).
margin-mail, 24 specs plus `app.ts` (401 lines) and `tsconfig.json`. Roughly one spec per screen,
plus `kit.spec.ts` (design system), `keyboard.spec.ts`, `guide.spec.ts` and `guide-shots.spec.ts`
(asset generation, not assertion).
### The helpers each invented
`tests/app.ts` in calendar and mail is the same file with different measurement functions bolted on.
Identical modulo doc comments:
- `clockAt(hour, minute)` and `MIDDAY` (cal `:41-53`, mail `:46-58`), pinning the clock to the
current day at a fixed hour in Asia/Kolkata.
- `openApp(page, options)` (cal `:61-90`, mail `:66-90`). Same `addInitScript` body, same
`__test-seeded` sentinel so a reload is not silently reset, same `page.clock.setFixedTime`. Only
the seed keys differ.
- `settle(page)`, two `requestAnimationFrame`s (cal `:105-112`, mail `:99-106`, third copy in docs
`tests/caret.ts:46-53`), `box(target)` (cal `:114-124`, mail `:108-118`), and `openDialog(page)`
(cal `:288-293`, mail `:188-193`).
- `contrastOf(page, selector)` (cal `:306-366`, mail `:219-278`), which composites every translucent
background between the element and the page through a 1x1 canvas so `oklch()` and `color-mix()`
do not read as transparent.
App-specific and correctly so: calendar's `gridFit`, `axis`, `blocks`, `headerDates`, `hourY`,
`columnX`, `drag`; mail's `rows`, `groups`, `paneMessages`, `paletteRows`, `actionBar`,
`checkedRows`, `bodyText`, `toast`, `listScroll`, `place`, `openRow`.
Two mail helpers are generic and belong in a shared package despite being written for mail:
`token(page, name)` (`app.ts:180-185`), reading a CSS custom property off the root, and
`failCommands(page, commands)` (`app.ts:343-360`), which uses `page.route` to answer the request for
`/src/dev/mockIpc.ts` with a shim that forwards to the real module (`?real`) and rejects the named
commands. That is the only way to test a failure path when the backend is in the page rather than on
the wire, and it is 18 lines that would work unchanged in any of the four.
margin-docs' helpers have no sibling: `putCaret`/`caretIsIn` (`caret.ts`), whose 28-line header
documents why nothing in the suite presses End to move a caret; `watchDirty`/`dirtyWasShown`
(`saving.ts`), a MutationObserver installed before typing so the 500ms autosave cannot be raced; and
`installTauriShim`/`change`/`ask` (`disk.ts`), covered below.
### Screenshot policy
`page.screenshot` appears 33 times across 16 mail specs, 7 times across 4 docs specs (all in
`_audit*`), and never in calendar. Mail writes into `screenshots/`, which is gitignored
(`.gitignore:41`), so an ordinary run leaves the tree clean; the 10 pictures that ship inside the
bundle go to the committed `public/guide/` and are gated by
`test.skip(() => !process.env.GUIDE_SHOTS)` (`guide-shots.spec.ts:19-22`) behind
`just guide-shots` (`justfile:28-30`). That two-tier rule is right and only mail has it.
### Design-system assertions
`mail/tests/kit.spec.ts` (132 lines) is the only design-system suite. It renders a `#/kit` route in
both palettes and shoots it, asserts fixed geometry read from custom properties (`--list-w` is
`420px`, a row is 46px, an avatar 30x30), then runs three static source scans with `node:fs`: no hex
literal in any stylesheet under `src/ui` or `src/screens`, every `<input>` carries `NO_AUTOFILL` or
an explicit `autoComplete`, and no file says "keychain".
calendar's equivalent is runtime rather than static: `legibility.spec.ts` measures contrast, checks
no block reads "Untitled" and checks every block has a non-empty accessible name, over both themes.
docs' equivalent is a vitest, not a spec: `src/theme.test.ts` reads three stylesheets (including
`node_modules/margin-shared/css/tokens.css`, `:18-22`) plus `index.html`'s pre-bundle boot script
and asserts all of them declare the same variable set, because a missing dark variable falls back
silently to the warm light value in `:root`. Nothing anywhere asserts anything about icons.
## src/dev: the invoke stub
All three apps switch dev mode on the same way, in `src/ipc.ts`:
```ts
export function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (import.meta.env.DEV && !isTauri) {
return import("./dev/mockIpc").then((m) => m.mockCall<T>(command, args));
}
return invoke<T>(command, args);
}
```
calendar `ipc.ts:192-197`, docs `ipc.ts:288-292`, mail `ipc.ts:748-752`, byte identical bar the
comment. `isTauri` is `"__TAURI_INTERNALS__" in window`, computed once at import time, and all three
derive `isDesktop`, `isMacDesktop` and `live()` from it with the same comments verbatim (calendar
`:9-40`, docs `:9-40`, mail `:10-34`). There is no environment variable and no dev-only build flag:
the switch is "DEV build with no Tauri bridge", so `pnpm dev` in a browser and Playwright get the
fixture and the packaged app cannot.
`mockCall` is a `switch (command)` in all three, returning `as unknown as T` at every arm and
throwing on an unknown command (calendar `mockIpc.ts:131-132`, "dev mock has no handler for"). There
is no type link between a command name, its arguments and its return; calendar casts args to
`Record<string, never>` (`:41`) and then to the real type per field. Command counts: calendar 12
arms, docs 40, mail roughly 180. State is a mutable copy of the fixture taken at module load, so
writes persist for the session and a reload resets (calendar `:11-14`, docs `:48-49`, mail `:72-84`).
### Fixtures
`src/dev/fixture.ts` in each. calendar (222 lines) exports `devAccounts`, `devCalendars` and
`devInstances(from, to)`, generated from a seed table and anchored to the current week; its header
records that it is modelled on a real sync of 12,067 events where 90% came back with no summary.
docs (344 lines) exports `devRoots`, `devEntries` (an in-memory folder with real base64 PNG, SVG and
PDF bytes at `:208-273`) and path utilities the mock and the app both use. mail (1,572 lines)
exports 20 symbols, 13 data tables (`devAccounts` through `devSyncStatus`) and 6 functions
(`devDiscover`, `devCert`, `devFiles`, `devContacts`, `groupOf`, `categoryOf`).
A fourth fixture source in docs has no sibling: `src/markdown/corpus/`, loaded through
`import.meta.glob("./*/*.md", { query: "?raw", eager: true })` at `corpus/load.ts:20`. Its header
records the bug that motivated the glob, that naming folders explicitly left twenty adversarial
files inside the corpus but outside every gate reading it. That is the "add a file, get a test"
pattern and it is worth generalising.
### Dev flags
localStorage, read defensively inside try/catch. calendar has one, `margincal-dev-empty`
(`mockIpc.ts:32-38`); docs has one, `margindocs-dev-no-writing-tools` (`:246-252`); mail has eight,
`marginmail-dev-` plus `empty`, `crowd`, `notify`, `imap`, `bridge`, `pending`, `hydrate-fails`,
`sync-fails`, read through `firstRun()` (`:220-230`) and a generic `flagged(key)` (`:262-268`). The
naming is uniform (`<app>-dev-<thing>`) and the reader is the same eight lines three times over.
### Faking events, three ways
This is where the three diverge and where the shared design has to be decided.
calendar does not fake events at all: `App.tsx:52` is `if (!isTauri) return;` before every `listen`,
so in a browser `menu-action`, `auth`, `sync-progress` and `store-changed` never arrive, and
anything they drive is unreachable from the Playwright suite.
mail dispatches window `CustomEvent`s under the same names from inside the mock
(`mockIpc.ts:237-241`), with an optional delay so a state that would otherwise last one frame is
observable (`narrateFirstSync` at `:637-651` walks a five-step sync over 1,250ms). The app side is
`onAppEvent` in `App.tsx:69-77`, seven lines picking `listen` or `window.addEventListener` off
`isTauri`. Cheapest correct answer, and confined to mail.
docs does neither. Its mock exports `external` (`mockIpc.ts:619-696`), a second surface that mutates
the fixture the way another program would, behind the app's back, and returns the exact
`WatchEvent[]` the Rust watcher would have emitted; putting them on the bus is the caller's job.
That caller is `tests/disk.ts:37-133`, which installs a hand-rolled `__TAURI_INTERNALS__` at
document start: `invoke`, `transformCallback`, `unregisterCallback`, `runCallback`, the
`plugin:event|listen` and `|unlisten` commands, a listener map, `metadata`, `convertFileSrc` and
`__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener`. Its comment at `:29-36` says this is
deliberately not `@tauri-apps/api/mocks`, which cannot be reached from an init script, and
deliberately written to the contract rather than to convenience. `emit` returns a delivery count per
event so a test can tell a working subscription from a payload that fell on the floor (`:66-75`).
`external` also carries `pauseWrites`/`resumeWrites` (`:679-695`) so a save can be held and a buffer
kept dirty instead of racing the 500ms autosave.
The docs approach is the strictly better one: the app runs the real `@tauri-apps/api`, so the
`isTauri` branch that ships is the branch under test, where mail leaves `onAppEvent`'s Tauri arm
never exercised in a browser. But it is 97 lines in a test helper, so a person running `pnpm dev` by
hand gets no events at all.
## The Rust side
margin-mail `src-tauri/fixtures/` is 36 `.eml` files (real messages as they come off the wire, CRLF
throughout, half of them not UTF-8), 36 matching `golden/*.txt` and 4 `autoconfig/*.xml`, compiled
into the test binary by a `corpus!` macro in `src/fixtures.rs:10-19` that emits one `include_bytes!`
const per file plus an `all()` returning every pair, so a sweep over the corpus is one call.
`fixtures.rs:58-102` is itself a test: every fixture has a header/body break, no bare LF, a
plausible date and a parseable From. Nothing is generated at test time.
Alongside it, `src/provider/fake.rs` (612 lines, `#![cfg(test)]` at `:15`) is an in-memory mailbox
implementing the `Provider` trait: real ids, labels, dates and raw bytes, paging, a history log, and
scripted failures via `fail_next` and `withhold_body`. `provider/mod.rs:1-14` records that this is
the only reason the sync engine is testable without credentials.
Nothing comparable exists elsewhere. margin-docs builds its Rust fixture at runtime instead:
`src-tauri/tests/support/notes_repo.rs` (339 lines) creates a real git repository per test binary,
copies 12 documents out of `src/markdown/corpus/real` so there is one corpus and not two, and
generates 13,000 files under a vendored `node_modules` because several tests need a folder large
enough that skipping it beats walking it. `git status` is the oracle.
Conventions differ and both are defensible. calendar and mail put tests in `#[cfg(test)] mod tests`
beside the code, 6 files / 79 tests and 54 files / 531 tests; fifteen of mail's are large enough to
live in a sibling `<module>/tests.rs` (backup, clips, contacts, decisions, drafts, exports, invites,
notify, piles, screener, send, snooze, state, sync, unsubscribe). docs has zero `#[cfg(test)]` and
eight integration binaries totalling 4,210 lines, one needing `--test-threads=1` and run as its own
CI step (`ci.yml:78`). Eight mail tests carry `#[ignore]`, six of them "hits the network" in
`imap/discover.rs`.
## tsconfig
The app `tsconfig.json` is `"include": ["src"]` in all four, so `tests/` is excluded by omission
rather than by an `exclude` entry, and the colocated `src/**/*.test.ts` files sit inside the app's
own type check. `tests/tsconfig.json` exists in the three and is the same nine options each time
(ES2022, bundler resolution, strict, noUnusedLocals, noUnusedParameters, skipLibCheck, noEmit) with
`"include": [".", "../playwright.config.ts"]`. Mail's adds `"types": ["node"]`. Nothing runs `tsc -p
tests/tsconfig.json` in any script or CI job, so the spec files are type checked only by an editor.
Vitest config lives in `vite.config.ts` under `test:`, all three with
`include: ["src/**/*.test.ts"]` and `environment: "node"`. No jsdom, no happy-dom, no
`@testing-library/*` anywhere. margin-docs adds `maxWorkers: "50%"` and three 30-second timeouts
with a 20-line comment explaining that a CPU-blocking markdown sweep cannot answer vitest's
`onTaskUpdate` RPC while it runs.
## Colocated unit tests: what they are and are not
They test pure functions in a node environment. No component is rendered, no store is mounted
against a DOM, nothing goes near `mockIpc`. Calendar's convention is explicit: a component `X.tsx`
gets a sibling `XModel.ts` holding the decisions and `XModel.test.ts` tests that module.
`GridModel.test.ts` covers `busyHours`, `heldHours` and `bandAt` over synthetic `Placed` values with
`instance: {} as Instance`, because only the times matter; `EventDetailsModel.test.ts` covers
popover placement against a fixed `Bounds`; `overlayModel` and `QuickCreateModel` cover date
arithmetic and draft construction; `AgendaModel` covers grouping, gap labels and search matching.
The odd one out is `EventDetailsHtml.test.ts`, half readability and half hostile input, because the
description string comes off the wire from whoever created the event.
margin-mail's eight follow the same rule. `providers.test.ts` tests the routing decision for an
address twice over, by domain and by what discovery found; `useSync.test.ts` the two pure decisions
the header and toast make from a `SyncStatus`; `guide.test.ts` link-checks the article library
without rendering it; the other five are table checks.
margin-docs' two named ones are a different genre and the more interesting one. `theme.test.ts` and
`width.test.ts` both read source with `node:fs` and assert that two tables not written in the same
language agree: theme against three stylesheets and a boot script, width against the command table
and the subscription joining them. The other 30 are the markdown bridge and are app-specific.
None of them snapshot, mock `call()`, or configure coverage, and there is no property-based library
(though `typst.test.ts` and the five `adversarial*.test.ts` files are hand-rolled corpus sweeps,
which is the same idea).
## Known-failing specs
Nothing in any repo records a known failure. The only markers are `guide-shots.spec.ts:19` (an
intentional env gate, not a failure) and a `test.fail` at `external-changes.spec.ts:320`, kept so it
would turn red the day the defect was fixed. `margin-editor/docs/conventions.md:74` is the standing
rule: "Never weaken, skip or delete a test to reach green."
The four margin-mail browser failures recorded in session memory are written down nowhere in the
tree. If they are real that is the gap: either a per-app `tests/known-failures.md` or annotations on
the specs, because right now the only record is a chat log.
## What a shared harness package would contain
`margin-shared` already exists at `margin/shared` and is consumed by margin and margin-docs by
relative `file:` path, and by margin-mail. margin-calendar does not depend on it at all. Adding a
`./test` subpath export is the least-friction home; a separate package means a fourth `file:` edge.
**1. A playwright config factory.** Everything in the three configs bar the port is a default, so
the whole file becomes `export default marginPlaywrightConfig({ port: 1450, witnesses: ["src/ipc.ts",
"src/dev/mockIpc.ts", "src/dev/fixture.ts"] })`. `witnesses` turns the docs identity check on for
every app rather than one, with the factory supplying `globalSetup` so no app wires it. `timezoneId`
defaults to `Asia/Kolkata` and docs passes null. Saves roughly 45 lines per app and makes "all three
run at 1440x900 with retries 0" a fact rather than a coincidence.
**2. The dev backend, with a typed command registry.** Replace `switch (command)` with a table keyed
by command whose handlers are typed off the DTO types `src/ipc.ts` already declares:
```ts
export const backend = defineBackend({
accounts_list: () => (firstRun() ? [] : accounts),
thread_view: ({ key }: { key: string }): ThreadView => viewOf(byKey(key)),
});
export type Command = keyof typeof backend;
```
`defineBackend` returns `{ mockCall, has, commands }`, with `mockCall` throwing the existing "no
handler for" error on a miss. The win is that the arg cast at every arm and the `as unknown as T` at
every return both disappear, and a command in `dto.rs` with no handler becomes checkable rather than
a runtime surprise three screens later. Alongside it: `makeCall(loader)` producing the `call()` in
`src/ipc.ts`, already identical in three places, and `devFlag(name)` replacing the three copies of
the try/catch localStorage reader.
**3. The Tauri shim, moved from `tests/` into the shared package and made the default.** Lift
`installTauriShim` out of docs' `tests/disk.ts:37-133` unchanged, parameterise the backend module
specifier, and expose it both as a Playwright init script (what docs does now) and as a dev entry
point so `pnpm dev` in a browser gets real Tauri events too. Then `onAppEvent` (mail
`App.tsx:69-77`) is unnecessary and the app runs one code path in both environments. This is the
highest-value item in the audit: 97 lines that took real care to get right, written to a contract
rather than to convenience, and two of the three apps are the poorer for not having it. With it,
`emitEvent(page, name, payload)` and the delivery-count return become shared, and mail's
`narrateFirstSync` style of timed multi-step event script becomes a shared `script([...])`.
**4. Fixture loading.** Two patterns, both generalisable: docs' `import.meta.glob` corpus loader
(`corpus/load.ts`) as `loadCorpus(glob)`, and mail's `corpus!` macro (`fixtures.rs:10-19`) as a
shared Rust macro crate. Both encode the rule that adding a file puts it inside every sweep. Also
shared: `mutableCopy(fixture)` for the clone-at-module-load idiom in all three mocks, and
`undoLedger()` for mail's `undoable`/`runUndo` (`mockIpc.ts:292-314`), which any app with an undo
toast will want.
**5. Playwright helpers.** `settle`, `box`, `clockAt`, `MIDDAY`, `openDialog`, `token`,
`failCommands`, plus `makeOpenApp({ theme: "marginmail-theme", pane: "marginmail-pane" })` returning
an `openApp`, since only the seed keys differ between the two that have one.
**6. Assertions on tokens and icons.**
```ts
expectNoColourLiterals(root, ["src/ui", "src/screens"]); // mail kit.spec.ts:82-96
expectThemesAgree(sheets, bootScript); // docs theme.test.ts
expectTokens(page, { "--list-w": "420px" }); // mail kit.spec.ts:62-67
expectContrast(page, selector, { min: 4.5 }); // cal legibility.spec.ts
expectAccessibleNames(page, selector); // cal legibility.spec.ts
expectIconsInSet(root, dirs); // does not exist yet
```
The first two are static scans over source and need no browser, so they can run under vitest in an
app with no Playwright, which is how margin gets its first test.
## What stays per app
Every measurement helper: calendar's `gridFit`, `axis`, `blocks`, `hourY`, `drag`; mail's `rows`,
`groups`, `paneMessages`, `paletteRows`, `actionBar`, `toast`; docs' `putCaret`, `caretIsIn`,
`watchDirty`. They read app-specific class names and encode app-specific timing, so sharing them
would be indirection over one caller each.
The fixtures themselves, and every spec file. A calendar of 12,067 events, a folder of markdown and
a mailbox of threads have nothing in common but the loading mechanism.
The Rust test convention. Mail's `#[cfg(test)] mod tests` beside the code and docs' integration
binaries are both right for their shape, and forcing one on the other buys nothing. The shareable
parts there are narrower: the `corpus!` macro and a `TempRepo` builder along the lines of
`support/notes_repo.rs`.