Testing reference
The commands to run each suite are in CONTRIBUTING.md. This document is the reference behind them: the integration-test backends and profiles, the console sentinel, the browser-suite plumbing, where a test goes, where shared test material lives, and the coverage and mutation-testing rationale.
Integration tests
Must pass before a PR merges to main or staging.
CLI. The suite stands up an SFTP server and drives the real SFTP adapter against it over a loopback socket. It is self-managing -- a vitest globalSetup on the integration project starts the server before the suite and stops it after -- so no manual steps are required:
npm run test:integration -w apps/cliBy default the server runs in-process (an ssh2.Server on an ephemeral loopback port serving a temporary directory), so each run -- and each worktree -- is isolated with no shared port or state. Set ALCOVE_SFTP_BACKEND=native to run the same suite against a native OpenSSH sshd spawned as an unprivileged child, exercising the adapter against a real server:
ALCOVE_SFTP_BACKEND=native npm run test:integration -w apps/cliThe native backend runs hardened configurations real deployments use, selected by ALCOVE_SFTP_NATIVE_PROFILE (default baseline, the plain forced internal-sftp config); the same conformance suite runs against each:
ALCOVE_SFTP_BACKEND=native ALCOVE_SFTP_NATIVE_PROFILE=restricted-crypto npm run test:integration -w apps/clirestricted-crypto-- a locked-down kex/cipher/MAC/host-key/pubkey policy, plus a test that a client offering only a key exchange the policy excludes (one OpenSSH allows by default) is refused.rate-limited-- connection and auth rate limits. The suite running under them is the coverage; there is no exceed-the-limit test (it would be CI-flaky).allowlist-- an explicituser@hostallow matrix, plus a test asserting a valid key under a username other than the served user is rejected.chroot--ChrootDirectoryconfinement, plus a test that a path outside the served root is unreachable from a chrooted session. It needssshdrunning as root over a root-owned jail, so it runs only on Linux as root and is launched through a dedicated script that skips cleanly (exit 0, with a message) everywhere else:
# Linux only; skips cleanly elsewhere. The PATH forwarding lets npm and node
# resolve under sudo when secure_path would otherwise drop them (the CI leg uses
# the same form); a plain `sudo npm run ...` works where sudo keeps your PATH.
sudo --preserve-env=PATH env "PATH=$PATH" npm run test:integration:native-chroot -w apps/cliThe harness itself is apps/cli/test/sftpServer/: index.ts selects the backend from the environment, inProcessServer.ts and nativeSshdServer.ts implement the two, globalSetup.ts starts and stops the selected one, and testContext.ts hands each test its connection details, per-party credentials, and rendezvous paths. The in-process backend adds two surfaces a real sshd cannot offer:
- Fault injection -- a malformed NAME or DATA packet, a request accepted and never answered, transient RENAME failures or every RENAME refused, a write torn at its close (the file kept, removed, or truncated), capped READDIR batches, a run of READDIR batches holding neither an entry nor end-of-directory (the progress-free flood the listing's round-trip cap exists for, and the only shape that reaches it), and a NAME reply of a chosen width (the hook the pinned stack's reply wall is measured through; see docs/spec/DEPENDENCY_PINS.md).
sessionControls.ts, the session-lifecycle hub -- lifetime, op-count, and idle caps that drop a live session, one-shot drops armed on the Nth further operation or on a wall clock, a withheld-close mode that consumes the client's FIN and sends nothing back, leaving it in half-close, and a handshake stall that accepts the TCP connection and writes nothing past the identification string ssh2 has already sent by then, so the key exchange never comes and a dial hangs established but never ready. The stalled connections are counted, which is how a case knows the stall has reached a dial rather than acting on a handshake that then completes, and they can be closed from the server's side, which is the only way to end a parked dial with a peer close rather than with a teardown the client drove. A live session can also be made to VANISH: from that moment the server neither answers nor closes it, so the client sees noend, noclose, and no further byte, and can learn of it only from its own liveness deadline. Unlike the withheld close it fires against a session that has asked for nothing, and it composes with the caps, so a server can both cap sessions and never close one cleanly. Every vanished session is released at teardown -- a socket silenced on both halves cannot answer theend()the backend'sstop()uses to close it -- and any release that reaches one hands back both halves at once, so no case can measure over a session that is half itself again.- Rename-tear staging, on the same hub. The op-count drops arm a teardown as a request is counted and defer it, so whether that request's filesystem work ran before the connection went is a race; these cut at a named point inside the RENAME handler instead -- before any filesystem work, or from inside the
fs.renamecallback with the write durably in place and the reply never written. The landed destination can be removed at the tear (standing in for a partner that consumed it), and a STAT/LSTAT of it can be held until it has been REMOVEd, which orders a real partner's consume-delete strictly before the sender's landed-confirmation probe of the same path. - Request accounting, on the same hub. A meter counts requests as they arrive and replies as they are written, across every session being served at once, so a suite driving a concurrent fan reads how deep the client let its request pipeline run on the one channel from the end this project owns rather than from the client library's internals. A reading holds per-opcode counts and the peak simultaneously-unanswered depth. A reply written straight onto the channel by a fault injection, and a withheld reply, are never counted as answers, so each leaves its request outstanding for the rest of the window.
Adapter behavior against a partner that cuts, stalls, or never closes is asserted through those controls. A probe that determines a dependency fact belongs in that tree too: questions about ssh2 / ssh2-sftp-client behavior are decided by measurement rather than by reading the library, and the probe that decided one graduates into the suite with the PR relying on the answer, written as a test driven by this harness -- not left in scratch/. The assumption is critical once a change is built on it, and an assumption no suite re-checks goes stale silently at the next pinned bump of either package.
What the real-server legs cannot reach
The controls above take the SFTP adapter a long way, but not everywhere. Four classes of its behaviour are held by the unit suite's hand-modelled ssh2-sftp-client alone, and stay there. Each is a limit of what a server can be made to do, not a gap waiting for a case, so a plan that decomposes the adapter has to keep the model for them rather than promise to retire it:
- The API-drift feature detects. The adapter reaches through
ssh2-sftp-clientto thessh2client, its socket, and the raw SFTP session (client._sock.destroy(),client.on()/once()/removeListener(),client.end(),setNoDelay/setKeepAlive, andsftp.open/close/opendir/readdir), and guards each reach so a version that relocated one degrades with a named warning instead of throwing. What those guards test is this process's own dependency tree, not anything on the wire, so no server, fault injection or hardened profile can make one true while the pinned versions expose everything they check. They exist to fire at the next bump, which is exactly the "does the API still look like this" question that belongs to a model rather than to a measurement. createExclusive's SFTPv4+FILE_ALREADY_EXISTS(status 11) arm. Driven against both backends, an exclusive-create collision reports SFTPv3's ambiguousSSH_FX_FAILURE(status 4) and nothing else -- the in-processssh2.Serverdouble and OpenSSH'sinternal-sftpagree -- which the adapter resolves through its own existence check. Reaching the status-11 normalization means fabricating a status no server here sends, so it would take a new fault injection rather than a new case.put/putOnce's one-shotReadableStreamsource. Nothing server-side distinguishes it from theBufferpath, and no call site in this app hands one: everyFileSyncConnectionput passes aBufferor a chunk list. A real-server case would have to call the adapter directly with a stream to reach it, which is what the unit suite already does.- The inter-attempt dead-session window.
renameOnce's guarded outcome is driven at method entry against a real server socket whose SFTP channel has been destroyed (sftpConnection.test.ts's "a fatal 'error' on the raw SFTP wrapper does not crash and fails terminally"). A fatal session error landing in the short window between two attempts of a retried operation is a race no server here can be held to, so that window stays the model's ground alone --ssh2SftpAdapter.test.ts's "stops retrying when a fatal session error lands between attempts".
The CLI's WebRTC transport is a project of its own, webrtc, holding the files under apps/cli/test/integration/webrtc/:
npm run test:integration:webrtc -w apps/cliIt is separate because it is transport-agnostic to everything the SFTP suite sets up -- two loopback werift peers meeting through the vendored broker, with no SFTP server involved -- while test:integration is run repeatedly per pull request, once per SFTP backend and once per hardened-sshd profile. As its own project it runs exactly once (see .github/workflows/cli_build_and_test.yaml, and the ship-gate step in release.yaml), and a new file joins it by being written in that directory rather than by matching a naming convention. Its project skips the SFTP globalSetup entirely, so no server is started for it.
broker.test.ts holds one check that is slow by design -- that a registered CLI peer is still registered and routable after the broker's silent-socket reap window has passed, which takes a real 25-second wait. It is kept rather than trimmed: it is the only place the CLI client's own heartbeat cadence is joined to the vendored broker's reap window over the real wire, and the two constants live in different workspaces, so no unit check on either side can stand in for it. It runs in its own worker process alongside the longer transport file.
Its broker harness is apps/cli/test/signaling/. It starts the repository's own vendored PeerJS broker as a child PROCESS rather than importing it, because a deployed broker is a service of its own and the CLI's client is measured against what that service does on the wire; the process entry point it spawns is packages/peerjs-broker/src/standalone.ts, which is why .github/workflows/cli_build_and_test.yaml has that workspace in its path scope as well as apps/cli. Each file starts a broker of its own. The same measurement discipline applies here as to the SFTP tree: what the CLI's hand-written signaling client and PeerJS framing rest on was established by driving the real broker and the real peerjs/peerjs-js-binarypack packages, and the assumptions that follow are held as checks -- see docs/spec/DEPENDENCY_PINS.md.
The peers' ICE server list names a second loopback harness server, apps/cli/test/integration/webrtc/refusingStunServer.ts: a STUN server on an ephemeral port that answers every binding request with an error. werift waits out a fixed 5 s gathering timer on a STUN entry nobody answers, once per peer connection, and a refusal ends the query at once with the same host-only candidates.
The harness takes further runner arguments and environment entries, which is what brokerStandaloneSurface.test.ts beside it uses to drive the runner's operator surface -- the address and port it binds, the readiness probe a deployment reads, and the refusal an option it cannot act on produces. What each setting resolves to from an argument vector and an environment is checked without a process in apps/web/test/unit/signalingStandaloneOptions.test.ts, where the broker's other unit coverage sits: the package ships no test project of its own.
One assumption the live suite cannot hold, and where it lives instead: werift inlines its ICE candidates in the SDP, so two peers connect on loopback even when every trickled candidate is discarded. The candidate-queue rule the transport exists to honour is therefore invisible end to end and would fail only in the field. It is pinned in apps/cli/test/unit/connection/webrtcNegotiation.test.ts, which drives the negotiation against a scripted broker and peer connection and asserts the ORDER of what goes on the wire.
The one-command acceptance leg
oneCommandAcceptance.test.ts drives whole COMMANDS rather than the transport beneath them: an inviting alcove invite creates a webrtc invitation and waits, and an accepting alcove accept INVITATION INPUT_FILE OUTPUT_FOLDER resolves its positionals, renders the consent surface, takes its confirmation from stdin, resolves the connection from the invitation's own endpoint, dials, and runs the exchange, with the linkage result asserted on both sides. Both parties are child processes (apps/cli/test/cliProcess.ts), so each run has its own argv, stdin, stdout, and exit code; an acceptance driven through the exported handler would have to stub the confirmation prompt, which is the checkpoint the leg exists to cover. It costs about fifteen seconds, most of it ICE gathering falling back to host candidates.
The leg needs a wss:// coordination server. An invitation's connection endpoint is a credential-free locator with no scheme, so an acceptance seeded from one resolves the coordination server over TLS, and the one-command form has no configuration file for an operator to set secure: false on first. So the leg puts a TLS terminator (apps/cli/test/signaling/tlsBrokerFront.ts) in front of the same vendored broker every other file here spawns, pipes each stream through unread, and starts each party with NODE_EXTRA_CA_CERTS pointed at the throwaway certificate -- trusting that one certificate in that one process rather than disabling verification anywhere. Terminating in front of the broker rather than inside it keeps the spawned entry point, and every byte the broker sees, the same as the rest of the project's.
A broker or environment failure stays distinguishable from an exchange failure. A precondition test ahead of the leg fetches the broker's own 404 through the front, with the throwaway certificate as its only trusted authority, so a broker that did not start, a front that is not listening, or a certificate this environment could not create fails as itself; past it, a failing leg belongs to the command path. Each party also has a hard deadline of its own, so a stalled run is reported with its exit status and the tail of its diagnostics instead of running until the framework kills the worker under it.
The backend-agnostic project
The integration files no SFTP backend differentiates are a third project, backend-agnostic, holding the files under apps/cli/test/integration/backendAgnostic/:
npm run test:integration:backend-agnostic -w apps/cliEach drives a filedrop directory, a listener it stands up itself, or nothing on a socket at all, so every backend and hardened profile would reach the same result for it -- and test:integration is run six times per pull request. Like the WebRTC project it starts no SFTP server, runs exactly once (see .github/workflows/cli_build_and_test.yaml, and the ship-gate step in release.yaml), and a new file joins it by being written in that directory.
The bar for writing a file there is that no leg differentiates what it EXERCISES, not merely what it asserts. A file that reaches the suite's server at all -- including incidentally, through a fixture, a control case, or a teardown -- belongs in integration, because moving it takes that exercise off the native and hardened legs entirely. Where only part of a file clears that bar the file is split rather than moved whole: dialPeerIdentification.test.ts keeps the dial paths that run over the server, while its two host-key probe entry points, which dial a peer of their own, are backendAgnostic/hostKeyProbePeerIdentification.test.ts, with the assertions both halves share in apps/cli/test/integration/peerIdentification.ts.
A move trades a recurring per-leg cost for a one-off one, so measure it first: a case costing milliseconds saves milliseconds five times over and still leaves a split file to keep in step. Several leg-agnostic cases are left in integration on that measurement.
Gating a case to the in-process backend
A case in integration that only the in-process server can be driven to -- a withheld close, a vanished session, a key exchange narrowed mid-run, a listener ceiling -- declares itself with the shared inProcessOnly gate (apps/cli/test/sftpBackendGate.ts) instead of test, and the file states above it what the native backend cannot do. The gate reads ALCOVE_SFTP_BACKEND once at module load, so every case in a file sees the same answer. Reach for it only where a real sshd exposes no control for the state under test: a behavior both backends show belongs in the backend-agnostic project above.
A standing console sentinel guards all three CLI integration projects: it wraps console directly and fails a test file at afterAll on any console.log/warn/error that no allowlist matcher accepts (the inverse of blanket silencing, and the one check that sees third-party console.* which the loglevel-based withCapturedLogs cannot). If your change makes the suite emit new console output, the fix is to eliminate it at the source -- route it through the logger or assert it under withCapturedLogs; accept it as intended only by adding a matcher to the allowlist in apps/cli/test/integration/consoleAllowlist.ts, a visible edit a reviewer sees. A matcher that never fires across a run is reported at teardown so the allowlist cannot accumulate dead entries. That report is advisory (a warning, never a failure) and is produced by the integration project's globalSetup, so it sees only that project's files: a matcher fired solely by a webrtc or backend-agnostic file displays as dead there.
Web (dev server managed automatically -- same pattern as the CLI integration tests):
npm run test:integration -w apps/web # auto-starts, waits for, and stops the dev serverSome of these specs drive the built console server at apps/web/dist/console-server/main.mjs with the client it serves from apps/web/dist/console/. Others serve the hosted static site in apps/web/dist/hosted/ through the static-host harness (below). Run VITE_SIGNALING_SERVER_URL=ws://127.0.0.1/api/ npm run build -w apps/web, npm run build:console -w apps/web and npm run build:console-server -w apps/web first. Without those builds the project fails at setup, naming the missing paths and the build commands, rather than reporting a pass with those specs quietly skipped.
Static-host harness
apps/web/test/staticHost/ is a small Node server standing in for Cloudflare Pages, which serves the hosted static site. It runs in the test process, on a free loopback port, and serves only the subset of Pages behavior the site relies on; the list, and what of it was measured on Pages, is in docs/notes/hosted-static-build.md. It refuses to start on an output holding _redirects or 404.html, and on _headers syntax outside that subset. appShellWarm, offlineUnvisitedRoute, csvWorkerProd, securityHeaders, and the hosted half of apiNamespace run against it.
For a dev-server-only run, set ALCOVE_ALLOW_MISSING_WEB_BUILD=1: the built-server specs report as skipped and the run passes. That is the one opt-out, and it is the only way an absent build is not an error.
ALCOVE_ALLOW_MISSING_WEB_BUILD=1 npm run test:integration -w apps/webThe dev-server-backed spec (securityHeaders) runs either way. Neither root npm run test nor test:browser runs this project, so neither is affected by the build requirement; test:integration:browser does run it, and so needs the build.
For a faster inner loop you can keep a warm server running across many runs: test:integration detects an already-running server, reuses it, and leaves it up rather than stopping it.
npm run dev -w apps/web # start (and keep) the dev server in a terminal
npm run test:integration -w apps/web # reuses the running server
# stop the dev server in the terminal when done (Ctrl-C)Browser suite
The browser suite -- cross-implementation byte-vector checks, a live PSI exchange, and React component tests such as the accept consent gate, run in real Chromium via Playwright -- self-manages the dev server the same way: it reuses a running npm run dev, and otherwise starts and stops its own.
The web app mounts no signaling broker, so both projects' setup (apps/web/test/devServer/globalSetup.ts) first starts the standalone one (packages/peerjs-broker) on an ephemeral loopback port, through the spawner the live WebRTC leg also uses (apps/web/test/utils/standaloneBroker.ts). The suites that open a real PeerJS connection dial it directly, at the port the setup provides. A dev server the setup starts is handed that port too and forwards its /api/ to the broker, as npm run dev from the repository root does (scripts/dev.mjs); the setup counts that server ready only once a signaling dial through it opens. A reused server is not probed for signaling, since it forwards to whichever broker started with it. A dev server the setup starts that exits before answering, a taken port included, fails the run at once with its last lines of output.
The browser project's second setup (apps/web/test/devServer/websocketRelay.ts) runs the WebSocket relay (packages/websocket-relay) in the test runner's process on a loopback port, for the relay fallback suite. It serves wss under the loopback TLS certificate (apps/web/test/devServer/relayTls.ts), and the project launches Chromium with --ignore-certificate-errors-spki-list naming that certificate's key, so the page accepts it and no other; without the switch Chromium refuses the socket (ERR_CERT_AUTHORITY_INVALID). The suite lists the relay from this browser's own relay setting, as a run does, dials it through the production client, enrolls each exchange's key through a test route beside it, and reads back what the relay forwarded through another.
npm run test:browser -w apps/web # auto-starts, waits for, and stops the dev serverIt runs in CI as part of the web build-and-test gate (web_build_and_test.yaml), which provisions Chromium on the runner; run it locally too when changing the web PSI exchange, the cross-implementation vectors, or a web UI component it covers (such as the accept consent gate).
A change to the web optimizeDeps or a worker dependency is verified only against a cold optimizer cache: remove apps/web/node_modules/.vite first -- a warm cache passes even when the configuration is wrong. Inline vitest projects do not inherit the root optimizeDeps/resolve configuration; each project that needs it has its own. A vitest run of the browser project in which Vite optimizes a dependency its startup scan missed, and so reloads the test page, fails with an error naming that cause: add the dependency Vite logs to that project's optimizeDeps.include.
Locators and text assertions
The browser projects set browser.locators.exact: false, so a name given to getByRole, getByText, and their siblings matches a substring of the element's rendered accessible name. A locator that has to match the whole name passes { exact: true } at its own call site.
Text content is asserted with toMatchTextContent, which matches a substring or a regular expression. toHaveTextContent beside it matches the element's whole text content exactly, so an assertion naming one sentence of a panel takes the first.
Console output a failure path logs
The app logs a caught run or exchange failure to the console in a development build, so a test that injects one also produces the line. The test declares it with expectConsole(level, ...lines) from apps/web/test/browser/expectedConsole.ts: a declared line is kept off the run output, and the test fails if no such line was logged, so a failure path that stops logging or changes its text is reported. A line no test declared prints as usual -- a console line in a browser run is one nobody expected.
Accessibility checks
Three kinds of browser test hold the web app's accessibility, each covering what the others cannot:
- The contrast sweep (
contrastSweep.ts, run bythemeContrastSweep.test.tson the hosted profile andthemeContrastSweepConsole.test.tson the console's) measures every rendered text element against the WCAG AA floor, in both color schemes. It runs on the same route states as the rule scan (routeStates.ts), and the test fails when a declared route has none. A light-scheme failure not yet fixed is listed incontrastSweep.ts, and the test fails when a listed one stops failing. A sweep sees each state as it mounts, so the verify screen's states after a file loads are swept byverifyReceiptContrast.test.ts, which loads them. - The rule scan (
accessibilityRules.ts, run byaccessibilityRuleScan.test.ts) checks a fixed set of DOM-decidable rules -- unique ids, resolving label and description references, named controls and images, heading order, list structure, a page h1. It runs on a state of every route the router declares, and the test fails when a route has none. It is in-house rather than axe-core, so a clean scan is not a WCAG conformance claim; the file header lists the rules. - The keyboard-only journeys (
keyboardInviteJourney.test.ts,keyboardAcceptJourney.test.ts,keyboardVerifyJourney.test.ts) drive the invite, accept, verify, and recurring setup flows by Tab, Enter, and Space alone throughkeyboardOnly.ts, assert where focus lands on each step change, and run the rule scan on each step and on the result and failure states. A native file chooser cannot be driven, so a journey tabs to the file control and hands the file to its input.
Cross-runtime interop suite
The CLI and the web app share no code but @alcove/core, and their own suites never meet: each drives both sides of an exchange with itself. The interop project is the one place a real alcove process and a party assembled from the web app's own exchange modules complete a live exchange together, in both invitation directions, over one file-drop directory.
npm run build -w apps/cli # the party on the CLI side is the built program
npm run test:interop -w apps/webIt lives in apps/web/test/interop/ because only that workspace may import apps/web/src; the CLI side is a spawned child process, which is how the console already drives it. The suite fails, naming the build command, when apps/cli/dist/ is absent, rather than skipping and reporting a pass with no tests run.
Not every file in the project is an exchange. consoleExportParity.test.ts spawns the built alcove only to WRITE what it compares against -- a configuration alcove invite or alcove accept wrote, opened in the console, run and handed back -- and it sits here for the reason the exchanges do: the comparison is against the real program's own bytes, and a unit test neither builds the CLI nor spawns it. What the console's hand-off composes without the CLI stays in the unit project.
One piece of the web party is the harness's rather than the app's, because the app has no counterpart it could use: the file-drop directory client. FileSyncConnection lives in core but its filesystem client (LocalFSClient) lives in apps/cli, which apps/web may not import. The harness supplies one scoped to what the file-sync protocol calls, not a second copy of the shipped client's hardening.
Everything else is each runtime's own: the CLI's argv, configuration, key file, PSI backend and result CSV; the web app's handshake and application-layer AEAD wrap, its acceptor assembly, its invitation creation and inviter spec assembly, and its browser WASM PSI engine.
cliWebRelayFallback.test.ts meets the CLI party over the WebSocket relay instead, with WebRTC blocked. The CLI waits at the standalone broker and parks at a wss relay the test runs on loopback; the web party lists that relay from its own relay setting and races it through the app's fallback. Its WebRTC open is the harness's: Node cannot stand up a browser peer connection, so it reports the path failed at once, and the CLI abandons WebRTC on the web party's start and follows it onto the relay. The CLI trusts the relay's loopback certificate through NODE_EXTRA_CA_CERTS, and the web party through Node's default CA list (tls.setDefaultCACertificates) for the file's duration. A run between a CLI party and a browser party with WebRTC open, whose relay sockets close unused, needs a browser peer, so it runs on the live WebRTC leg below; the browser suite covers that case between two browser parties.
It runs on both cli_build_and_test.yaml and web_build_and_test.yaml. That is by design rather than redundant: the drift it exists to catch can land on either runtime, and each workflow's path scope sees only its own.
Live WebRTC leg
The interop suite above meets a CLI party over a file-drop directory, which is the only transport a Node host can stand up on its own. The live-webrtc project is where the two implementations meet over the transport they actually share: a real alcove process and a real browser peer, in Chromium, complete one WebRTC PSI exchange through the standalone signaling broker.
npm run build -w apps/cli # the party on the CLI side is the built program
npm run build:console -w apps/web && npm run build:console-server -w apps/web
npm run test:live-webrtc -w apps/webUnlike the interop suite, an absent apps/cli/dist/ or console build fails this leg rather than skipping it. The leg is on no script but its own, so a run that asked for it and got a skip would report a pass over the one thing it was asked to cover.
It lives in apps/web/test/liveWebrtc/, beside the interop suite and for the same reason: only that workspace may import apps/web/src, and the CLI side is a spawned child process. The broker and that process are started by the vitest browser commands in test/liveWebrtc/legCommands.ts -- the channel a browser test has for work it cannot do itself -- so the test body asserts both parties' association tables rather than leaving one to a teardown hook.
The CLI holds the inviter role, which is what puts the broker on an origin of its own rather than the page's: the invitation a CLI party creates from a ws:// coordination-server URL names that broker's host, port, and mount, and the browser peer dials what the invitation names. A browser inviter would name its own page's origin instead.
consoleExchange.test.ts puts the console in the CLI's place: the built console server, with the broker authored through PUT /api/jobs/webrtc, runs a webrtc job whose CLI child meets the browser peer, once in each seat. Where the browser invites, the app's listen registers at its build's own signaling address, which on the test page is the page's origin, so the harness hands the PeerJS constructor the broker's location instead; that is the one piece of the seat that is not the app's.
relayUnused.test.ts runs the WebSocket relay fallback with WebRTC open. The leg's Node side runs the relay on loopback over wss, under the certificate the browser project's relay uses (apps/web/test/devServer/relayTls.ts), which the project's Chromium launch trusts and the CLI trusts through NODE_EXTRA_CA_CERTS. The CLI party is an alcove exchange inviter whose configuration names the broker and the relay, so its invitation names both; the browser party lists the relay from that invitation and races it against WebRTC through the app's fallback. WebRTC opens, and the case asserts that the exchange ran over it, that neither side sent its start, that the relay checked both hellos and forwarded nothing, and that every relay socket closed before the CLI party began closing its WebRTC connection. The relay closes a party's socket when its partner's closes, so the browser party's socket factory holds back each close its race calls until the CLI party's sockets have closed at the relay. The case then asserts that the CLI party's sockets closed there before the browser party's, so the CLI party's own race closed them, and that the browser party's race closed with 1000 every socket the relay had not already closed. The leg tells the two parties' sockets apart at the relay by the Origin header a browser sends and the CLI's client does not.
A cross-origin broker is what lets the leg answer whether that broker needs a CORS header for the browser peer. It does not: PeerJS asks the broker for an id over HTTP only when constructed without one, and Alcove always supplies the id derived from the invitation secret, so nothing but the signaling WebSocket crosses, and CORS does not govern a WebSocket. The leg holds that as a check rather than as a note here.
One piece of the browser party is the harness's. src/psi/transport/rendezvous loads its config at module scope through a read of process, which the browser runner does not have, so the import throws there; the rest of the browser suite stubs the whole rendezvous module for that reason (apps/web/test/browser/moduleMocks.ts), and this leg stubs the CONFIG one level below instead, at the schema's own defaults, so the dial it drives is the app's own. Everything else is each runtime's own.
It runs nightly, from .github/workflows/nightly_live_webrtc.yaml, and is off the pull-request gate. It pays for a CLI build, a browser build and minutes of real ICE, DTLS, SCTP, and WASM work to exchange six records, while what it guards -- a divergence between the two implementations on the wire -- moves with the two transports rather than with the pull requests that land. The standing per-PR guarantees stay the interop suite above and the cross-implementation vectors in the browser project; this leg complements them and replaces neither.
A leg that only ran nightly could decay into a flake nobody reads, so its two failure modes are kept apart in the output. Anything about the ENVIRONMENT -- an unbuilt CLI, a broker that would not start or answer its readiness endpoint, an invitation the CLI never printed, a party killed on its deadline -- is raised under the live-webrtc leg environment failure: prefix from the leg's Node side (test/liveWebrtc/legTypes.ts), and the leg's first test is an environment precondition that fails before the exchange is blamed. An interop divergence is a failed assertion on an association table, on the identity read off the agreed terms, or on the close outcome. A browser that will not launch fails the vitest session before any test runs, which is distinct from both.
The leg also measures what each side's clean close waits, printing both numbers on one [live-webrtc] line. It asserts the browser party's exit against the ordering the run took, which it reads off the connection the moment before that party closes: where the browser party closes first the CLI party's close has to end the wait, and where the CLI party closes first that close has to be already read, leaving no wait to take. Either way it holds the browser party's wait under a ceiling set between those endings and the one they replace, which costs 15 s or more. The durations themselves stay a tracked limit recorded in docs/spec/WEBRTC_TRANSPORT.md, read across runs before anything tighter gates on them.
Core stress tier
npm run test:stress -w packages/core runs packages/core/test/stress/, the large-input cases kept out of npm run test and run on a schedule by .github/workflows/nightly_core_stress.yaml, which gives each file a job and a hosted runner of its own, except six short files that share one. The four engine files that take from 14 minutes to about three hours on a hosted runner (psiRoundWalls, streamedMatchLarge, streamedMatchLargeNative, wasmMaskingGrowth) run weekly, on Sunday, and every other file runs nightly; a manual dispatch runs either set or both, on the branch it is dispatched on. Stress files run one at a time, and a case that needs more memory than the machine has skips with a message naming the case, its need, and the memory it found: the free memory, or on macOS, whose free figure leaves out the cache it can reclaim, the available memory memory_pressure reports: free, inactive, and speculative pages.
Dispatch the weekly or all tier only to confirm a change to the PSI engine path or to one of those three files ahead of the Sunday run: each of their jobs holds a hosted runner for up to five hours, and a measurement that repeats one file multiplies that. Any other change is confirmed with the nightly tier.
npm run test:stress -w apps/cli runs apps/cli/test/stress/, exchanges between processes of the built CLI (npm run build -w apps/cli first) at sizes only a large host holds. Nothing schedules it: a host with the memory runs it by hand. Its files skip the same way, and also without a built CLI; the interrupt run, which sends Ctrl-C during masking through a terminal of its own, also skips without util-linux script. Its file-sync and WebRTC completion runs can also drive one party on each of two hosts, when no one host holds the pair: ALCOVE_STRESS_COMPLETION_PARTY names the party this host runs, and each test file's header lists the other variables its mode needs (file sync: docs/spec/FILE_SYNC.md, Measured runs at 2^24; WebRTC: the header of webrtcCompletion.stress.test.ts). The relay round (websocketRelayRound.stress.test.ts) needs no built CLI: it sends one round of the largest measured exchange's size between two of the CLI's relay connections through the WebSocket relay's standalone process, and logs the time and the relay's resident set.
Scheduled runs
The nightly_* workflows (nightly_core_stress.yaml, nightly_live_webrtc.yaml, nightly_mutation.yaml, nightly_platform.yaml) check out staging by name, so their scheduled runs test the staging tip. nightly_relay_deployment.yaml tests no code: it checks out main, the branch the relay host pulls, and checks that the host has deployed it (infra/broker/README.md, The deployment check). The weekly dependency audit, CodeQL, and image smoke workflows run on their default ref. GitHub fires a schedule from the workflow file on main, so a change to a scheduled workflow's own definition reaches its scheduled runs only once it is promoted there; a manual dispatch runs the definition on the branch it is dispatched on.
A scheduled workflow red for more than BLOCKING_STREAK scheduled runs in a row (scripts/scheduled-run-streak.mjs) becomes a blocking item on the project board, closed by a fix or by retiring the workflow with its reason stated. The count is of runs, not days: for a weekly workflow it is that many weeks. node scripts/scheduled-run-streak.mjs tallies the current red streak of each scheduled workflow from gh run list, counting scheduled runs only, and marks a streak past the threshold as blocking. A workflow with more than one cron, such as the nightly and Sunday tiers of nightly_core_stress.yaml, gets one streak per tier. The script header states which run endings count as red.
Per-file test durations
The CLI, web, and static-check jobs set ALCOVE_VITEST_JSON_DIR on their test steps, so each vitest run writes vitest's JSON report into that directory (scripts/lib/jsonReportReporter.mjs, registered beside the skipped-leg reporter). .github/actions/test-durations then lists the job's slowest test files in the job summary, as many as DEFAULT_TOP in scripts/slowest-test-files.mjs, and uploads the reports as a test-durations-* artifact kept for the action's retention-days. To rank downloaded reports, or a local run's:
ALCOVE_VITEST_JSON_DIR=/tmp/vitest-json npm run test:unit -w apps/cli
node scripts/slowest-test-files.mjs /tmp/vitest-jsonWhat a file's duration includes is stated in the header of scripts/slowest-test-files.mjs.
What a run did not cover
A run that quietly covers less than the suite does is worse than a red one: its green is read as evidence. Three guards keep the gap visible, each a run-level vitest option -- read once for a run rather than per project, so a project added later is covered without registering anything of its own. The dist guard is in both app configs, the prerequisite guard in apps/web's, and the reporter in every workspace config and in the root vitest.config.mts as well, since reporters belong to the config that starts a run rather than to a project it reaches.
A stale @alcove/core or @alcove/cli-contract dist fails the run
The apps import the built packages, never a package's src, so a run whose dist/ is older than the sources it was built from exercises yesterday's library and reports failures that belong to the build. Before any app suite runs, the dist/ entries each package's package.json publishes are compared against what its build reads -- packages/core/src and rollup.config.ts; packages/cli-contract/src, tsconfig.build.json, and the tsconfig.json it extends, leaving out the shared tsconfig.base.json since it sits outside the package directory -- and a dist that is missing or older fails the run, naming the file that outran it and the rebuild, core first since the cli-contract build reads core's declarations:
npm run build -w packages/core
npm run build -w packages/cli-contractpackages/cli-contract's own suite imports its src but reaches @alcove/core through core's dist, and has no pretest to rebuild it, so it runs the same guard over core's dist alone. packages/core's suite needs none: pretest rebuilds, and its tests import src. A run that selects no app project and not the cli-contract one pays nothing -- npm run test:scripts needs no build and never asks for one.
Set ALCOVE_ALLOW_STALE_DIST=1 to run the app suites and the cli-contract suite against the dists as they stand. The opt-out does not reach the two core tests that assert properties of the built dist itself (builtEntryPointsShareState.test.mjs, builtEntryPointsShareTypes.test.mjs): they fail on a stale or missing dist regardless, since running them would assert a known-stale artifact.
The comparison is over modification times, so it catches the ordinary staleness -- an edited source, or a checkout that rewound one -- and not a dist built from a source since reverted to identical bytes.
A missing environment prerequisite is named, and fails in CI
Some legs need a tool the repository does not ship. The web signaling suites and the CLI's live one-command acceptance need a self-signed loopback certificate, which Node cannot issue, so @alcove/testkit/loopbackTlsCert shells out to openssl and those legs skip where it cannot create one (no openssl, or a LibreSSL one that takes the flags differently).
apps/web/test/requireTestPrerequisites.ts declares each such prerequisite and decides what its absence costs, by where the run happens. On a workstation the run continues, with the missing prerequisite, the coverage it costs, and how to supply it named on the console. Under CI, the run fails instead: a runner is provisioned to a spec, so a prerequisite absent there is a defect in the image or the workflow rather than a property of somebody's laptop. Set ALCOVE_ALLOW_MISSING_TEST_PREREQUISITES=1 to skip those legs.
A suite that needs a new prerequisite adds it to webTestPrerequisites() beside the certificate. The CLI states three of its own the same way but per leg, since the legs that must have them are named individually: ALCOVE_REQUIRE_WORKER_BUILD=1 on the leg that builds the CLI worker bundle, ALCOVE_SFTP_CHROOT_REQUIRED=1 on the chroot profile, and the one-command acceptance leg's own openssl, which it creates its TLS front's certificate with. That leg reads CI and the same ALCOVE_ALLOW_MISSING_TEST_PREREQUISITES opt-out as the web gate, so an operator whose machine has no openssl sets one variable for both.
Every skipped test is named
Vitest counts skips (2 passed | 3 skipped) but names none of them, so a leg that starts skipping displays as green with nothing to say which suite went quiet. The skipped-leg reporter (scripts/lib/skippedLegReporter.mjs) ends every run by listing each skipped test under its project and file, with the reason where a runtime ctx.skip(reason) gave one. test.todo is left out, being a placeholder vitest already reports on its own.
It reports and never fails: which skips are legitimate is a per-suite question -- the SFTP matrix legs skip the backends they are not running, and the whole matrix together is the coverage -- so failing on one belongs with whatever declares the prerequisite, as above.
A platform gate is a skip, not an early return
A test that cannot run on this host declares that with test.skipIf / describe.skipIf, so vitest counts the leg and the reporter above names it. A body that returns before its first assertion instead -- if (process.platform !== "darwin") return; -- still reports PASSED, so a leg that never ran is counted as coverage and there is no skip for the reporter to see.
scripts/platform-gate-skips.test.mjs (run by npm run test:scripts, a CI static check) is what holds that, reading every test module the checkout contains rather than a maintained list. It fails on a gate that returns early when the platform is linux or darwin -- the hosts the suites are actually run on, CI being Ubuntu throughout -- and reports a gate it cannot evaluate rather than passing it over. Its reach and what it leaves standing by design are in its own header.
Where a test goes
Four rules cover how a test file is named, how it is grouped internally, when it splits, and where its helpers live.
Naming
One test file per source module, named for that module: fileSyncConnection.ts tests live in fileSyncConnection.test.ts. A test that is not about one module -- a cross-cutting property, an end-to-end scenario -- is named for the property or scenario, states that scope in a one-line file header, and sits beside the module it exercises rather than in a naming bucket of its own.
A .unit / .integration / .browser suffix is never required to route a file: the directory it sits in already does. packages/core/vitest.config.ts (its unit project, around lines 26-35) and apps/cli/vitest.config.mts route every project by directory alone and define no suffix glob at all. apps/web/vite.config.ts (around lines 195-249) accepts a *.unit.test.ts / *.integration.test.ts / *.browser.test.ts suffix as an alternate match alongside its test/unit/**, test/integration/**, test/browser/** globs, but a file already under one of those directories matches by directory regardless of the suffix, and roughly a sixth of the files under apps/web/test/unit/ carry it while the rest do not. Name a file for its module or scenario, not for its project.
Grouping
Group related tests with vitest's own describe, never with a title prefix written into a string: a title is a describe spelled as a string, so it does not nest, does not collapse in a reporter, and does not survive a rename. A describe title does not repeat the file's own name -- the path already says which module is under test.
Size, and when a file splits
A file crossing roughly 1,500 lines is due for a look, not a stop. Nothing checks the number, so it is a prompt to open the file and ask whether it still tracks one module, not a gate.
The harder rule: a test file splits when its source module splits. Left unsplit, it asserts behavior that lives in several modules and a module's tests become unfindable without grep. Both of this repository's cases have been split along that line: the SFTP adapter's suite is now one file per subject beside ssh2SftpAdapter.ts, sharing one fixture module, and the file-sync connection's rendezvous and retain-mode cases sit with the modules they exercise.
Where helpers live
A helper used by one group of tests moves with that group when the file splits. A helper used across a workspace's own test tree lives in that workspace's test tree -- packages/core/test/utils/, apps/cli/test/, apps/web/test/utils/ are the three today. In core, packages/core/test/utils/support.ts holds the helpers core's suites share, so a suite imports them rather than defining its own copy. A helper more than one workspace needs takes one of the two channels documented in Shared test material below -- @alcove/core/testing or @alcove/testkit -- whose admission rule lives there, not here; see also cross-workspace-test-material.md.
Directory layout
Test directories mirror the source directories they cover. The vitest project globs (test/unit/** and its equivalents in the other projects) are already recursive, so grouping a workspace's tests to match its src/ tree needs no config change to adopt.
Shared test material
A helper that only tests use lives in the test tree that uses it -- packages/core/test/utils/, apps/cli/test/, apps/web/test/utils/. It may import anything the repository declares, root devDependencies (typescript) included, for as long as no workspace outside its own imports it.
A helper that more than one workspace's test tree needs has two channels, and which one it takes is decided by what it imports, not by what it is about.
@alcove/core/testing, whose subjects live in packages/core/src, is the channel for a helper that needs nothing packages/core does not already declare in its own dependencies. Everything it holds is built into dist/testing.* and published with @alcove/core, so a fixture put there ships to consumers.
@alcove/testkit is the channel for a helper that cannot meet that condition: a private, never-published workspace with no build and no dist, whose exports map points at its ./src/*.ts and which is therefore consumed as raw TypeScript and typechecked inside each consumer's own program. It may import anything the repository declares, including a dependency packages/core does not -- which is the whole reason it exists, since core's build resolves its externals from dependencies alone, so an import of anything else either fails that build or is inlined into the package core publishes.
Its admission rule, which is narrow on purpose: material goes in only when a second workspace's test tree needs it AND it cannot take the @alcove/core/testing channel, and it is exported one explicit subpath at a time. Nothing moves in for tidiness, and the existing @alcove/core/testing subjects stay where they are.
Anything a single workspace needs stays in that workspace's own test tree, whichever channel it would qualify for. The decision, the alternatives measured against it, and where each current @alcove/core/testing subject stands are in cross-workspace-test-material.md.
Checked-in vectors
A vector file is shared test DATA rather than a helper, so it takes neither route above: each one lives in packages/core/test/vectors/ beside the generator that produces it, and a suite in any workspace reads it by relative path -- the Node suites with node:fs, the browser suites with Vite's ?raw. Nothing about it enters packages/core/src or ships in dist/testing.*.
The suites assert that the code reproduces the checked-in JSON; npm run check:vectors asserts the other direction on every pull request, so a failing conformance assertion cannot be silenced by editing the vectors file. It regenerates each file with its generator and fails on a difference, naming both, and it classifies every file in the directory -- generated, a verifier (verify-native-wire-vectors.mjs), or hand-authored with no generator (canonical-vectors.json and psi-prebuild-manifest.json) -- so a file added beside the others fails until it is classified rather than passing unchecked.
Two files hold values the check cannot compare, both in signed-receipt-vectors.json and signing-cert-vectors.json: the ECDSA signature fields, freshly randomized on every signing run, and signatureProducer, which records the openssl version of the host that signed the checked-in bytes and so reproduces only on a host with that same build. Those are masked by name and by the shape each value takes, the mask fails closed if it stops matching, and the signatures' validity is asserted by the suites that verify them. Refreshing any vectors file is running its generator and then npm run format, as each generator's header states.
Reading one from more than one workspace is by design, along two axes. A construction that two applications implement independently is guarded only when each application's OWN call sites are driven against one pinned answer; a test that computes both "sides" through a single shared path proves nothing about either. And a construction that runs on crypto.subtle is a different implementation on each platform, so a Node-only assertion of it leaves the browser half unmeasured.
webrtc-interop-vectors.json -- the CLI-to-web rendezvous, invitation, and handshake-role contract -- splits both ways:
packages/core/test/config/webrtcInterop.test.tsfor what core alone owns.apps/cli/test/unit/connection/webrtcInterop.test.tsandapps/cli/test/unit/connection/webrtcDispatch.test.tsfor the CLI's own constructions.apps/web/test/unit/psi/webrtcInterop.test.tsfor the web app's, with each of the three flows that read the side-to-role table driven through its own entry point as well: the two one-shot flows inapps/web/test/unit/psi/webrtcInteropHooks.test.tsand the managed re-run inapps/web/test/unit/psi/managedRunDriver.test.ts. A correct table read on the wrong key is the same failure on the wire, and one no web-to-web test can see, since a swapped key moves both ends together.apps/web/test/browser/webrtcInterop.test.tsfor the peer-id derivation and the invitation checksum in real Chromium.
Each of those projects runs on every pull request that can touch the contract, so a divergence fails there rather than at first cross-application contact. kex-vectors.json splits on the platform axis alone (packages/core/test/kex.test.ts and apps/web/test/browser/kex.test.ts): both applications drive the key schedule through the same core entry point, so it has no per-application call sites to guard.
Verifying Windows owner-only file protections
On Windows the CLI protects its owner-only files with ACLs rather than POSIX mode bits. A nightly windows-latest job (.github/workflows/nightly_platform.yaml) runs the CLI unit suite, including this describe's cases, but until that leg has stayed green the checks below remain the procedure to trust. The checks below are a manual procedure to run on a Windows host to confirm the owner-only writers still narrow ACLs correctly after a change to their Windows branch. Run them in PowerShell from a scratch directory on an NTFS volume that has the usual inheritable ACEs (a subdirectory of the user profile is fine); each check produces an artifact you then inspect. $me below is the domain-qualified account the CLI narrows the file to; derive it from whoami, the same call the CLI uses to name the grant principal, so the comparison matches the granted identity exactly (domain casing and NetBIOS form included).
$me = (whoami)Owner-only artifacts on Windows come from three writers: writeFileOwnerOnly (the key file, the config writer, exchange records, and the signing identity), createOwnerOnlyWriteStream (the result CSV), and writeFileAtomic for a file written with the owner-only mode. Produce one artifact from each by running the operations that use them: an exchange writes the result CSV and rotates the key file, accept writes the signing identity, and so on (see CLI.md). Each check assumes a file at $f.
A narrowed file grants Modify to the current user only, with no inherited or foreign non-owner ACE. After a writer creates $f, its DACL must grant Modify to the current user and to no one else, and must not be inheriting from the parent directory:
icacls $f
# Expect one line granting the current user (M); no BUILTIN\Users, no (I)
# inherited entries, no other principal.
(Get-Acl $f).AreAccessRulesProtected # must be True (inheritance stripped)
(Get-Acl $f).Access | ForEach-Object {
"{0} {1} Inherited={2}" -f $_.IdentityReference, $_.FileSystemRights, $_.IsInherited
}
# Every rule must be IdentityReference = $me, FileSystemRights = Modify,
# Inherited = False. Any other principal, or any Inherited = True rule, fails.This is what writeFileOwnerOnly, writeFileAtomic (owner-only mode), and createOwnerOnlyWriteStream all produce: they run icacls <file> /inheritance:r /grant:r "$me:(M)", which strips inheritance and replaces the DACL with the single owner Modify grant.
createOwnerOnlyWriteStream's overwrite path drops a pre-existing foreign explicit ACE. The stream writer unlinks and recreates the destination as a fresh inode before narrowing, so a foreign principal's explicit (non-inherited) grant left on a prior file at that path does not survive the overwrite. Seed such a grant on a stand-in file, overwrite it by writing a result CSV to the same path, and confirm the foreign grant is gone:
# Seed a pre-existing file with an explicit grant for another principal
# (Guests is present on a default install; substitute any non-owner account).
"stale" | Out-File -Encoding utf8 $f
icacls $f /grant "Guests:(R)"
icacls $f # confirm the Guests ACE is present before the overwrite
# Now run an exchange whose result CSV output path is $f, then re-inspect:
icacls $f
# Expect only the current user (M); the Guests ACE must be absent. If it
# survived, the fresh-inode overwrite regressed to an in-place narrow.The load-time over-permissive check flags a loosened ACL. On load, before reading an owner-only secret (the key file or the signing identity), the CLI runs warnIfFileOverPermissive, which on Windows checks the ACL: first via PowerShell Get-Acl with SID translation (both inherited and explicit ACEs; SYSTEM and Administrators are exempt), falling back to icacls (explicit non-owner ACEs only) where PowerShell is unavailable. Loosen a correctly-narrowed key file and confirm the next CLI load warns:
# Grant another principal read on the key file, defeating owner-only.
icacls .alcove.key /grant "Guests:(R)"Run a command that loads the key file (for example alcove exchange) and confirm it logs a warning that the file grants access to other users and should be restricted to owner-only. To confirm the icacls fallback tier (used where Get-Acl cannot run, such as a Nano Server container or a constrained-language environment), repeat with PowerShell unavailable on PATH; the warning must still fire. Restore owner-only afterward:
icacls .alcove.key /inheritance:r /grant:r "$me:(M)"A clean load emits no such warning, so absence of the warning after restoring the ACL confirms the check clears a correctly-narrowed file.
Coverage
Coverage is an informational REPORT to help you and reviewers see which product paths a change leaves unexercised. It is produced on demand -- it is not part of npm test and does not run in CI.
npm run coverage # all workspaces; writes a report per workspace
npm run coverage -w packages/core # a single workspaceIt uses @vitest/coverage-v8 (first-party Vitest tooling, so no second runner) and writes a text summary to the terminal plus a browsable HTML report and an lcov.info (for editors/tooling) under each workspace's coverage/ directory. The denominator is scoped to product source under each src/ tree, with the generated route tree excluded, so the numbers reflect hand-written product code; the vendored signaling broker is a workspace of its own (packages/peerjs-broker) and runs no coverage, so it sits outside every denominator.
The report runs core unit, cli unit, integration, webrtc, and backend-agnostic (the SFTP adapter is exercised only by the integration suite, the WebRTC transport only by the webrtc suite, and the exchange and zero-setup command handlers only by the backend-agnostic suite, so dropping any of them would leave what it covers looking near-uncovered), and web unit plus web browser (real Chromium via Playwright). The web unit and browser projects run together and their coverage is merged, so the component, live-exchange, and consent-gate paths exercised only in the browser are reflected instead of reading as near-zero; running npm run coverage for the web workspace therefore stands up the dev server and Chromium the same way npm run test:browser does.
The web black-box integration suite is excluded by design: it fetches separately-spawned servers and imports no src, so under --coverage it measures the empty runner process, not the server. Capturing the console server's entry and route code is feasible -- run the spawned server under NODE_V8_COVERAGE and merge its profile -- but low-value: it buys a bespoke merge step outside Vitest's model to cover thin entry and route glue whose behavior the integration suite already asserts end-to-end, so it is out of scope, not a deferred gap.
By design there is NO global percentage gate, and adding one is not a missing piece to be "fixed": a blanket "N% or the build fails" bar rewards vanity tests that raise the number without raising confidence, so the report informs review rather than blocking merges. Do not add a thresholds line to the Vitest coverage config. If coverage gating is ever wanted, it is scoped to packages/core and expressed as diff/patch coverage (coverage of the lines a PR changes), never an absolute whole-repo percentage -- and even that stays opt-in.
Mutation testing
A nightly leg mutates the three core security-bearing files -- packages/core/src/auth.ts, src/connection/abortMarker.ts, and src/connection/encryptedMessageConnection.ts -- and re-runs packages/core's unit tier against every mutant. It measures something the coverage report above cannot: a line can be executed by a test that passes just as happily when the line's behavior is changed, and the mutation score counts only the mutants some test actually distinguishes. That is the gap it exists for -- a domain-separation label, a fail-closed guard, an error kind a caller branches on, each of them reading as covered while nothing pins it.
npm run test:mutationIt runs against an installed tree (npm ci plus the core build). On the nightly's Ubuntu runner, at the 4 test runners Stryker picks there, the three files' 413 mutants take about 13 minutes, about 5 of them the initial run of the whole core unit tier, which the configuration caps at 15 (dryRunTimeoutMinutes). Stryker is not a repository dependency by design -- it drags in a second copy of Vitest and its own TypeScript -- so scripts/stryker-security.mjs installs it into a private prefix under the work directory: $RUNNER_TEMP in CI, the system temp directory locally, and ALCOVE_STRYKER_WORK_DIR overrides both. The HTML and JSON reports are written there too, so nothing lands in the working tree; the nightly workflow (.github/workflows/nightly_mutation.yaml) checks out staging explicitly and uploads both as a run artifact.
A test nested in a describe block kills mutants the same as one at the top level of its file. That takes a plugin in packages/core/vitest.stryker.config.ts: Stryker's vitest runner selects the tests for each mutant by name, joining a describe title and a test title with a space, while Vitest 5 matches the name filter against the titles joined with >. Without the plugin every nested test is filtered out of every mutant run: re-measured on 2026-09-29 with ALCOVE_STRYKER_WORK_DIR=<dir> npm run test:mutation, limited to abortMarker.ts by removing the other two files from scoreFloors for the run, abortMarker.ts scores 55.96% (61 of 109 mutants killed) when the 21 tests of abortMarkerSubsystem.test.ts sit in one describe and the plugin is off, and 91.74% (100 of 109) with the plugin on -- the same as the file's tests at their real top level, where the plugin changes nothing. The leg fails, naming the file and the mutated lines, when a mutant survives with no tests completed against it: Stryker had tests selected for that mutant, so zero completed means the runner executed none, whatever the file's score reads.
The CLI's accept command
apps/cli/stryker.config.mjs mutates apps/cli/src/commands/accept.ts against the CLI unit tier, through apps/cli/vitest.stryker.config.mts. That tier runs without the integration suites' SFTP server or broker, so accept behavior only they exercise counts as surviving. Stryker's vitest runner forces the threads pool, where process.chdir and process.umask throw and a worker's os.homedir() ignores a changed process.env.HOME, so the config leaves out the unit files that call either function or, in atSignRefs.test.ts's case, point HOME at a temp dir for tilde expansion; none of them imports accept.ts. The same runner drives it, as a second job in the nightly workflow and locally as:
npm run test:mutation:cliIt keeps its sandbox and reports under cli/ in the same work directory, so the two legs' reports do not overwrite each other. Its 473 mutants take 14 minutes at concurrency 3 on a 10-core machine under a load average of about 7.5, 2.5 of them the initial run of the unit tier. The file scores 75%, its committed floor: most survivors are help and message text, and the rest include a removed call to the count-only column check on the online path, which prepareForOnlineExchange repeats with the same refusal.
The floors
Each leg's configuration -- packages/core/stryker.config.mjs, and apps/cli/stryker.config.mjs for the accept command -- holds its corpus and each file's committed floor in one table, scoreFloors: the keys are the files to mutate, the values their minimum mutation score in whole percent. The gate is per file rather than Stryker's whole-run thresholds.break, so a file whose tests were gutted cannot be offset by the others; the leg fails naming the file, its score, and its floor.
- Raising a floor. When tests land that raise a file's score, raise its floor to the new score rounded down to a whole percent. The runner prints the value to use for each file that has moved above its floor.
- Never lowering one. A floor is not lowered to make a red leg green: a drop means a test stops distinguishing the mutated behavior, which is the finding the leg exists to report.
- Widening the corpus. Adding a file to a leg is one line in its table, with the floor that file measures when it is added.
Do not chase the score itself. The survivors these floors sit above are error and log message text and private bookkeeping flags with no external observer, which a test can only pin by asserting the exact wording -- worth doing where an operator acts on the text, not as a way to move the number.