Testing
Vitest for unit and integration tests, Playwright for E2E (Electron). Tests run via Turborepo so you can scope by package.
Quick Reference
pnpm test # all packages
pnpm --filter @memry/desktop test # desktop only
pnpm --filter @memry/sync-server test # sync server only
pnpm --filter @memry/desktop test:coverage
pnpm --filter @memry/sync-server exec vitest run --coverage --coverage.reporter=json-summary --coverage.reporter=text-summary
pnpm i18n:check # hardcoded text + locale resource coverage
pnpm test:i18n-tools # i18n scanner/check tool tests
pnpm test:e2e # Playwright
node apps/desktop/scripts/build-packaged-app.js --dir
node apps/desktop/scripts/check-packaged-runtime-deps.jsUnit + Integration (Vitest)
Run from the repo root or scope with --filter:
pnpm --filter @memry/desktop test
pnpm --filter @memry/desktop test path/to/file.test.ts
pnpm --filter @memry/desktop test -- --watchTest files live next to the code they cover (foo.ts + foo.test.ts).
Seed Vault Fixtures
Desktop seed-vault data lives under apps/desktop/scripts/seed-data/ and is covered by the desktop main Vitest project. Keep demo dates relative through the shared seed date helper so the generated vault stays current on the day a developer runs it. The seed set should read like a real personal vault: linked notes, inbox captures, calendar items, and tasks should point at each other instead of standing alone.
The project hub is seeded too: project_links rows point each project at its notes and calendar events (scripts/seed-data/project-links.ts), and projects.home_note_id supplies the overview note. Those links carry no foreign key to their target, so they are inserted after the notes and events they reference. File links are intentionally absent — binary files get their id from the indexer at vault-open time, so a pre-seeded file id would never match.
The seed includes canvases, written as plain .excalidraw files under canvases/ through the app's own writer (main/canvas/scene-file.ts), so the seed can never drift from the real format. No keychain, no key material, no device binding: a seeded vault opens in any dev profile and keeps working after it is copied elsewhere. --device is accepted and ignored — it used to pick the keychain entry the canvas ciphertext was bound to.
The seed no longer writes the vault key-verifier setting. It used to, which silently rebound the vault to a key it had just minted — the failure mode that made an already-open vault report "Current master key does not match this vault".
When changing seed data, run the relevant seed-data test file or the desktop main test project:
pnpm --filter @memry/desktop test:main -- scripts/seed-data
pnpm --filter @memry/desktop seed:vaultKey Rules
- Real SQLite (not mocked) for database tests — uses an in-memory DB per test.
- Real crypto (libsodium) — fast enough that mocking isn't worth the divergence risk.
- IPC handlers are tested by importing them directly; no Electron runtime needed.
- Time-sensitive workflow tests should use dates relative to the current run instead of fixed calendar dates, so snooze/reminder assertions do not expire as CI time moves forward.
E2E (Playwright)
pnpm test:e2e
pnpm test:e2e --headed # see the window
pnpm test:e2e --ui # interactive runner
pnpm test:e2e -- tests/notes.spec.ts # one fileE2E runs against the built bundle (
out/main/index.js), not source. After editing source, rebuild:bashnpx electron-vite build pnpm test:e2eSkipping the rebuild is the #1 source of "passes locally, fails in CI" surprises.
Main-push Desktop CI runs the full Electron E2E suite across 16 Playwright shards. Local E2E keeps the default 60s test timeout and 20s assertion timeout, while CI raises those to 180s and 60s, with helper-specific headroom for sync replication and Agent/MCP startup on slower Linux runners.
E2E Keychain Cleanup (macOS)
Sync E2E launches set MEMRY_DEVICE=e2e-<uuid>-A|B, and every keychain account is suffixed with that device id. The OS keychain is machine-global and outlives the throwaway user-data dir, so each launch mints permanent com.memry.sync items. Teardown now deletes them: tests/e2e/utils/keychain-cleanup.ts purges the run's accounts, and fixtures call destroyLaunchedElectron(launched) — prefer it over destroyElectronApp, since it derives the dirs and device id from the launch so a new fixture cannot forget the purge. The purge only ever fires for device ids matching /^e2e-[A-Za-z0-9-]+$/, so local A/B/C/dev vaults and production's bare master-key are never touched. macOS only — keytar is built for the Electron ABI here, so teardown shells out to security(1) instead.
To reclaim a backlog from runs that predate this cleanup:
pnpm --filter @memry/desktop keychain:purge-e2e # dry run, prints counts
pnpm --filter @memry/desktop keychain:purge-e2e -- --apply # deleteE2E Test Hooks
Desktop E2E helpers live behind globalThis.__memryTestHooks and only register when NODE_ENV=test. Keep hooks deterministic and limited to test control surfaces such as seeded sync data, secondary windows, or Quick Capture shortcut probes. If a flow can be tested through normal user-visible UI, prefer that path before adding a hook.
Sync E2E bootstrap opens local vault state before attaching shared sync credentials. Keep bootstrapSyncDevice responsible for rebinding the test vault key and starting the sync runtime before sync assertions run; otherwise queued records can fail before they reach the test backend. Account-sync E2E should wait until both bootstrapped devices see the complete device list before revoking a remote device. Removing another device must not emit the current-device revoked event back into the renderer that initiated the removal. Split-view E2E locators depend on the pane root exposing data-testid="tab-pane", so keep that test id stable when changing pane/drop-zone markup.
Agent Chat approval cards render inline in the chat stream, not as modal dialogs. Scope approval selectors to the Agent chat region, and scope post-approval assertions to the destination surface instead of broad text that can still appear in the chat transcript or tool arguments. Vault unit tests import the vault index with narrow Electron mocks. Keep Agent runtime startup behind vault open/close service functions so note and database tests do not need to load Agent IPC handlers.
Virtualized UI Tests
@tanstack/react-virtual doesn't render any items inside jsdom (heights are zero, virtualization sees no scrollable area). Cover virtualized calendar / week / list UIs at the Playwright layer only.
IPC Contract Check
pnpm ipc:check # validate renderer/main contracts typecheck
pnpm ipc:generate # regenerate the typed invoke mapWhen to run:
- Any time you touch a Zod schema in
packages/contracts - Any time you add or rename an IPC channel
- Before opening a PR that touches the boundary
I18n Checks
pnpm i18n:check
pnpm test:i18n-toolsThe i18n check scans desktop source for untranslated UI text, verifies every referenced key exists in the English bundles, and keeps non-English locale files aligned with English resources. Run the tool tests when changing apps/desktop/scripts/i18n/* so scanner allowlists, JSON output, TODO limits, and locale-completeness behavior stay covered.
Alongside it, the i18n/no-toast-string-literal ESLint rule guards toast copy in the renderer. It judges the static text of the first argument, so interpolation is no exemption — a hard-coded English template such as toast.error(`Could not open ${name}`) is reported exactly like toast.error('Could not open file'). Pass the values as placeholders instead:
toast.error(t('notes:page.toast.fileNotFound', { target }))A template whose letters all come out of t() — toast.error(`${t('a')}: ${t('b')}`) — stays valid, because only punctuation and spacing are hard-coded. TODO(i18n): wrap … in t() silences the rule for one call site, but pnpm i18n:check runs with --max-todo 0, so it is a review aid rather than a way to land untranslated copy.
Focused Typecheck
Skip the flaky pre-hooks and known pre-existing errors:
pnpm typecheck:node # main process only
pnpm typecheck:web # renderer onlyTypechecking test files
The two configs above compile app source only — they exclude **/*.test.ts(x) and **/*.spec.ts. Test files are compiled by a separate pair of projects:
pnpm --filter @memry/desktop typecheck:test # both halves
pnpm --filter @memry/desktop typecheck:test:node # tests/** harness + main/preload tests
pnpm --filter @memry/desktop typecheck:test:web # renderer testspnpm typecheck runs all four, and CI has a step per project.
apps/desktop/tsconfig.test.node.json and apps/desktop/tsconfig.test.web.json each carry an exclude array listing test files that already failed to compile when the gate was introduced. That list is a shrinking backlog, not a policy: a new or newly-touched test file must compile, so never add an entry. Fixing a listed file's errors means deleting its line as well.
This is what makes type-level guards in test code real. implements, satisfies, and typed harness signatures used to be checked only by your editor — a mock that drifted from the class it stands in for would silently answer undefined at runtime instead of failing the build.
Native Module Rebuild
better-sqlite3 and keytar are the most common source of native runtime failures. If you see ERR_DLOPEN_FAILED, NODE_MODULE_VERSION mismatches, or a Mach-O architecture error:
| Target | Fix |
|---|---|
| Node tests | pnpm rebuild better-sqlite3 |
| Electron app / E2E | bash apps/desktop/scripts/ensure-native.sh electron |
Using the Node fix for Electron (or vice versa) leaves the app silently broken — see Common Gotchas.
Packaged Runtime Smoke
The packaged smoke check builds the app into apps/desktop/dist/mac-arm64/MemryNote.app from an isolated dependency tree, then verifies runtime dependencies resolve from the packaged Resources directory. It also checks native .node binaries for the expected macOS architecture so an arm64 release cannot ship with x64 better-sqlite3 or keytar binaries:
node apps/desktop/scripts/build-packaged-app.js --dir
node apps/desktop/scripts/check-packaged-runtime-deps.jsThe packaged runtime check runs its native-module probe through the built MemryNote.app/Contents/MacOS/MemryNote executable, so it verifies the same Electron runtime that will ship instead of depending on the workspace electron package binary. The desktop unit coverage job sets ELECTRON_OVERRIDE_DIST_PATH=/usr/bin before Vitest so main-process tests can import electron for IPC and BrowserWindow mocks even when the workspace Electron binary download is unavailable on the runner. Main-push E2E jobs still need the workspace electron package binary; ensure-native.sh electron clears fallback install env and verifies path.txt before Playwright launches Electron. That path uses a blocking installer helper pinned to Electron's official release mirror, checks the downloaded archive against Electron's packaged checksum, then verifies the extracted binary before native rebuild starts. ensure-native.sh trusts that helper-level check instead of rerunning the package installer's path.txt probe. CI also reruns the helper at the start of each E2E step, after electron-vite build, so Playwright sees a fresh workspace Electron binary immediately before launch. The E2E launcher passes that resolved package binary as Playwright's explicit executablePath so Playwright does not fall back to resolving Electron from its own package directory. If CI still reaches the launcher with a missing path.txt or executable, the launcher runs the same installer helper once, trusts the helper's validation, and passes the platform-specific package executable path directly instead of importing electron/index.js.
Because several processes can reach that helper at once on one machine — ensure-native.sh from predev / prebuild / pretest:e2e, plus each Playwright worker (workers: 2 locally) — the install is serialised with a lock file next to the electron package, and only one process downloads: the others wait and reuse the finished install. The archive is extracted into a staging directory beside dist/ and swapped in with a rename, and path.txt is written last and never deleted, so a concurrent require('electron') always sees either the previous install or the new one. Without that, an install in flight makes unrelated Vitest suites fail at import time with Electron failed to install correctly, which reads like a code regression rather than an environment problem. A failed download now also leaves an existing install untouched. The behaviour is covered by node --test apps/desktop/scripts/install-electron-binary.test.cjs, which runs offline against a fixture archive.
Release builds create one staged dependency tree per macOS architecture. Build x64 on an Intel runner and arm64 on an Apple Silicon runner; do not build --x64 --arm64 from the same staged package, because native modules are rebuilt for one target architecture at a time.
The macOS signing patch in apps/desktop/scripts/patch-osx-sign-walk.js walks the packaged app serially. Keep that traversal bounded; unbounded parallel file reads can exhaust CI file descriptors while scanning large dependency trees.
Linux release artifacts include the target architecture in both .deb and .AppImage filenames so x64, arm64, and amd64 builds stay distinguishable in GitHub releases.
Coverage Targets
memrynote is pre-production, but desktop and sync-server coverage are now ratcheted. Keep new coverage feature-scoped and avoid catch-all test files.
- Desktop — configured in
apps/desktop/config/vitest.config.ts; current Vitest 4.1/V8 coverage floor is 84.8% statements, 72.4% branches, 85.7% functions, and 86.6% lines. - Sync-server — configured in
apps/sync-server/vitest.config.ts; current floor is 90% for statements, branches, functions, and lines. - Sync harness —
tests/sync-harnessuses the same Vitest major as the sync server so encrypted sync E2E helpers stay on the same runner behavior as Worker tests. - PRs — include the relevant coverage command output when raising or defending a threshold change.
Known Test Files With Known Errors
websocket.test.tsfolders.test.tssync-telemetry.ts
These have pre-existing TypeScript errors that don't reflect runtime issues. They're tracked and excluded from gating.
CI Divergence Playbook
When E2E passes locally but fails in CI, walk through in order:
- Stale
out/— did the CI build succeed before tests? - Error context —
gh run downloadto graberror-context.md. - Timezone — CI runs UTC; date assertions can flake.
- Native ABI — CI rebuilds; check the Electron version is pinned.
- xvfb timing — Electron windows need time to compose; bump waits.