Skip to content

Test and verify a change

Suite runs three test runners: Vitest for the workspace projects, Playwright for end-to-end journeys, and node --test for @suite/billing, which sits outside the pnpm workspace. Commands run from the repository root unless stated.

Every change runs this, locally:

Terminal window
pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm docs:symbols && pnpm shape

None of those commands needs a runner or an account. pnpm check runs Biome, tsc, vitest run --changed, the node --test suite under services/billing, pnpm docs:symbols and pnpm shape, which is why it belongs after each edit batch; it leaves out pnpm docs:check and the full test run. pnpm verify adds the story suite, the build, and the two fast security scans (security:deps and security:secrets, both described below).

The billing suite is in the edit loop because pnpm test cannot see it: those tests are node --test and Vitest does not collect them. A rename that reached deploy/stripe/fixtures.json and not the tests naming its tiers passed every fast run and only failed at pnpm verify, which is a bad trade for half a second.

Playwright stays outside the gate. No contributor or agent starts it by default, and no change waits on a run. E2E runs when the owner asks for it, or at a commit milestone the owner names. A full pnpm e2e is a milestone action; when a run is asked for, an area script is the cheaper evidence.

ci.yml in .github/workflows has valid YAML, and every run on this account ends in startup_failure with zero jobs, so no workflow is a check on main. A self-hosted runner or an entitlement change would make the workflow usable again.

Command Covers Run it
pnpm check Biome, tsc across the workspace, vitest run --changed, the billing suite, docs symbols, shape After each edit batch
pnpm lint Biome over the whole tree The gate, and after a formatting sweep
pnpm typecheck tsc across every workspace package The gate, and after a type-only change
pnpm test Every Vitest project The gate, and before you report a result
pnpm docs:check Relative links and anchors, code fences, banned prose, generated indexes, limits A prose change, or a change to a limit
pnpm docs:symbols Backticked paths, root scripts and ADR references that resolve A page that names a command or a file
pnpm shape File length, observe call sites, raw Loro writes A change under apps/ or packages/
pnpm verify The gate plus the story suite, the build, security:deps and security:secrets Before a commit you want fully checked, and on a tag
pnpm test:stories The Storybook suite under jsdom plus axe, in both themes A .stories.tsx changed, or you touched a component a story renders

Four scans, one pnpm script each. Nothing here needs a GitHub workflow, a runner or an account, and each script exits non-zero on a finding, so the same command is a check and a gate.

Command Reads Needs
pnpm security:deps the registry advisories for pnpm-lock.yaml, then every package’s published signature the network
pnpm security:secrets every file in the checkout, then the staged diff from the pre-commit hook Docker
pnpm security:osv pnpm-lock.yaml, against the OSV database Docker and the network
pnpm security:image the package set inside a built image Docker and a built image

security:deps runs pnpm audit --audit-level high and then pnpm audit signatures. The second half checks that each tarball came from the registry that published it, which is the part a lockfile alone cannot tell you. A high or critical advisory, or a package with no verified signature, fails it.

security:secrets runs gitleaks in a pinned image (zricethezav/gitleaks:v8.28.0) with the rules in .gitleaks.toml. That file allows the gitignored paths a working copy has (.env, dist/, storybook-static/) and names each fixture credential the tests use, one value at a time, so test files are not exempt as a class and a real key pasted into a test still fails. Findings print redacted, with the file, the line and the rule. The same script runs from .husky/pre-commit as pnpm security:secrets --staged, which scans the index: a credential stops the commit instead of becoming a commit that has to be rewritten out of history.

security:osv runs OSV-Scanner (ghcr.io/google/osv-scanner:v2.7.0) against pnpm-lock.yaml. It reads a different feed from pnpm audit, so the two report different sets of packages and different fixed versions, and running both is the point. The switches:

Terminal window
pnpm security:osv --download # copy the vulnerability database into ~/.cache/suite/osv-scanner
pnpm security:osv --offline # scan with no network at all, from that cache
pnpm security:osv --licences # also report packages outside the licence allowlist

