Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@ jobs:
run: yarn test:coverage
env:
LOG_SILENT: 'true'

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-node-${{ matrix.node-version }}
path: coverage/
if-no-files-found: warn
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,11 @@ dist

# Compiled browser SDK for the edge-replica example (build with: yarn build:browser)
examples/edge-replica/lib/

# Claude Code tooling (agent worktrees, local state)
.claude/

# Stryker mutation testing output
reports/
.stryker-tmp/
stryker.log
36 changes: 36 additions & 0 deletions docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,42 @@ This is an assessment of where the test suite is strong, where it is thin, and a
prioritized list of concrete additions. Numbers come from
`npx jest --coverage` (Istanbul) over `src/`.

## Follow-up round 2 — deeper hardening

A second pass (after the round-1 work below) took the suite from **273 → 347
tests** and coverage to **~90.4% stmt / 76.7% branch / 90.7% func / 92.3% line**:

- **Edge stream-source internals** (`tests/edgeStreamSource.test.ts`): drives
`HttpStreamSource` against a stub SSE server and `EventSourceStreamSource` via
an injected double — frame parsing, split frames, malformed payloads, auth/non-
200 responses, and every termination path. `httpStreamSource` 75→100% stmt,
`eventSourceStreamSource` 80→100% stmt. This surfaced a **real bug**: an abrupt
socket reset emitted `aborted`/`close` (not `end`), which the source ignored —
so the replica silently stalled instead of reconnecting. Fixed in
`src/edge/httpStreamSource.ts` (listen for `aborted`/`close`, single-fire guard).
- **Runtime unit tests** (`tests/runtime/{canonical,shardRouter,keyedModule,projection}Unit.test.ts`):
drove `canonical.ts`, `shardRouter.ts`, `keyedModule.ts`, `projection.ts` to
**100%** statements and branches (from 76/74/78/64%).
- **Generative consensus safety suite** (`tests/consensusProperties.test.ts`):
seeded (mulberry32, no new deps) randomized command streams + leader-crash
churn over LocalTransport clusters, asserting the Raft safety triple — state
convergence, committed-log agreement up to the shared commit index, and
determinism of replay. Reproducible via `SEED=<n>`.
- **CI**: coverage is now emitted as lcov + an uploaded artifact per Node leg, and
the global threshold floor was ratcheted to 88/74/88/90.
- **Mutation testing (opt-in)**: `yarn test:mutation` runs Stryker (dev-only,
scoped, not in the CI gate) to measure test *effectiveness*, not just reach. The
initial `canonical.ts` run scored ~94%. See [ADR-0024](./adr/0024-opt-in-mutation-testing.md).

**Known remaining gap (intentional):** the 2s `READ_BARRIER_TIMEOUT` branch in
`RaftNode.waitForApplied` is still not unit-tested. `lastApplied` is incremented
*before* `apply()` (raftNode.ts), so `commitIndex` can't be held ahead of
`lastApplied` via a throwing state machine; the only trigger is a precisely-timed
partition of a lagging follower right after it receives a ReadIndex, which is
inherently racy. The adjacent fail-closed paths are covered in
`followerReads.test.ts`/`readBarrier.test.ts`. Forcing this branch cleanly would
need a test-only seam in `src` — deferred rather than shipping a flaky timing test.

## Resolution — all six areas addressed

Every gap below has been worked through (commits on this branch). Result: the
Expand Down
50 changes: 50 additions & 0 deletions docs/adr/0024-opt-in-mutation-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 0024. Opt-in mutation testing (Stryker), dev-only

- Status: Accepted
- Date: 2026-06-22

## Context

Line/branch coverage tells us which code *ran* under test, not whether the tests
would actually *catch a regression* in it. After raising coverage to ~90% the
natural next question is test strength: a file at 100% line coverage can still
have assertions weak enough that a real bug slips through. Mutation testing
answers this by introducing small faults ("mutants") and checking the suite
fails — a killed mutant means the tests caught it.

