Skip to content

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 the prepare script). If hooks aren't firing, run npm install once to wire core.hooksPath.

Test setup (jest + ts-jest)

  • Location: tests live under test/, mirroring src/ (e.g. test/hooks/utils/PathUtils.test.ts). Keeping them out of src/ means the release tsc and esbuild never compile them.
  • Imports: source is imported through the same bare-specifier aliases used in the app (architecture/..., hooks/...). jest.config.js moduleNameMapper maps them to src/, mirroring tsconfig's baseUrl: "src".
  • Obsidian mock: test/__mocks__/obsidian.ts stubs the Obsidian API (which is external at 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, adds isolatedModules for fast transpile-only compilation).
  • Globals: tests import describe/it/expect from @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 over src/. 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 in eslint.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's no-static-styles-assignment matches el.style.x = …, so a JSX style={{ … }} prop slips past a lint the project otherwise keeps at zero.
  • The locale-parity test compares en.ts with es.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:

UPDATE_API_DOCS=1 npx jest generatedContract

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:

UPDATE_DOCS=1 npx jest capabilityIndex

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 the architecture/plugin barrel — the barrel is a jest mock and a cyclic import can surface a service as undefined.

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.