Testing Strategy
Two Axes: Testing Levels and Execution Tiers
Every test has a position on two independent axes.
Level answers "what can the test see?": pure logic (unit), DOM (component), built artifact, real browser (E2E), or real platform. The level is fixed by what the assertion needs to observe — it does not change based on how long the test takes or how hardware-constrained it is.
Tier answers "where and when does it run?": inner loop, PR gate, scheduled macOS job, or local heavy lane. The tier is chosen by cost, hardware constraints, and timing.
Keeping the axes separate dissolves the most common escalation mistake: when a test "feels too heavy for CI", that is a tier question, not a reason to rewrite it at a lower level. Fix the tier assignment; keep the assertion where it belongs.
This repo's tier mapping
| Tier | Name | Surface | Definition |
|---|---|---|---|
| T0 | Inner loop | constant | typecheck, affected vitest, cargo test; seconds; run constantly while implementing |
| T1 | PR gate | ci.yml | required checks: lint + typecheck + unit + build + CI-safe Chromium e2e; target ≤10 min; the authoritative gate |
| T3 | Scheduled re-exam | e2e-webkit-macos.yml | @interactive + @macos-only WebKit pass on a real macOS runner; weekly cron + workflow_dispatch for on-demand pre-merge runs |
| T4 | Local heavy lane | pnpm b4push / pnpm exam | bounded fast pre-push pass (b4push) + platform-gated heavy run (exam); convenience, not enforcement |
No T2 (T1 is within its time budget). No unused tiers are scaffolded.
Tag Taxonomy
Tags appear in the Playwright test.describe name (e.g., test.describe("My Feature @interactive", () => { … })).
| Tag | Meaning | Tier |
|---|---|---|
| (untagged) | CI-safe default — fast, DOM-only, no shortcut engine; the default for every new e2e test | T1 + T4 b4push Chromium |
@interactive | Needs the app's shortcut engine (keyboard delivery) | T3 macOS + T4 exam WebKit |
@macos-only / @webkit-only | Trustworthy only on real macOS WebKit (evidence-based, per #2604) | T3 macOS |
@flaky | Quarantined — inline issue URL required within 2 lines; excluded from strict gates; runs allowed-to-fail in T3; exits via fix/demote/delete with a deadline | T3 allowed-to-fail |
@verification | One-time agent proof artifact — excluded from every regression surface (ci.yml, b4push, exam, scheduled strict step) | no gate |
@heavy | Slow but CI-capable (defined; currently unused) | T2/T3 |
@gpu | Needs hardware GPU / video encoder (defined; currently unused) | T3 + local heavy |
@smoke | Critical-journey subset (defined; currently unused) | T1 |
@flaky and pass-on-retry
playwright.config.ts sets retries: 2 for CI but the local lanes (b4push, exam) run with zero retries. A test that passes on its second or third CI attempt is reported green — but pass-on-retry is a triage signal, not a success. It belongs in the @flaky quarantine pipeline: tag with an inline issue URL, exclude from strict gates, run allowed-to-fail in T3, and fix or delete with a deadline.
Vitest quarantine: @flaky-vitest
Vitest has no equivalent allowed-to-fail execution lane. A quarantined file uses a greppable header with a full tracking-issue URL and skips its top-level describes:
// @flaky-vitest — quarantined (reason): https://github.com/zudolab/zudo-text/issues/NNNN
describe.skip("flaky suite", () => {
// …
});Unlike Playwright @flaky, which still runs allowed-to-fail in T3, describe.skip yields zero further failure data. Every @flaky-vitest issue therefore needs a re-triage deadline and one exit: fix the flake, replace it with a cheaper guard, or delete the test. Remove the marker and describe.skip in the same PR as the fix.
The CI workspace-package test step is wrapped by nick-fields/retry with max_attempts: 2, so a first-attempt Vitest failure may finish green. That pass-on-retry is still a triage signal; inspect and investigate the failed attempt rather than treating the final job status as success.
@verification and the graduation rule
Agent-authored verification specs are tagged @verification and excluded from all gates. Promotion to a regression tier is explicit: remove the tag, assign the appropriate tier (or leave untagged for T1), and confirm the spec fits within the target suite's time budget. An agent never promotes its own verification spec — it proposes promotion in the PR description and leaves the decision to the reviewer.
b4push vs exam Contract
| Command | Contents | Budget | Who runs it |
|---|---|---|---|
pnpm b4push | Bounded local mirror of the T1 PR gate: lint, typecheck, build, CI-safe Chromium e2e (grep-invert @interactive|@flaky|@verification). No WebKit pass. | ≤10 min target | Everyone, before every push, on any machine |
pnpm exam | Platform-gated heavy run: WebKit @interactive --workers=1, @macos-only/@webkit-only on Darwin, RUN_FLAKY=1 opt-in quarantine pass in both projects | Open-ended | Opt-in; requires a capable macOS host; FORCE_EXAM=1 required on non-Darwin |
b4push and exam are different jobs and must stay separate — one command slowly accreting both jobs is what breaks the pre-push gate. When b4push exceeds its budget, people skip it, and nothing runs before push. exam is allowed to be slow precisely because nobody sits waiting for it.
If a change touches code from the module-to-spec mapping table in e2e/, pnpm b4push warns you to also run pnpm exam or dispatch gh workflow run e2e-webkit-macos.yml --ref <branch> before declaring done.
MCP lanes
The MCP checks use the same level/tier separation as the rest of the repository. The level says what the assertion can observe; the tier says when the cost is acceptable. A fake transport is useful deterministic evidence, but it is not deployed authenticated evidence.
| Tier | MCP lane | Evidence | Boundary |
|---|---|---|---|
| T0 | Package inner loop | pnpm --filter @takazudo/zudotext-mcp test (Vitest) | Fast package behavior, inventory, schema quality, manifest, and skill checks; fakes do not prove a real backend or account. |
| T1 | PR gate | Package tests, agent-route-inventory, feature-manifest, workers/, and release:verify packaged smoke | The route and real-Hono/Miniflare journey are deterministic PR evidence; the packaged smoke proves installation and binary shape, not a deployed service. |
| T3 | Scheduled live lane | Weekly mcp-live-verify.yml (live:verify) | Requires the owner-provisioned smoke workspace, narrow PAT, password, deployed server, and published release. Only a conforming scheduled run qualifies as live authenticated evidence. |
| T4 | Local heavy/manual lane | live:verify on macOS and eval:agents | live:verify is a repeatable owner smoke; eval:agents is a manual, nondeterministic scenario lane. Archive each run's evidence; neither is a PR gate. |
Adding or changing an MCP tool
Use this checklist for a new tool, a schema or description change, or a route and skill contract change. Keep the package copy in packages/ verbatim with this checklist.
Register the tool in its family, regenerate
tool-inventory.json, and review the description diff withpnpm -.- filter @takazudo/ zudotext- mcp exec vitest run src/ server- inventory. test. ts - u Add or adjust the
MCP_FEATURESrow or an explicit exemption, then run the route-parity test againstworkers/.sync- server/ test/ agent- route- inventory. json Update both shipped
SKILL.mdfiles and every counted document named bypackages/: the package README,zudotext- mcp/ docs- inventory. test. mjs doc/,src/ content/ docs/ packages/ zudotext- mcp. mdx doc/, andsrc/ content/ docs/ guide/ mcp- integration. mdx doc/. Also checksrc/ content/ docs/ architecture/ local- agent- authoring. mdx doc/: it is a subset-inventory document, so a renamed or removed tool it names in call form must be fixed there too, but it never needs a tool-count update.src/ content/ docs/ guide/ agent- task- recipes. mdx Extend
workers/only when the tool joins the core connected authoring story.sync- server/ test/ mcp- journey Run
pnpm -.- dir packages/ zudotext- mcp release: verify - - - - expected- tag "zudotext- mcp- v$(node - p \ "require('. / packages/ zudotext- mcp/ package. json'). version\ ")" Note the tool, inventory, skill, or release impact in
packages/.zudotext- mcp/ RELEASING. md
Unit Tests — TypeScript Packages
Packages use Vitest for unit testing. Packages with test configurations:
@takazudo/file-utils@takazudo/find-in-page@takazudo/shortcut-engine@takazudo/color-themes@takazudo/code-block@takazudo/command-palette@takazudo/kanban-parser@takazudo/kanban-board@takazudo/app-defaults@takazudo/backend-bridge
Run tests for a specific package:
cd packages/<package-name>
pnpm testUI Component Tests
@takazudo/ui-components uses Vitest with jsdom and Testing Library for component tests:
@testing-library/react— Component rendering and queries@testing-library/jest-dom— DOM assertions@testing-library/user-event— User interaction simulation
The test environment is configured in vitest.config.ts:
export default defineConfig({
test: {
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["./vitest-setup.ts"],
},
});Backend Tests — Rust
The Rust backend unit tests live in the zudotext-core crate (tauri-app/core/):
tauri- — Helper modules with #[cfg(test)] sections:
filename.rs— Filename generation logicfrontmatter.rs— Markdown frontmatter parsingpath_utils.rs— Path utility functionspath_containment.rs— Path containment checkspin_path.rs— Pin path resolutionpin_tree.rs— Pin directory tree buildingsafe_path.rs— Path sanitizationwatcher_dedup.rs— File watcher deduplication
tauri- contains only thin re-export shims (pub use zudotext_core::helpers::…) with no #[cfg(test)] code of their own.
tauri-app/core/ — The zudotext-core crate mirrors business logic without Tauri dependencies, enabling cargo test on any platform (including WSL2 and CI without GTK libs):
cd tauri-app/core && cargo testRun all Rust tests:
cd tauri-app
cargo testFrontend Testing with MockAdapter
The @takazudo/backend-bridge package provides a MockAdapter that replaces the real Tauri backend in tests, Storybook, and the standalone mock dev mode (pnpm dev:mock). This enables frontend development and testing without a running Tauri process.
The BackendAPI Adapter Pattern
The frontend never imports @tauri-apps/api directly. Instead, all backend communication goes through a BackendAPI interface with swappable adapters. At app startup, exactly one adapter is initialized via initBackend(adapter). All components call getBackend() to access the backend. This means the same UI code runs in Tauri, in mock dev mode, in REST dev mode, and in tests — no conditional imports or branching needed.
See the Backend Bridge Pattern architecture page for the full adapter design.
Using the Mock Adapter
import { initBackend } from "@takazudo/backend-bridge";
import { createMockAdapter } from "@takazudo/backend-bridge/mock-adapter";
const { api, controls } = createMockAdapter();
initBackend(api);
// Use api just like the real backend
await api.messages.list();
// Simulate backend events
controls.triggerMessagesChanged("test.md");
controls.triggerNotesChanged("inbox");MockControls
The controls object provides programmatic access to the mock backend's internal state:
interface MockControls {
// Trigger event listeners (simulates backend events)
triggerMessagesChanged: (filename?: string) => void;
triggerPinFileChanged: (entryPath: string) => void;
triggerDraftExternalChange: (draftNumber: number) => void;
triggerIncomingWorkspaceConflict: (conflict: IncomingWorkspaceConflict) => void;
triggerNotesChanged: (dir: string) => void;
triggerSchemaChanged: () => void;
triggerSubscriptionInfoChanged: (info: SubscriptionInfo) => void;
// State setters
setAuthState: (state: AuthState) => void;
setSubscriptionInfo: (info: SubscriptionInfo) => void;
addRemoteFile: (path: string, content: string) => void;
// Direct access to in-memory stores
files: Map<string, string>; // message files
pinFiles: Map<string, string>; // pin files (keyed by "pinIndex:entryPath")
remoteFiles: Map<string, string>; // simulated remote files for sync
}Customizing Seed Data
To set up specific test scenarios, write to the internal stores before running your test:
const { api, controls } = createMockAdapter();
initBackend(api);
// Seed message files
controls.files.set("meeting-notes.md", "---\ntitle: Meeting Notes\n---\n\n# Notes");
controls.files.set("draft-email.md", "---\ntitle: Draft Email\n---\n\n# Email");
// Seed pin files
controls.pinFiles.set("0:readme.md", "# Project README");
// Trigger UI refresh
controls.triggerMessagesChanged();Storybook Integration
Storybook stories for backend-dependent components use the mock adapter to provide realistic data without requiring Tauri. Initialize the adapter in the story setup:
import { initBackend } from "@takazudo/backend-bridge";
import { createMockAdapter } from "@takazudo/backend-bridge/mock-adapter";
const { api } = createMockAdapter();
initBackend(api);The mock adapter provides:
In-memory file stores for messages, pins, and drafts
Event simulation via
controls(message, note, schema, workspace-conflict, and subscription changes)Same
BackendAPIinterface as the realTauriAdapter
Testing with Real Data via REST Mode
When you need to test the frontend against the real Rust backend with full browser DevTools, use pnpm dev:rest (requires pnpm tauri:dev running in a separate terminal). See the REST Dev Mode setup guide for details.
REST mode uses the RestAdapter which talks to the same Rust backend over HTTP instead of Tauri IPC.
The filesystem-backed message and note paths in this mode are a retained local-engine development fallback. They do not represent the shipped cloud-workspace renderer's storage path.
This gives you:
Real file system data — your actual messages, pins, and settings
Chrome network tab — inspect every backend call, response times, and payloads
Chrome performance profiler — identify rendering bottlenecks with real data volumes
Accessibility auditing — use browser extensions on the live app
REST mode complements the MockAdapter approach: use mock mode for isolated component tests and rapid UI iteration, and REST mode when you need to verify behavior with real backend data and full browser debugging tools.
See the Backend Bridge Pattern docs for the adapter architecture.
Renderer Typecheck Gate
pnpm typecheck runs tsc --noEmit against the renderer production tree using tauri-. It resolves all workspace packages (@takazudo/*) from their TypeScript source (src/) rather than pre-built .d.ts files (Path B: "types" → source). This means a type error anywhere in a package's source that the renderer depends on will fail the gate — not just errors in pre-shipped .d.ts declarations.
The gate is zero-error and is enforced in two places:
CI (
.,github/ workflows/ ci. yml checksjob): the "Renderer typecheck" step runs after "Build workspace packages".Pre-push (
scripts/):run- b4push. sh pnpm typecheckruns after "Build workspace packages" as the "Renderer typecheck" step.
What it catches
Two bug classes that previously slipped through:
1. Missing barrel re-exports. A type defined in a package's source but missing from its index.ts barrel will resolve fine against the built .d.ts (which may include it transitively) but fail when tsc reads source directly. Example: removing LocalDirEntry from packages/ produces:
renderer/components/directory-tree-picker.tsx(3,15): error TS2724:
'"@takazudo/backend-bridge"' has no exported member named 'LocalDirEntry'. 2. Feeding string[] into a string-typed CodeMirror keymap key. AppSettings.shortcuts.* values are string[] (multi-binding). CodeMirror's KeyBinding.key is typed string. Passing the array without taking [0] produces:
renderer/hooks/use-codemirror-editor.ts(157,7): error TS2322:
Type 'string | string[]' is not assignable to type 'string'.
Type 'string[]' is not assignable to type 'string'. These two bugs are the regressions from #2142 that the gate was designed to prevent.
Reproducing the breaks
To verify the gate catches each bug class, apply the break, run pnpm typecheck, then revert:
Break A — barrel re-export:
In
packages/, remove thebackend- bridge/ src/ index. ts LocalDirEntryline from theexport type { … }block.Run
pnpm typecheck— expect the TS2724 error above ondirectory-tree-picker.tsx:3.Revert the line.
Break B — string[] into CodeMirror keymap:
In
tauri-line 118, changeapp/ renderer/ hooks/ use- codemirror- editor. ts appSettings?.shortcuts?.inlineAiCommandTrigger?.[0] ?? "Mod-j"toappSettings?.shortcuts?.inlineAiCommandTrigger ?? "Mod-j"(remove?.[0]).Run
pnpm typecheck— expect the TS2322 error above onuse-codemirror-editor.ts.Revert the change.
App Lifecycle Tests
Beyond unit tests and component tests, zudo-text has a 3-phase integration testing strategy that validates the full application stack. Each phase targets a different layer, and together they catch issues that unit tests cannot.
Phase 1: REST API Smoke Test
Tests backend API correctness by exercising the REST endpoints directly.
What it tests: Settings retrieval, message CRUD operations, draft management, draft content handling.
What it catches: API regressions, data corruption, endpoint failures, serialization bugs.
# Requires pnpm tauri:dev running in another terminal
pnpm test:rest-smokeThe script sends HTTP requests against localhost:3001/api/* and validates response status codes, JSON structure, and basic data integrity.
Phase 2: macOS Launch Test
Tests the full app start/stop lifecycle on macOS — from open to quit to process cleanup.
What it tests: App launch, REST API readiness, graceful shutdown, orphaned process detection.
What it catches: Startup failures, orphaned processes, shutdown cleanup regressions, port binding issues.
# macOS only — requires a built app bundle
pnpm test:launch-macosThe test flow:
Opens the app via
open /Applications/ zudotext. app Polls
localhost:until it responds (with timeout)3001/ api/ settings Runs the Phase 1 smoke tests against the live app
Quits the app via AppleScript
Verifies no orphaned processes remain and the port is freed
Set ZUDOTEXT_APP_PATH to test a different app bundle location.
Phase 3: Mock Headless Browser Test
Tests frontend page navigation and rendering using a headless browser with the mock backend.
What it tests: Page loading, navigation between views, console errors, component rendering.
What it catches: Frontend crashes, import errors, rendering failures, broken routes.
pnpm test:mock-headlessThis phase starts the mock dev server (pnpm dev:mock) and uses Playwright to navigate through the app's pages, checking for JavaScript errors and verifying that key elements render correctly.
How the Adapter Pattern Enables Testing
The BackendAPI adapter pattern is what makes this 3-phase strategy possible. Each phase uses a different adapter:
Phase 1 (REST Smoke) — Uses the REST adapter's HTTP endpoints. The Tauri app exposes the same backend logic over HTTP, so tests can exercise real Rust code without a GUI.
Phase 2 (macOS Launch) — Tests the real Tauri app lifecycle while probing its development REST endpoint. This validates native IPC availability, Rust backend startup, filesystem fallback behavior, and process cleanup.
Phase 3 (Mock Headless) — Uses the Mock adapter's in-memory backend. No Rust process needed — the frontend runs entirely in the browser with simulated data, isolating frontend-only issues.