The established tool for the TypeScript/Jest stack is **Stryker**, which is a
heavyweight dev toolchain (100+ transitive packages) and runs the suite many
times. [ADR-0013](./0013-minimal-dependencies.md) is explicit about keeping
dependencies minimal — but it scopes that rule to *runtime* primitives
(consensus, RPC, metrics, persistence), which must stay visible and in-house.

## Decision

Add Stryker as a **dev-only, opt-in** tool, not part of the default `yarn test`
or the CI gate:

- `@stryker-mutator/core` + `@stryker-mutator/jest-runner` as **devDependencies**
(they never ship in the library surface; runtime deps are unchanged).
- `stryker.conf.json` reuses the existing `jest.config.js` and uses
`coverageAnalysis: "perTest"` so only the tests covering a mutant run.
- A `yarn test:mutation` script. `mutate` is scoped narrowly by default (a single
pure module) so a run is fast on a POC; widen it ad hoc to assess other code.
- `thresholds.break` is `null` — mutation score is a **diagnostic**, never a hard
gate, so it can't make CI flaky or slow.

This honors ADR-0013's intent (no new *runtime* weight, the core stays in-house)
while gaining a sharper test-quality signal on demand.

## Consequences

- We can measure test *effectiveness*, not just reach. The initial scoped run
(`src/runtime/canonical.ts`) scored ~94%, surfacing a few survived mutants in
the `'drop'`-mode branch — concrete, actionable test-strengthening targets that
coverage alone could not reveal.
- No impact on the default workflow: `yarn test`/`test:coverage` and CI are
unchanged; mutation runs are explicit and local.
- The dev dependency footprint grows, accepted as dev-only and reversible
(delete the two devDeps + `stryker.conf.json` + the script). Stryker output
(`reports/`, `.stryker-tmp/`) is git-ignored.
- For production hardening, widen `mutate` to the consensus core and treat
surviving mutants as a backlog of weak assertions to fix.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ supersedes the old one rather than editing history.
| [0021](./0021-pluggable-bft-consensus-seam.md) | Pluggable BFT consensus — the seam, and why it is not built | Proposed |
| [0022](./0022-joint-consensus-membership.md) | Dynamic cluster membership via joint consensus | Accepted |
| [0023](./0023-edge-read-replicas-in-the-browser.md) | Edge read replicas in the browser | Accepted |
| [0024](./0024-opt-in-mutation-testing.md) | Opt-in mutation testing (Stryker), dev-only | Accepted |

## Template

