Vitest 5 handed the webdriverio provider to community maintenance, and no
stable 5.x of `@vitest/browser-webdriverio` was ever published — npm `latest`
is still 4.1.11, with only a 5.0.0-rc.1 from before vitest 5.0.0 shipped.
Pairing that v4 provider with `@vitest/browser` 5 kills the suite at startup:
TypeError: Cannot read properties of undefined (reading 'project')
at createBrowserServer (@vitest/browser/dist/index.js:7900:26)
`@vitest/browser-playwright` 5.0.0 is stable and first-party, and Playwright
1.62.1 is already here for the e2e suites, so the browser toolchain costs
nothing new. Only two of the 129 specs touch provider-visible API at all, both
through the provider-agnostic `userEvent`.
Playwright drives the browser over CDP with no separate driver, so the
chromedriver half of the NixOS workaround goes away: the dev shell keeps
`pkgs.chromium` and `CHROME_BIN`, which now reaches the provider as
`launchOptions.executablePath`. CI installs the browser in a step of its own
rather than inside the test step, whose 15-minute cap exists to catch a browser
session that never starts and should not also have to cover a 190 MB download.
Two specs depended on webdriverio behaviour and are fixed rather than skipped:
- The token-cost assertion read `1.234` where it wanted `1,234`.
`toLocaleString()` takes the browser's locale, and Playwright inherits the
host's where the old Chrome defaulted to en-US. Pinning `contextOptions.locale`
keeps the suite from depending on the developer's machine.
- The format painter's drag-selection selected nothing. Playwright's
`dragAndDrop` turns on drag interception, so the press reaches the page as an
HTML5 drag intent instead of selecting text. Driving the press, move and
release over CDP restores what the test is actually for: proving a *native*
pointer interaction has updated the model selection by the time the `mouseup`
listener runs.
Vitest 5 also moved failure screenshots from `.vitest-attachments` to
`.vitest`, which needs ignoring.
Verified: 127 files / 1626 tests green, coverage 99.91/99.54/100/99.98 against
the 99.5 gate. The Nix path is unexercised — `executablePath` is ordinary
Playwright, but it was not run from a dev shell.
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:
pnpm --filter … exec vitestcan trigger a pnpm auto-install that hitsEPERM. 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.