Skip to content

Testing ​

Vitest for unit and integration tests, Playwright for E2E (Electron). Tests run via Turborepo so you can scope by package.

Quick Reference ​

bash
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.js

Unit + Integration (Vitest) ​

Run from the repo root or scope with --filter:

bash
pnpm --filter @memry/desktop test
pnpm --filter @memry/desktop test path/to/file.test.ts
pnpm --filter @memry/desktop test -- --watch

Test 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.

No literal dates. seedJournalDate in scripts/seed-data/date.ts shifts a narrative date by the offset between JOURNAL_ANCHOR and the run day, so entries and date properties dated before the anchor land in the past and the ones after it land in the future. It covers journal filenames, the startDate / endDate / deadline note properties, dated rows inside note bodies, and any [[YYYY-MM-DD]] wikilink pointing at a journal entry — a hardcoded date there dangles the moment the timeline moves.

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.

For notes, those rows are only a head start. A markdown note's project membership lives in its project: frontmatter, and the note-project-links projector rebuilds project_links from it on every note.upserted — a seeded row with no matching frontmatter key is deleted the first time that note is indexed. NOTE_PROJECTS in scripts/seed-data/notes.ts is what actually makes the membership stick; project-links.test.ts fails if a seeded note link has no frontmatter behind it.

Property definitions work the same way. .memry/properties.md is the source of truth: the service reloads it on vault open and rebuilds the property_definitions table from it, so definitions written only to the table are wiped before anyone sees them. The seed writes that file from scripts/seed-data/properties.ts, which is also where the status categories and the select and multiselect option lists live. Only the five persistable kinds may appear in the file — status, select, multiselect, date, project — because one unparsable entry fails the schema and discards every definition in it; text, number, URL and checkbox properties are typed by inference from their values instead.

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:

bash
pnpm --filter @memry/desktop test:main -- scripts/seed-data
pnpm --filter @memry/desktop seed:vault

Benchmark Vault ​

The demo seed stays small so it reads well in screenshots, which leaves nothing to measure indexing, search, the graph, or the notes list against. pnpm seed1k fills a separate vault with 1000 fully-written notes — frontmatter, headings, bullet lists, a table, checkboxes, code blocks, and wiki-links, roughly 2 KB of markdown each — spread evenly across ten folders.

bash
pnpm seed1k                                  # ~/MemryBenchVault, 1000 notes, seed 42
pnpm seed1k -- --count=5000                  # a larger corpus
pnpm seed1k -- --vault=/tmp/bench --seed=7   # a second vault to compare against

Content comes from a seeded PRNG (scripts/seed-data/bulk-notes.ts), so the same --seed and --count produce a byte-identical vault and two benchmark runs compare like for like. The command always wipes its target first, and writes note_metadata rows alongside the files so note ids stay stable when the indexer adopts them by path.

Key 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) ​

bash
pnpm test:e2e
pnpm test:e2e --headed                  # see the window
pnpm test:e2e --ui                      # interactive runner
pnpm test:e2e -- tests/notes.spec.ts    # one file

E2E runs against the built bundle (out/main/index.js), not source. After editing source, rebuild:

bash
npx electron-vite build
pnpm test:e2e

Skipping the rebuild is the #1 source of "passes locally, fails in CI" surprises.

Main-push Desktop CI runs the full Electron E2E suite across 8 Playwright shards, each running 2 workers — the same 16 parallel lanes the old 16-shard matrix had, but the per-job install and build is paid half as often. Those shards do not build the app themselves: a single Desktop build (Linux) job builds out/ plus the Electron-ABI native modules once and ships them as an artifact that every Linux E2E job restores. 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.

Two workers means two Electron apps at once, so anything an E2E launch touches must be per-launch: each launch gets its own mkdtemp user-data dir, its own e2e-<uuid> device id (which namespaces every keychain account), and every server the harness starts binds an ephemeral port — the simulated sync server included, since miniflare's default 8787 would otherwise collide between workers.

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:

bash
pnpm --filter @memry/desktop keychain:purge-e2e            # dry run, prints counts
pnpm --filter @memry/desktop keychain:purge-e2e -- --apply # delete

E2E 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 ​

bash
pnpm ipc:check       # validate renderer/main contracts typecheck
pnpm ipc:generate    # regenerate the typed invoke map

When 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 ​

bash
pnpm i18n:check
pnpm test:i18n-tools

The 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:

ts
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:

bash
pnpm typecheck:node     # main process only
pnpm typecheck:web      # renderer only

Typechecking 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:

bash
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 tests

pnpm 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:

TargetFix
Node testspnpm rebuild better-sqlite3
Electron app / E2Ebash 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:

bash
node apps/desktop/scripts/build-packaged-app.js --dir
node apps/desktop/scripts/check-packaged-runtime-deps.js

The 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) — 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-harness uses 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.ts
  • folders.test.ts
  • sync-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:

  1. Stale out/ — did the CI build succeed before tests?
  2. Error context — gh run download to grab error-context.md.
  3. Timezone — CI runs UTC; date assertions can flake.
  4. Native ABI — CI rebuilds; check the Electron version is pinned.
  5. xvfb timing — Electron windows need time to compose; bump waits.

Released under the GNU GPL v3.0.