Testing & guardrails¶
ZettelFlow development is test-driven and protected by layered, automated guardrails. This page documents the setup and the policy. The AI harness mirrors it in the tdd skill (.claude/skills/tdd/) and the CI workflow.
Policy¶
- Test-first. New logic and bug fixes start with a failing test (a bug fix ships with the regression test that proves it).
- Blocking guardrails:
typecheck(tsc) +lint(oxlint) +test(jest) must pass before code is pushed or merged. - Advisory guardrail: the official Obsidian guideline lint (
lint:obsidian) runs but does not block yet — the codebase has a known backlog of violations tracked as issues. It becomes blocking once that backlog is cleared.
Where the guardrails run¶
| Layer | Runs | Checks |
|---|---|---|
| Local, on demand | npm run verify | typecheck + oxlint + jest |
pre-commit (husky) | every git commit | npm run lint (oxlint) |
pre-push (husky) | every git push | npm run typecheck && npm test |
CI (.github/workflows/ci.yml) | every PR / feature push | typecheck + oxlint + jest (blocking); lint:obsidian (advisory) |
Husky activates on
npm install(via thepreparescript). If hooks aren't firing, runnpm installonce to wirecore.hooksPath.
Test setup (jest + ts-jest)¶
- Location: tests live under
test/, mirroringsrc/(e.g.test/hooks/utils/PathUtils.test.ts). Keeping them out ofsrc/means the releasetscand esbuild never compile them. - Imports: source is imported through the same bare-specifier aliases used in the app (
architecture/...,hooks/...).jest.config.jsmoduleNameMappermaps them tosrc/, mirroringtsconfig'sbaseUrl: "src". - Obsidian mock:
test/__mocks__/obsidian.tsstubs the Obsidian API (which isexternalat build time and has no runnable module). Extend it as units under test need more surface. - Compiler: ts-jest uses
tsconfig.jest.json(extends the base tsconfig, addsisolatedModulesfor fast transpile-only compilation). - Globals: tests import
describe/it/expectfrom@jest/globals(no ambient types needed).
Commands: npm test, npm run test:watch, npm run test:coverage.
What to test first¶
Pure logic with no Obsidian runtime is the highest-ROI starting point and is already seeded: architecture/styles/helper.ts, hooks/utils/CompareUtils.ts, hooks/utils/PathUtils.ts. Next: ContentDTO/NoteDTO, the flow graph traversal (FlowImpl), and the wizard callbacks. React components and modals need jsdom + @testing-library/react (add when required).
Linters¶
- oxlint (
npm run lint) — the fast, day-to-day linter oversrc/. Blocking. - eslint-plugin-obsidianmd (
npm run lint:obsidian) — the official Obsidian guideline rules, the same set behind the Community-hub automated review and the 1–100 quality score. Configured ineslint.config.mjs. Advisory for now.
Current baseline¶
At the time this was set up, npm run lint:obsidian reported 475 problems (328 errors, 147 warnings) across src/. That is the backlog to burn down to raise the score; it is tracked by the M1/M2 issues. When it reaches zero (or an agreed threshold), flip the CI lint:obsidian step from advisory (continue-on-error: true) to blocking. See Obsidian review & scoring for the rule catalogue and Project health & roadmap for the plan.
Type-checking¶
npm run typecheck runs tsc -noEmit -skipLibCheck — the same gate the release script uses. esbuild does the actual bundling; tsc only type-checks. Blocking.
The coverage floor (#317, E2)¶
npm run test:coverage collects from src/** and enforces a ratcheting coverage floor set in jest.config.js (coverageThreshold.global). CI runs test:coverage, so a regression that drops coverage below the floor fails the build.
The policy — deliberately, not vanity:
- It is a floor, not a target. We chase behavioral tests of the risky, user-affecting paths (the write paths, the note-builder, the AI path), each with a named failure scenario — not a 100% number to game.
- Raise the floor as tests land, never lower it. The current values (stmts 83 / branch 75 / func 78 / lines 84) sit just below the measured level.
Source-scanning guardrails (#406)¶
Two project rules are absolute in CLAUDE.md — never inline styles and all user-facing text lives in the i18n layer — and neither blocking lint can see them in React:
eslint-plugin-obsidianmd'sno-static-styles-assignmentmatchesel.style.x = …, so a JSXstyle={{ … }}prop slips past a lint the project otherwise keeps at zero.- The locale-parity test compares
en.tswithes.ts; a literal that never entered the i18n layer is invisible to it.
It also catches the one that crashed a real user (#418): an element built through a Node-appending helper. createEl/createDiv/createSpan are Node methods — they create the element and append it to the receiver. Bare, or on document/activeDocument, that appends a second root element and throws "Only one element on document allowed" mid-render, which unmounts the React tree. Note that eslint-plugin-obsidianmd's prefer-create-el pushes the other way, so document.createElement is not the answer either: a ref that starts null and attaches on mount needs no placeholder at all.
test/application/components/wizardConventions.test.ts therefore reads the source of the creation-experience trees (src/application/components/**, src/zettelkasten/**) and fails on either. Two carve-outs are deliberate: a style prop taking a variable (dnd-kit's transform) is not a static style, and an object literal whose keys are CSS custom properties (--zf-step-accent) is the sanctioned way to hand a dynamic value to a stylesheet.
Generated artefacts (#352)¶
Two files are produced from the zf API manifest, not written by hand:
| Artefact | Where |
|---|---|
| The API reference page | docs/api/reference.md |
| The script type declarations | written into the user's JS-library folder, on demand |
generatedContract.test.ts regenerates the reference from the manifest and asserts the committed file matches, so code and docs cannot disagree without failing npm run verify. When you change a member's signature or summary, regenerate rather than editing the page:
The same suite hands the generated .d.ts to the real TypeScript compiler in strict mode. A declaration that referenced a type it never declared would autocomplete happily and then show an error in a file ZettelFlow wrote into the user's vault — so it is checked, not assumed.
The front door (#588)¶
README.md and docs/index.md are the doors a person uses to decide whether to install, so #588 holds them to the same rule as the plugin: a capability nobody can find does not exist. Five guardrails under test/docs/ keep them honest:
| Guardrail | What it asserts |
|---|---|
readmeCeiling.test.ts | the README stays ≤200 lines · ≤3,500 words · ≤25 rows in any table — a ratcheting ceiling (like themeGrid.test.ts; the numbers may only go down) |
readmeNames.ts + readmeNamesKept.test.ts | the 84 capability names the README carried at 2f6198f5 (frozen, extracted in Node so an astral-plane emoji bullet is not dropped) still occur in README.md or under docs/** — nothing shipped disappears |
frontDoor.test.ts | the four practice loops are named and linked on the README's first screen; docs/index.md carries no second inventory; every relative link resolves; no shipped work is called proposed; the privacy disclosure is intact (privacyBullets.ts) |
placementRule.test.ts | CLAUDE.md and the implement skill route a capability by door rank, and neither says "add a row to the Features table" |
capabilityIndex.test.ts | the reader-facing Everything it does page equals its generator and names every capability |
That page is a third generated artefact — a second rendering of CAPABILITIES, keyed by door rather than symbol id (the maintainer-facing sibling is capability doors). Regenerate it, exactly as with the API reference, rather than editing the page:
Its generator lives in test/docs/capabilityIndex.ts rather than beside capabilityAudit.ts because
588's AC-10 froze src/; it moves next to its sibling the next time src/ is unfrozen.¶
The write-path harness (#317, E2)¶
test/support/harness.ts (wireHarness) gives a test an in-memory Obsidian whose FileService / FrontmatterService calls actually round-trip (one shared frontmatter object per file, so a write is visible to a later read). Use it for any vault-mutating path — e.g. the Cultivate writes, quick-capture, lifecycle transitions. The AI provider is tested with a settable requestUrl (__setRequestUrl in the obsidian mock) so no test makes a real network call.
Import services by their file path in tests (e.g.
architecture/plugin/services/FileService), not thearchitecture/pluginbarrel — the barrel is a jest mock and a cyclic import can surface a service asundefined.
Treat each closed score issue as an opportunity to add the tests that lock in the fix.
The verification script (§XIV)¶
Jest proves the projection; it never proves the pixel. Most of what ships here is a projection drawn in a view — a lens, a chip, a hull, a camera flight — so every spec ends with a How to verify section holding both halves of the proof: an automated command → criteria table, and a numbered script a person walks in a real vault (npm run dev:vault).
The rules live in .claude/skills/specify/references/verification.md and the invariant is constitution §XIV. In short:
- Every acceptance criterion has a prover — a command, or a numbered manual step.
- A manual step names why it cannot be automated (WebGL scene · camera flight · view lifecycle · a real vault's shape). Anything else is a missing test.
- The empty state and the negative ("the note is byte-identical", "the layout did not move") are steps, not footnotes.
- Finishing an implementation includes walking the script and fixing it where it drifted.
A manual step is not a lesser test — it is the only test for a scene, and writing it down is what makes a 3D feature reviewable by someone who did not build it.