--offline fails when the cache is empty rather than reporting nothing, so an offline run is evidence. --licences checks the packages against a list in the script (MIT, Apache-2.0, ISC, BSD, 0BSD, CC0, BlueOak, Python-2.0, Unlicense, Zlib) and it is opt-in: what it currently reports (MPL-2.0 through lightningcss, ten LGPL-3.0-or-later packages, two UNKNOWN) is a licensing decision, and a build gate is not where that decision is made.

security:image runs Trivy (aquasec/trivy:0.76.1) against suite:local, or against the image named as its argument (pnpm security:image ghcr.io/<owner>/suite:v1.1.0). It reports HIGH and CRITICAL only, and only findings with a fixed version, because a finding with no fix cannot be closed by rebuilding the image. It is a release-time command: pnpm verify leaves it out because it needs a built image. Build one with docker build --target runtime -t suite:local ..

Docker is the only prerequisite for three of the four. A missing daemon stops the command with an instruction; no scan is skipped without saying so. git commit --no-verify is the only way past the hook.

An e2e run starts its own canvas dev server on :5173 and a relay on :1235, with rate limits raised and a database per run. Every run passes through e2e/scripts/preflight.mjs first: if a port is already in use, the run stops before a browser opens and names the process holding it.

Command Covers
pnpm e2e:smoke The specs tagged @smoke: boot, boards and sections, cards, links, selection, library, views, sharing, sign-in, collab
pnpm e2e:canvas Boot, boards, cards, links, selection, input, library, appearance
pnpm e2e:collab Collab, sharing, account
pnpm e2e:views Views
pnpm e2e:changed Playwright’s --only-changed origin/main: the specs touched since that ref
pnpm e2e Every spec, the milestone run
pnpm e2e:doctor Reports the processes holding the e2e ports, and stops nothing
pnpm e2e:stop Stops only those listeners
pnpm e2e:logs The tail of the captured Vite and relay logs under e2e/.artifacts/
Variable Default Meaning
CI unset Selects the github reporter and retries a failed test once. Servers are always fresh outside UI mode.
SUITE_WORKERS 3 Playwright browser workers. Lower it to isolate a timing failure; raise it only on a machine with headroom.
SUITE_TRACE unset on records a trace for every test; anything else keeps retain-on-failure.

Three workers keep the two-browser specs from starving the other browsers on one relay. Artifacts land under e2e/test-results/<test-slug>/: trace.zip on failure, test-failed-1.png at the moment of failure, and video.webm only when a retry happens. The reporter is list or github, so there is no HTML report unless you pass --reporter=html.

Project Runner Scope Command
graph Vitest, node packages/graph/src/**/*.test.ts pnpm exec vitest --project graph
importer Vitest, node packages/import-obsidian/src/**/*.test.ts: the drop walk, markdown and frontmatter parsing, canvas colour mapping, and the vault as a whole pnpm exec vitest --project importer
licence Vitest, node packages/licence/src/**/*.test.ts pnpm exec vitest --project licence
server Vitest, node apps/collab-server/src/**/*.test.ts (http/, store/, projection/, config/, sync/, ops/) pnpm exec vitest --project server
control-plane Vitest, node apps/control-plane/src/**/*.test.ts pnpm exec vitest --project control-plane
auth Vitest, node services/auth/src/**/*.test.ts (http/, identity/, invitations/, licensing/, organizations/, store/) pnpm exec vitest --project auth
canvas Vitest, jsdom apps/canvas/src/**/*.test.{ts,tsx}, with apps/canvas/src/test/setup.ts pnpm exec vitest --project canvas
e2e Playwright, chromium e2e/tests/**/*.spec.ts, grouped by area (boot/, boards/, cards/, links/, collab/, sharing/, …) pnpm e2e
billing node --test services/billing/src/**/*.test.ts pnpm --filter @suite/billing test

pnpm test is vitest run and covers every Vitest project in vitest.config.ts; pnpm --filter @suite/billing test runs the billing suite outside it. UI mode is the one e2e command that reuses a running server, so the window can stay open between runs.

