26 KiB
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
- margin has no tests of any kind. Confirmed plainly:
package.json:5-12hasdev,build,preview,tauri,dmg,fonts:sync,fonts:checkand nothing else, and neithervitestnor@playwright/testis a dependency. Notests/, nosrc/dev/, zero*.test.tsundersrc/, zero#[cfg(test)]and zero#[test]undersrc-tauri/src/. Itsvite.config.tsimports fromvite, notvitest/config, so there is notestblock to add to, and itssrc/ipc.ts(43 lines) callsinvokedirectly behind anisDesktopearly-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. - 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 dropstimezoneId. Everything else is byte identical. - margin-docs is the only app that checks the dev server is serving its own checkout, and all
three set
reuseExistingServer: trueon a fixed port.tests/identity.ts(106 lines) fetches five source files over?rawand 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. - 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 windowCustomEvents with an app-side bridge, and margin-docs hand-rolls 97 lines of__TAURI_INTERNALS__inside a test helper so the real@tauri-apps/apievent plugin works in a plain tab. The third is the correct one and it is the one that is not reusable, because it lives intests/disk.tsrather than insrc/dev/. - margin-docs has no shared spec helper at all. 19 of 19 spec files define their own
async function open()and inline the samemargindocs-recentslocalStorage seed. margin-calendar and margin-mail both havetests/app.ts, and 33 of their 34 spec files importopenAppfrom it. Between those two,contrastOf(60 lines),clockAt,MIDDAY,settle,boxandopenDialogare code identical and differ only in doc comments.
What each app has
| margin | calendar | docs | ||
|---|---|---|---|---|
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)andMIDDAY(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). SameaddInitScriptbody, same__test-seededsentinel so a reload is not silently reset, samepage.clock.setFixedTime. Only the seed keys differ.settle(page), tworequestAnimationFrames (cal:105-112, mail:99-106, third copy in docstests/caret.ts:46-53),box(target)(cal:114-124, mail:108-118), andopenDialog(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 sooklch()andcolor-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:
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 CustomEvents 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:
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.
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.