Expand Down
31 changes: 22 additions & 9 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ module.exports = {
},
moduleDirectories: ['node_modules', 'src'],
testMatch: ['**/*.test.ts'],
// Never discover tests inside git worktrees the tooling parks under .claude/
// (they contain a full copy of this suite, which would multiply discovery).
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/.claude/'],
modulePathIgnorePatterns: ['<rootDir>/.claude/'],
verbose: true,
// Reporters used when --coverage is passed: a concise summary for CI logs,
// the per-file table, and lcov (coverage/lcov.info + an HTML report) which CI
// uploads as an artifact.
coverageReporters: ['text-summary', 'text', 'lcov'],
coverageDirectory: 'coverage',
// What coverage is measured over (only when --coverage is passed). Exclude
// pure type/wiring entry points that carry no testable logic of their own.
collectCoverageFrom: [
Expand All @@ -18,17 +27,21 @@ module.exports = {
'!src/moduleServer.ts',
'!src/edge/browser.ts',
],
// Floor to stop coverage from regressing. Set just below the current numbers
// so it ratchets up over time rather than blocking on day one. Sandboxed
// reducer bodies run inside a vm and are not seen by the instrumenter (their
// injected counters are no-op'd — see src/runtime/sandbox.ts), so global
// line/branch figures sit a little lower than the bulk of the per-file ones.
// Floor to stop coverage from regressing, set just below the current aggregate
// (stmts ~90.4 / branches ~76.7 / funcs ~90.7 / lines ~92.3). A single global
// bucket is deliberate: jest's per-path/glob thresholds SUBTRACT their files
// from the global bucket, so adding directory floors would shift the global
// number unpredictably as code moves — a tightened global floor is the robust
// regression guard. Sandboxed reducer bodies run inside a vm and are invisible
// to the instrumenter (their injected counters are no-op'd — see
// src/runtime/sandbox.ts), which holds the global figures a touch below the
// bulk of the per-file ones.
coverageThreshold: {
global: {
statements: 86,
branches: 71,
functions: 86,
lines: 88,
statements: 88,
branches: 74,
functions: 88,
lines: 90,
},
},
};
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"mint-token": "node scripts/mint-token.js",
"start": "tsc && node dist/server.js",
"test": "jest --detectOpenHandles --runInBand --forceExit",
"test:coverage": "jest --coverage --runInBand --forceExit"
"test:coverage": "jest --coverage --runInBand --forceExit",
"test:mutation": "stryker run"
},
"repository": {
"type": "git",
Expand All @@ -35,6 +36,8 @@
"express": "^4.18.2"
},
"devDependencies": {
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/jest-runner": "^9.6.1",
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.1",
Expand Down
31 changes: 23 additions & 8 deletions src/edge/httpStreamSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,17 @@ export class HttpStreamSource<C extends AppCommand = AppCommand> implements LogS
const client = url.protocol === 'https:' ? https : http;

let closed = false;
let streamDone = false;
let buffer = '';

// Report stream termination exactly once (until an intentional close()),
// regardless of which of end/aborted/close/error fires first or repeats.
const fail = (err: Error): void => {
if (closed || streamDone) return;
streamDone = true;
handlers.onError(err);
};

const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (this.token) headers.Authorization = `Bearer ${this.token}`;

Expand All @@ -46,12 +55,12 @@ export class HttpStreamSource<C extends AppCommand = AppCommand> implements LogS
(res) => {
if (res.statusCode === 401 || res.statusCode === 403) {
res.resume();
if (!closed) handlers.onError(new Error(`stream unauthorized (HTTP ${res.statusCode})`));
fail(new Error(`stream unauthorized (HTTP ${res.statusCode})`));
return;
}
if (res.statusCode !== 200) {
res.resume(); // drain
if (!closed) handlers.onError(new Error(`stream HTTP ${res.statusCode}`));
fail(new Error(`stream HTTP ${res.statusCode}`));
return;
}
handlers.onOpen?.();
Expand All @@ -66,15 +75,21 @@ export class HttpStreamSource<C extends AppCommand = AppCommand> implements LogS
dispatchFrame(frame, handlers);
}
});
res.on('end', () => {
if (!closed) handlers.onError(new Error('stream ended'));
});
// Surface every way the stream can terminate so the EdgeReplica can
// reconnect. A clean server close emits `end`; an abrupt reset
// (RST / socket.destroy) emits `aborted`/`close` WITHOUT `end`. We
// must report the abrupt case too, or the replica silently stalls.
// `aborted` is deprecated since Node 17 but still fires; `close`
// covers the abrupt path on newer runtimes — `fail()` collapses the
// overlap (and a late `close` after `end`) to a single onError, and
// is a no-op after an intentional `close()`.
res.on('end', () => fail(new Error('stream ended')));
res.on('aborted', () => fail(new Error('stream aborted')));
res.on('close', () => fail(new Error('stream closed')));
},
);

req.on('error', (err) => {
if (!closed) handlers.onError(err);
});
req.on('error', (err) => fail(err));

return () => {
closed = true;
Expand Down
21 changes: 21 additions & 0 deletions stryker.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"_comment": "Opt-in mutation testing (ADR-0024). NOT part of the default test/CI gate. Run: `yarn test:mutation`. Scoped narrowly by default to keep runs fast on a POC; widen `mutate` to assess other modules. `coverageAnalysis: perTest` only runs the tests that cover each mutant.",
"packageManager": "yarn",
"testRunner": "jest",
"jest": {
"projectType": "custom",
"configFile": "jest.config.js"
},
"coverageAnalysis": "perTest",
"tsconfigFile": "tsconfig.json",
"mutate": [
"src/runtime/canonical.ts"
],
"reporters": ["clear-text", "progress"],
"thresholds": {
"high": 90,
"low": 75,
"break": null
}
}
Loading
Loading