Terminal window
# One unit file, or one test in it.
pnpm exec vitest run packages/graph/src/schema.test.ts
pnpm exec vitest run apps/collab-server/src/store/storeUpgrade.test.ts -t "refuses a store a newer build wrote"
# One spec, or one test in it. Extra arguments reach Playwright.
pnpm e2e e2e/tests/collab/collab.spec.ts
pnpm e2e -g "undoes a removal from the board"

A single spec is the right tool while iterating: it boots the same servers as the full run, so a pass in isolation is real evidence. Treat it as weaker evidence when the failure depends on order or on state an earlier spec left behind.

pnpm e2e:ui opens a persistent Playwright window on http://127.0.0.1:8181: test tree, live browser pane, timeline, and a DOM snapshot per step. One window at a time, since the port is pinned; a second call fails with EADDRINUSE. Free it with pnpm e2e:ui:stop.

pnpm e2e:headed runs the real suite in a visible browser, one worker (--workers=1), sequential and followable. Use it when the failure depends on execution order, and UI mode when you want to pause and inspect.

pnpm e2e:trace opens the newest trace.zip from e2e/test-results/. Traces are kept on failure by default; SUITE_TRACE=on pnpm e2e records one for every test.

pnpm e2e:logs prints the tail of the captured Vite and relay logs under e2e/.artifacts/. Read it when a run stops producing test results or a browser loses its server. The relay log names the request that failed; the Vite log names the file that triggered a reload.

An e2e spec that passes alone and fails in the suite usually depends on order or timing, not on a server another spec started. Run pnpm e2e:headed with --workers=1 to keep order and watch the sequence.

A jsdom test that fails on a measurement is the wrong tool: layout, scrolling and getBoundingClientRect are zero there, so the assertion belongs in an e2e spec or a story.

A graph test that fails only in the full run usually shares a seed or reuses a container across cases. A 15 s timeout means real work.

A flaky test is a bug in the test. --retries exists for videos and traces when CI is set; do not use it to turn a red test green locally.

pnpm coverage runs the whole Vitest suite with @vitest/coverage-v8 and writes coverage/ (coverage/lcov-report/index.html for the browsable view). coverage.include names four sources: graph, licence, server and canvas, so importer, control-plane and auth run without being measured. Of the measured four only @suite/graph is gated, through one glob key in coverage.thresholds at the root of vitest.config.ts. The others are printed, not gated: their uncovered lines are socket, SQLite and entry-point wiring that e2e already drives, and a threshold would invite assertion-free tests.

Stryker asks whether a test would notice if an expression were wrong. It is a diagnostic for choosing the next test, never a score to raise.

Terminal window
pnpm mutation:quick --mutate packages/graph/src/views.ts # the normal way to try one file
pnpm mutation:server # licence, limits, config
pnpm mutation # the full graph run, minutes not seconds

Read reports/mutation/<run>.html. A survivor is a missing behaviour, an equivalent mutant, or tool noise. Missing behaviour: add a test through the public API. Equivalent mutant: a // Stryker disable next-line comment with the reason. Never close one with expect(result).toBeDefined().

Five fast-check suites exist, and they are where “it must hold for every input” belongs:

Suite Property
packages/graph/src/sections.property.test.ts derived section membership under arbitrary placement moves
packages/graph/src/merge.property.test.ts concurrent edits converge regardless of order
packages/graph/src/search.property.test.ts query semantics hold across generated workspaces
packages/licence/src/property.test.ts licence parsing rejects every mutation
apps/collab-server/src/config/limits.property.test.ts limit resolution order holds for any layer mix

They run inside pnpm test. Add a case to the matching suite rather than a table of examples when the invariant is the thing worth pinning.

e2e/tests/visual/visual.spec.ts keeps a small curated set of layout snapshots as committed PNGs under e2e/tests/visual/, so a run needs no vendor service. Story surfaces are served from the built Storybook on :6006; the app states run against the normal webServer.

Terminal window
pnpm --filter @suite/e2e e2e:visual # check the baselines
pnpm --filter @suite/e2e e2e:visual:update # rewrite them

--update-snapshots is the only supported way to change a baseline, and never the first thing to try. A failure means the pixels moved: read -diff.png and -actual.png in test-results/ and decide whether the movement is the change you intended.