Testing papyrus #
The client is tested with bun test (Bun's native runner) + happy-dom +
@testing-library/react. Chosen over Vitest because the Nix checks sandbox is
bun-only (bun2nix), so bun test is the only runner that actually runs — and
therefore is enforced — inside nix flake check.
Division of responsibility (important) #
There are two kinds of tests, with different owners:
| Kind | Location | Owner | Purpose |
|---|---|---|---|
| Unit | client/src/**/*.test.ts(x) (colocated) |
the coding agent / LLM | logic it wrote — pure functions, hooks, reducers, small units |
| Behavioral | client/tests/behavior/**/*.test.tsx |
the developer | user-facing behavior invariants (render a component, drive it, assert) |
Why the split: the LLM writes the implementation, so unit tests are part of
that job — it adds/updates them freely. Behavioral tests encode what the product
must do — invariants the developer cares about (e.g. "the rename pane closes when
you switch tabs"). Those are specified by the developer. The LLM must not add
or modify behavioral tests on its own; it may suggest one when building a
feature, but the developer decides and owns them. The colocated-vs-tests/behavior/
location is the visible signal of who owns a file.
Running #
# from experimental/papyrus/client (needs bun — use `nix develop` at the repo root)
bun test # runs unit + behavioral tests
bun test tests/behavior # just the behavioral suite
nix flake check runs the whole suite via the client-tests derivation, so a red
test fails the build. The shippable build (tsc && vite build) never sees test
files — tsconfig.json excludes src/**/*.test.* and tests/ is outside its
include.
Harness notes / gotchas #
- Setup lives in
client/tests/setup.ts(preloaded viaclient/bunfig.toml): registers happy-dom, extendsexpectwith jest-dom matchers,cleanup()after each test, and mocks framer-motion to a synchronous passthrough soAnimatePresenceexit animations don't defer DOM removal (present/absent assertions stay deterministic). - Render components in isolation, not
App.Appruns the ~1s reconcile poll and mounts xterm + React Flow, which fight a headless DOM. TestSidebaralone. - Mock the
Terminalmodule when importingSidebar(mock.module(".../Terminal", …)before a dynamicimportof Sidebar) so you don't pull@xterm/xtermand its CSS import. Using disconnected tabs also keepsTerminalfrom rendering. - Seed state via the store:
useStore.setState({ sidebarOpen: true, selectedNodeId, sessions }). The store is a singleton — re-seed at the start of each test.
See client/tests/behavior/session-rename-pane.test.tsx for a worked example.
Server tests (server-tests/) #
A separate server-side e2e suite lives in server-tests/ at the package root. It drives
papyrus's real HTTP routes in-process (imports the exported apiRoutes and calls
apiRoutes.request(...), Hono's test client — no port bound) against a real silverwood
binary and a per-test temp forest. Because papyrus is stateless and delegates all
durable state to silverwood, each test asserts ground truth by re-reading silverwood (via the
wrapper or a raw cli() helper), never server memory. This is the regression guard for the
server↔silverwood boundary: a silverwood CLI-contract change (e.g. moving a subcommand) turns
these red, where the client suite stays green.
server-tests/silverwood-contract.test.ts— the wrapper (services/silverwood.ts) argv contract: create/get/list/rename/archive, kv, and the full session lifecycle. Native-free.server-tests/api.e2e.test.ts— the canvas CUJs through the routes: create / edit / delete a node, session-tab metadata, session creation/reconnect launch argv, and errors.server-tests/helpers/—forest.ts(temp forest +cli()runner, the TS analog of silverwood'stests/common/mod.rs) andapp.ts(stubs the nativebun-pty— pulled in transitively viasessionManager— before loading the routes, the same trick the client behavioral test uses forTerminal; no CUJ here spawns a PTY).
Running (needs bun and a silverwood binary):
# from experimental/papyrus; SILVERWOOD_BIN points at a built silverwood
SILVERWOOD_BIN=../silverwood/target/debug/silverwood OPENUI_QUIET=1 bun test server-tests
# or: bun run test:server (with SILVERWOOD_BIN set / silverwood on PATH)
bun test server-tests scopes to the server-tests/ dir; do not run bare bun test at
the package root (the "tests" substring would also match client/tests/, whose happy-dom
preload only loads under client/).
Scope / constraints. Basic workstreams use --checkout-extent skip; session launch
journeys use local-blank with mocked PTYs, so no agent runs. The suite is network-free
and runs in the nix flake check sandbox via the
server-tests derivation (flake.nix), which sets SILVERWOOD_BIN to the flake's wrapped
silverwood. The full create→checkout→live-terminal journey needs network + jj and is out of
scope here, matching silverwood's own #[ignore]d e2e split.
Ownership. These are developer-owned CUJ invariants (like behavioral tests): the coding agent may add/update them when it changes the server↔silverwood contract, but the developer owns the set and reviews changes.