CoreApiTester now seeds the execution context from the trilium-* request headers, so Pattern 0 documents how to drive a hoisted request and warns that a spec omitting trilium-hoisted-note-id exercises the unhoisted path. Anything reading hoistedNoteService.getHoistedNoteId() — quick search, autocomplete, SearchContext's implicit ancestorNoteId — sees "root" without it, so a passing spec is not evidence that scoping works. A contributor's quick-search PR asserted exactly that and proved nothing. The Windows note covered only `pnpm --filter … exec vitest`; the auto-install fires for a package's own test script too. It now names the symptom seen here: EPERM / Access is denied on a node_modules directory VS Code holds open, which rolls back cleanly but loses the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
| name | description |
|---|---|
| writing-unit-tests | Use when writing, extending, or debugging Vitest unit tests anywhere in the Trilium monorepo — Preact components, jQuery widgets, client services, or the server/trilium-core backend. Covers how to render components (zero new deps), the easy-froca/becca fixtures, supertest API patterns, the honest coverage config, running a single test, and the known gotchas. |
Writing unit tests in Trilium
Trilium is a pnpm monorepo tested with Vitest (v8 coverage). This skill captures the patterns that actually work here, plus the footguns that waste time. Read the per-layer reference file for the area you're touching.
First principle: prefer extracting pure logic
The dominant, lowest-risk pattern across this repo is extract the decision/transform logic out of a component/widget/route into a top-level export function that takes plain inputs and returns a plain value, then test that function. Rendering and side effects stay thin; the logic gets covered cheaply. apps/client/src/widgets/ribbon/FormattingToolbar.tsx (getFormattingToolbarState, tested in FormattingToolbar.spec.ts) is the canonical example. Reach for rendering/integration only when the behavior is the DOM/HTTP.
Also follow CLAUDE.md: write concise tests (group related assertions in one it, don't make one test per trivial passthrough), and when you add pure business logic, extract + unit-test it.
Which technique? (decision tree)
| You're testing… | Technique | Reference |
|---|---|---|
A reusable Preact component (apps/client/src/widgets/react/) |
Render with raw preact render() into a happy-dom div |
client-components.md |
| A jQuery widget / type widget | Extract logic → test fn; or instantiate + assert on $widget |
client-logic-and-services.md |
A client service (apps/client/src/services/) |
easy-froca + override server.*; or pure logic |
client-logic-and-services.md |
A server service (apps/server/src or packages/trilium-core/src) |
Real in-memory DB (sql_init + cls.init) or mocked becca |
server-and-core.md |
A shared core API route (packages/trilium-core/src/routes/api/*) |
CoreApiTester — in-process, cross-runtime, real services (incl. zip export/import/multipart), minimal mocks |
server-and-core.md Pattern 0 |
| An internal REST API route's Express transport (CSRF/auth/wiring) | supertest agent + /login + /bootstrap CSRF |
server-and-core.md Pattern 1 |
| An ETAPI endpoint | supertest + basic-auth via spec/etapi/utils.ts |
server-and-core.md |
| Pure logic (parsers, formatters, math, data maps) | Plain Vitest, no harness | any reference |
Specialized harnesses (owned by sibling skills)
Some layers have a purpose-built spec harness documented in the skill that owns the feature — route there instead of re-deriving it:
| You're testing… | Harness shape | Skill |
|---|---|---|
A DB migration (packages/trilium-core/src/migrations/*) |
getSql() captured in beforeEach (not at describe time — core isn't initialized yet) + sql.rebuildFromBuffer(fixtureDb) per test, mutated inside cls.getContext().init |
evolving-the-data-model |
An Electron preload bridge method (apps/desktop/src/preload.ts) |
vi.mock("electron", …) mirroring the IPC channels into in-memory maps; assert the exposed window.electronApi shape (apps/desktop/src/preload.spec.ts) |
developing-electron-desktop |
A CKEditor 5 plugin in Trilium's own bundle (packages/ckeditor5) |
Browser-mode headless Chrome: ClassicEditor.create + licenseKey: "GPL" + _setModelData, co-located src/**/*.spec.ts |
ckeditor5-testing |
Running tests
Run the narrowest thing that covers the change, and never the full suite.
pnpm test:all,test:parallel,test:sequentialandpnpm coveragetake minutes and are CI's job — reach for one only if the user asks. Never run ESLint either (pnpm dev:linter-check/-fix,npx eslint): it currently dies out-of-memory, so a run costs minutes and tells you nothing. Typecheck withpnpm typecheck(never a rawtsc -p/tsc -b, which misses the project references) — cheaper than a suite, but not instant, so run it once a change is finished rather than after every edit.
- Filtered (preferred):
pnpm --filter <pkg> test <path-or-pattern>— the trailing argument is a substring filter over spec paths, sopnpm --filter server test special_notesruns every match. - Whole package:
pnpm --filter <pkg> test(e.g.@triliumnext/client,@triliumnext/server,@triliumnext/commons). - Single file (server):
pnpm --filter server test spec/etapi/search.spec.ts - Single file (client):
pnpm --filter @triliumnext/client exec vitest run src/widgets/react/Button.spec.tsx - Coverage: append
--coverage. - Server tests run sequentially (shared DB,
pool: "forks", fork isolation is per file). Client/package tests run in parallel.
Windows/sandbox note: any
pnpm --filter …invocation —exec vitestand the package's owntestscript alike — can trigger a pnpm auto-install that hitsEPERM/Access is deniedon anode_modulesdirectory VS Code holds open (it rolls back, but the run is lost). If so, run the hoisted binary directly (it lives in the repo-rootnode_modules):CI=true node node_modules/vitest/vitest.mjs run <spec> --root apps/client, ornode_modules/.bin/vitest.CMD run <spec> --root apps/<app>.
Coverage config rules (Vitest 4)
Each project's test config (vite.config.* / vitest.config.*) measures coverage honestly via:
coverage: {
provider: "v8" as const,
include: ["src/**/*.{ts,tsx}"], // makes UNTESTED files count too
exclude: ["**/*.{test,spec}.{ts,mts,cts,tsx,js,jsx}", "**/*.d.ts"],
reporter: ["text", "lcov"]
}
- Do NOT use
all: true— it was removed in Vitest 4 and is a type error;includealready pulls in untested files. - If a config sets Vite
root: "src"(e.g.apps/standalone), coverageincludeglobs resolve relative tosrc, so use["**/*.{ts,tsx}"], not["src/**/…"]. - Files outside the project
rootneedcoverage.allowExternal: true. v8 defaults it tofalse, which silently drops every out-of-root file — so anincludeglob alone (e.g.../../packages/trilium-core/src/**) is ignored and contributes nothing.trilium-corehas no runner of its own; its coverage is measured throughapps/serverandapps/standalone, and both must setallowExternal: trueplus a core glob incoverage.includewhose../depth matches that suite'sroot:../../packages/trilium-core/src/**for server (rootapps/server),../../../packages/trilium-core/src/**for standalone (rootapps/standalone/src). WithoutallowExternalcore never reaches the lcov or Codecov. The lcov writes these as../…/packages/…paths;codecov.yml'sfixes:entries strip the../so they map onto the repo tree. - For provably-unreachable defensive branches, mark them with
/* v8 ignore next *///* v8 ignore start */…/* v8 ignore stop */and a one-line reason — don't delete the guard or write a fake test./* v8 ignore next */does not reliably suppress a branch sitting on a} else if (…) {line — use thestart/stopform there. Better still, check whether the arm is dead because the code can be simplified: a trailingelse if (name) { … } return []wherenameis provably truthy collapses to a directreturn, removing the branch honestly instead of hiding it. Project style is a plain/* v8 ignore next -- reason */— no@preserve. - Checking one file's coverage: the v8 text reporter crashes (
PARSE_ERRORwhile remapping unrelated uncovered core files) on single-spec--coverageruns. Producelcov/json/json-summaryinstead and parse it with the analyzing-coverage skill'scoverage.mjs(… summaryfor pct/aggregate,… gaps --filter <file>for the uncovered line list). The full-suite text report (run over a directory) is fine. Don't hand-roll a coverage parser — that script already handles all three formats and the Windows footguns.
Universal gotchas
-
A green Vitest run is not proof the spec compiles. Vitest strips types with esbuild, so specs are never type-checked by the run itself — and
apps/client/tsconfig.app.jsondeliberately excludessrc/**/*.spec.ts(x). They are checked bytsconfig.spec.json, whichpnpm typecheckreaches through the project references, sopnpm typecheckis the only thing that catches a broken spec — but run it once, after the whole batch of specs is written, never per file or per edit. It walks the project references and costs real time; spending that repeatedly is the single easiest way to make a test-writing session slow. Passing tests routinely hide mechanical type errors that then fail CI. The recurring ones:act(() => someExpression)returning a value where avoidcallback is expected,Component | undefinedpassed where the context wants| null, and mock-froca helpers whose defaultgetNote = vi.fn(async () => undefined)infersPromise<undefined>and rejects a caller'svi.fn(async () => someNote)— type such params loosely ((...args: unknown[]) => Promise<unknown>). -
No non-null assertions (
!) — never use the TypeScript postfix!operator, even in tests. Narrow instead:becca.getNoteOrThrow(id)/getAttachmentOrThrow(id)instead ofbecca.getNote(id)!;value?.prop ?? fallbackthen assert; or capture into a const after anexpect(x).toBeDefined()/null check. (Project rule — seeCLAUDE.mdCode Style.) -
vi.mockis hoisted above imports. Put component/module imports after thevi.mock(...)calls; mock factories can't reference outer non-hoisted variables. Partial-mock withasync (importOriginal) => ({ ...(await importOriginal()), onlyThis: vi.fn() }). -
vi.resetModules()dropsimportOriginal-based partial mocks. The next dynamic import of the module gets the real dependency back, not your stub, and the symptom reads as "the mock isn't applying" rather than as a reset problem. Prefer registering singletons and IPC handlers once and ordering the tests instead — put the one that latches module-level state last. Where a reset is genuinely needed (a value cached on first call), mock with a full factory rather than animportOriginalpartial. -
Don't assert on translated (i18n) strings — assert structure/keys/behavior (classes, counts, ids), not human-readable English.
-
happy-dom is not a browser:
getBoundingClientRect()returns zeros,ResizeObserver/layout/visibility are stubs. Anything pixel/size/scroll-based needs@vitest/browser, not happy-dom. -
@vitest/browserreal-browser mode IS configured — thepackages/ckeditor5,-mermaidand-mathbundles run their co-locatedsrc/**/*.spec.tsin headless Chromium (@vitest/browser-playwright; seepackages/ckeditor5/vitest.config.ts). These are the browser-modetest:sequentialsuites. Reserve real-browser mode for genuine layout/integration needs (CKEditor, Excalidraw, Modal transitions, size measurement); normal unit tests stay on happy-dom. -
WASM scrypt is ~10× slower under the standalone suite (pure-JS
scrypt-jsunder V8 coverage instrumentation, vs Node's nativescryptSync) — enough to blow the 5s default. A core spec that hashes a password bumps the timeout for the standalone runtime only; copy the guard frompackages/trilium-core/src/routes/api/login.spec.ts:13:const isBrowserRuntime = typeof window !== "undefined"; if (isBrowserRuntime) { vi.setConfig({ testTimeout: 60000, hookTimeout: 60000 }); } -
A spec that hangs at exit takes the whole suite down with it. An unclosed timer,
ResizeObserver, event listener or in-flightfetchcan let every test pass and then stop Vitest from exiting — which hangs the fullpnpm testrun, and CI with it. Because the failure is at teardown, the spec looks green in isolation. Diagnose by sweeping the specs one at a time under a hard timeout and looking for exit code 124:find <dir> -name '*.spec.tsx' | xargs -P6 -I{} timeout 70 <vitest> run {}Use absolute paths in that command — a background shell starts at the repo root, not wherever you last
cd'd. Clean up the handle in the spec rather than raising the timeout.