<NodeTestRules keywords="node-test, runner, assertions, native_mock_fn, http_mock_agent, snapshot_files" type="testing-rules" ver="3.0">
  <Mission>
    Node.js built-in `node:test` runner (`node --test`) specifics on top of [`testing/common`](./common.xml). Only the deltas are stated here: assertion API mapping, native `mock.fn()` usage, HTTP mock-agent harness, snapshot file location.

    Read `testing/common.xml` first. Everything about contract boundary, case flow, phase anchors, unified context, factory-over-hooks, BDD mapping, snapshot operator-confirm, file budget — lives there.
  </Mission>

  <Inherited_Baseline>
    All axioms in [`testing/common`](./common.xml) `Belief_State` apply here UNCHANGED — reader-agent treats them as binding under this directive. `BDD_Mapping_Hint`, `Definitions`, `Workflow_Outline` likewise inherited. This file only adds node:test-specific overrides and patterns.
  </Inherited_Baseline>

  <Belief_State>

    <Axiom id="AX_ASSERT_API_CHOICE">
      Pick the simplest direct match by shape (node:test assert API):
      - `assert.strictEqual(actual, expected)` — scalars and reference identity.
      - `assert.deepStrictEqual(actual, expected)` — structured results (deep, type-strict).
      - `assert.throws(fn, matcher)` / `await assert.rejects(promise, matcher)` — thrown failures.
      - `assert.match(str, regex)` — partial string / error-message checks (per inherited `AX_PARTIAL_STRING_VIA_MATCH`).
      - `t.assert.snapshot(value)` / `t.assert.fileSnapshot(value, path)` — ONLY for large stable serializable outputs (per inherited `AX_SNAPSHOT_USAGE_GATE`).

      Each function has a unique diagnostic signature. A mismatched choice (snapshot for a scalar, `includes` on a message) degrades the failure report.

      Forbidden idioms: `assert.ok(x.includes(y))` on error messages → use `assert.match`. Manual `let threw = false; try {...} catch {...} assert.ok(threw)` → use `assert.throws` / `assert.rejects`.
    </Axiom>

    <Axiom id="AX_PREFER_NATIVE_MOCK_FN">
      Use `mock.fn()` from `node:test` for tracking calls and stubbed behavior on injected collaborators. Native API requires no extra dependencies and integrates with the runner (cleanup, assertion helpers).

      Common surface:
      - `const fn = mock.fn(async () => 'ok')` — create stub with implementation.
      - `fn.mock.callCount()` / `fn.mock.calls[i].arguments` — interaction assertions.
      - `mock.module('pkg', { namedExports: {...} })` — module-level mocking (gated by inherited `AX_MOCK_AS_LAST_RESORT` + `AX_NO_FALSIFICATION_VIA_MOCKS`).
    </Axiom>

    <Axiom id="AX_HTTP_MOCK_AGENT_PATTERN">
      For HTTP-heavy tests where injection is not feasible:
      - Use `setupMockAgent()` from `utils/test/mock-http.ts`; call `cleanup()` in `afterEach`.
      - Request-specific intercepts go INSIDE the case `SETUP` phase, not the shared hook.
      - If the SUT uses `axios`, set `axios.defaults.adapter = 'fetch'` in shared setup so the mock agent sees the requests.

      Default posture (per inherited `AX_MOCK_AS_LAST_RESORT`): inject an HTTP client / `request` port into the SUT and stub it with `mock.fn()`. Mock-agent harness is for code that already exists without an injection seam.
    </Axiom>

    <Axiom id="AX_SNAPSHOT_FILE_LOCATION">
      `t.assert.snapshot()` / `t.assert.fileSnapshot()` outputs live in a dedicated `snapshots/` directory next to the test area. Updates go ONLY through the runner's snapshot-update flow under inherited `AX_SNAPSHOT_OPERATOR_CONFIRM` — manual edits to snapshot files are forbidden (they enable silent contract drift).
    </Axiom>

  </Belief_State>

  <Test_Patterns>
    <Pattern id="PT_DIRECT_SCALAR_CASE">
      <Intent>Trivial scalar case without an opening brief: title + direct input + a single assertion already expose the scenario. Anchors omitted on the one-line TRIGGER and ASSERT phases per `AX_PHASE_ANCHORS`; kept only on SETUP because it has its own intent.</Intent>
      <Snippet language="typescript">
        ```typescript
        import { describe, it } from 'node:test';
        import assert from 'node:assert/strict';
        import { unwrap } from '#shared/result.ts';

        describe('SlugBuilder', () => {
            it('should build a lowercase slug from the title', async () => {
                const input = { title: 'Hello World', separator: '-' };
                const slug = await unwrap(builder.build(input));
                assert.strictEqual(slug, 'hello-world');
            });
        });
        ```
      </Snippet>
      <Why>Title + direct input + single `strictEqual` make the scenario self-evident → brief skipped. Each phase is one statement with no hidden policy → anchors skipped. Phase grammar still readable for the next agent because the three lines naturally map SETUP/TRIGGER/ASSERT.</Why>
    </Pattern>

    <Pattern id="PT_ASYNC_RETRY_WITH_BRIEF">
      <Intent>Async scenario with retry policy: opening brief records invariant + failure mode; phase anchors separate multi-line SETUP, the TRIGGER call, and the composite ASSERT with main + orthogonal observation.</Intent>
      <Snippet language="typescript">
        ```typescript
        import { describe, it, beforeEach, afterEach } from 'node:test';
        import assert from 'node:assert/strict';
        import { setupMockAgent } from '#utils/test/mock-http.ts';

        describe('VendorClient', () => {
            let mockEnv: ReturnType<typeof setupMockAgent>;
            let client: VendorClient;

            beforeEach(() => {
                mockEnv = setupMockAgent();
                client = new VendorClient({ baseUrl: 'https://vendor.test' });
            });

            afterEach(() => { mockEnv.cleanup(); });

            it('should retry once and return the normalized payload', async () => {
                // contract: one transient failure is tolerated before success is returned
                // failure mode: do not wrap the returned result into a synthetic summary object

                // #region START_FETCH_ITEM_SETUP_MOCKS
                const url = 'https://vendor.test/items/42';
                const tracker = mockEnv.interceptMultiple('GET', url, [
                    () => ({ status: 503, body: 'Service Unavailable' }),
                    () => ({ status: 200, body: { id: 42, name: 'Keyboard', inStock: true } }),
                ]);
                // #endregion END_FETCH_ITEM_SETUP_MOCKS

                const result = await client.fetchItem(url);

                // #region START_FETCH_ITEM_ASSERT_RESULT
                assert.deepStrictEqual(result, {
                    ok: true,
                    status: 200,
                    data: { id: 42, name: 'Keyboard', inStock: true },
                });
                assert.strictEqual(tracker.getAttemptCount(), 2);
                // #endregion END_FETCH_ITEM_ASSERT_RESULT
            });
        });
        ```
      </Snippet>
      <Why>Brief reveals retry invariant; SETUP has multiple statements + intercept policy → anchored; TRIGGER is a single call → no anchor; ASSERT carries two related observations → anchored. Orthogonal `attemptCount` lives next to the main contract assertion without aggregation noise.</Why>
    </Pattern>

    <Pattern id="PT_ERROR_PATH_WITH_ASSERT_REJECTS">
      <Intent>Error-path scenario: focused assertions via `assert.rejects()` + `assert.match()` on a stable message fragment; no `try/catch + flag` antipatterns.</Intent>
      <Snippet language="typescript">
        ```typescript
        import { describe, it } from 'node:test';
        import assert from 'node:assert/strict';

        describe('VendorClient#reserveStock', () => {
            it('should reject with VendorStockError when upstream reports OUT_OF_STOCK', async () => {
                // contract: domain error class + recognizable message preserves cause-chain
                // failure mode: do NOT assert full error.message — it carries a dynamic correlation id

                // #region START_RESERVE_STOCK_SETUP_MOCKS
                const idempotencyKey = '550e8400-e29b-41d4-a716-446655440000';
                mockEnv.interceptOnce('POST', '/v2/reservations', () => ({
                    status: 409,
                    body: { code: 'OUT_OF_STOCK' },
                }));
                // #endregion END_RESERVE_STOCK_SETUP_MOCKS

                await assert.rejects(
                    () => client.reserveStock(idempotencyKey),
                    (error: unknown) => {
                        assert.ok(error instanceof VendorStockError);
                        assert.match((error as Error).message, /\[VendorClient#reserveStock\] .*OUT_OF_STOCK/);
                        return true;
                    }
                );
            });
        });
        ```
      </Snippet>
      <Why>Combined TRIGGER+ASSERT via `assert.rejects` is intentional — forced phase split would yield an empty TRIGGER. The single `rejects` call needs no anchor; SETUP has multiple lines + interception policy → anchored.</Why>
    </Pattern>

    <Pattern id="PT_MOCK_FN_INTERACTION_CONTRACT">
      <Intent>Native `mock.fn()` for tracking calls on injected collaborators + assertions on interaction contract without inspecting SUT internals.</Intent>
      <Snippet language="typescript">
        ```typescript
        import { describe, it, mock } from 'node:test';
        import assert from 'node:assert/strict';

        describe('OrderLifecycle#settleCharge', () => {
            it('should persist order in PAID state and emit settled event after successful charge', async () => {
                // observation focus: persistence call args + emitted event payload, not pipeline internals

                // #region START_SETTLE_CHARGE_SETUP_DOUBLES
                const repoSave = mock.fn(async (_order: Order) => {});
                const onSettled = mock.fn();
                const lifecycle = new OrderLifecycle({ saveOrder: repoSave });
                lifecycle.on('settled', onSettled);
                // #endregion END_SETTLE_CHARGE_SETUP_DOUBLES

                await lifecycle.settleCharge('ord-1');

                // #region START_SETTLE_CHARGE_ASSERT_INTERACTIONS
                assert.strictEqual(repoSave.mock.callCount(), 1);
                assert.deepStrictEqual(repoSave.mock.calls[0].arguments[0], {
                    id: 'ord-1',
                    state: 'PAID',
                });

                assert.strictEqual(onSettled.mock.callCount(), 1);
                assert.deepStrictEqual(onSettled.mock.calls[0].arguments[0], {
                    type: 'order.settled',
                    detail: { orderId: 'ord-1' },
                });
                // #endregion END_SETTLE_CHARGE_ASSERT_INTERACTIONS
            });
        });
        ```
      </Snippet>
      <Why>Demonstrates `AX_PREFER_NATIVE_MOCK_FN` and contract-boundary observation of an emitted event (per inherited `AX_CONTRACT_OVER_IMPLEMENTATION`). Multi-statement SETUP and multi-statement ASSERT anchored; single-line TRIGGER is not.</Why>
    </Pattern>

    <Pattern id="PT_FIXTURE_AND_OBSERVE_AGGREGATION">
      <Intent>Composite contract (generated text + observability milestone) where an OBSERVE phase normalizes result + side-effect log into one diagnostic slice. Justified case for aggregation per inherited `AX_DEFAULT_DIRECT_ASSERTION_PROTOCOL`.</Intent>
      <Snippet language="typescript">
        ```typescript
        import { describe, it, beforeEach } from 'node:test';
        import assert from 'node:assert/strict';
        import { readFileSync } from 'node:fs';

        describe('CodegenPipeline#renderModule', () => {
            let pipeline: CodegenPipeline;

            beforeEach(() => {
                pipeline = new CodegenPipeline({ logger: createMemoryLogger() });
            });

            it('should produce normalized output and emit a single info-level milestone log', async () => {
                // contract: pipeline emits exactly one info log per successful render
                // failure mode: do not snapshot scalar log count — it hides the actual milestone shape

                const inputSpec = JSON.parse(readFileSync('test/fixtures/render-module.input.json', 'utf8'));
                const output = await pipeline.renderModule(inputSpec);

                // #region START_RENDER_MODULE_OBSERVE_AGGREGATE
                // observation focus: text + milestone collapsed into one diff for a richer failure report
                const actual = {
                    text: output.text,
                    milestones: pipeline.logger.records
                        .filter((r) => r.level === 'info')
                        .map((r) => r.message),
                };
                // #endregion END_RENDER_MODULE_OBSERVE_AGGREGATE

                assert.deepStrictEqual(actual, {
                    text: '/* generated module */\nexport const value = 42;\n',
                    milestones: ['[CodegenPipeline#renderModule] [rendering → rendered] ok'],
                });
            });
        });
        ```
      </Snippet>
      <Why>Aggregation in OBSERVE justified: a single `deepStrictEqual` shows drift in both text and milestones. SETUP and TRIGGER are one-liners → no anchors; OBSERVE has aggregation policy → anchored. Fixture read allowed, write forbidden (inherited `AX_FIXTURE_IO_POLICY`).</Why>
    </Pattern>
  </Test_Patterns>

  <Anti_Patterns>
    <Anti_Pattern id="AP_PRIVATE_INSPECTION">
      <Bad>Test inspects `client._inflightCount` (protected/internal field) via `@ts-expect-error` to bypass encapsulation.</Bad>
      <Why_Bad>Violates inherited `AX_CONTRACT_OVER_IMPLEMENTATION`. A refactor renaming `_inflightCount` to `_pending` breaks the test without changing the contract.</Why_Bad>
      <Good>Subscribe to `client.on('reservation.requested', e => events.push(e))` and assert `events.length` + `events[0].idempotencyKey`. Public lifecycle event is the observable.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_TRY_CATCH_FLAG_ANTIPATTERN">
      <Bad>`let threw = false; try { await client.reserveStock(''); } catch (e) { threw = true; assert.ok((e as Error).message.includes('idempotency')); } assert.ok(threw);`</Bad>
      <Why_Bad>Manual try/catch + boolean flag instead of `assert.rejects()` (`AX_ASSERT_API_CHOICE`). `includes()` on error message (inherited `AX_PARTIAL_STRING_VIA_MATCH`). Test can silently pass if the code stops throwing — `threw` stays false, only fallback gate is `assert.ok(threw)`.</Why_Bad>
      <Good>`await assert.rejects(() => client.reserveStock(''), (error) => { assert.match((error as Error).message, /idempotency.*empty|empty.*idempotency/i); return true; });`</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_SHARED_MUTABLE_STATE_AND_NON_ENGLISH_NARRATION">
      <Bad>`const cart = new Cart()` shared between tests; test names in Russian («добавляет товар»); `Шаг 1: добавляем` / `Шаг 2: проверяем` comments; second test depends on state from first.</Bad>
      <Why_Bad>Shared mutable state breaks isolation (inherited `AX_NO_DESCRIBE_LET_PILEUP`); creates order dependency. Non-English names/comments (inherited `AX_ENGLISH_ONLY_NAMES_AND_COMMENTS`). Step-by-step human narration (inherited `AX_TESTS_NO_HUMAN_NARRATION`).</Why_Bad>
      <Good>Single context factory `createCartContext(overrides?)` called inside each case; English titles like `should expose count = 1 after a single add`; direct assertions.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_SNAPSHOT_FOR_SCALAR">
      <Bad>`it('returns 42', (t) => { const result = compute(); t.assert.snapshot(result); });`</Bad>
      <Why_Bad>Snapshot for a scalar — violates inherited `AX_SNAPSHOT_USAGE_GATE`. Failure shows diff of snapshot blobs instead of «42 ≠ 41».</Why_Bad>
      <Good>`assert.strictEqual(result, 42)` — direct, clear, no snapshot file overhead.</Good>
    </Anti_Pattern>
  </Anti_Patterns>

  <Verification_Hooks>
    <Hook id="HOOK_RUN_TESTS">
      <Command>npm test</Command>
      <Expected>Exit 0; all cases pass; no `.skip` / `.todo` without explicit deferred-ownership in the ticket.</Expected>
    </Hook>
    <Hook id="HOOK_RUN_SINGLE_FILE">
      <Command>node --test path/to/subject.test.ts</Command>
      <Expected>Exit 0; targeted file passes.</Expected>
    </Hook>
    <Hook id="HOOK_COVERAGE">
      <Command>node --test --experimental-test-coverage</Command>
      <Expected>Exit 0; report shows public methods with ≥1 happy + ≥1 boundary (when applicable) + ≥1 failure-path per inherited `AX_COVERAGE_BY_CONTRACT_NOT_BY_LINE`. Coverage cannot run → declare blocker EXPLICITLY.</Expected>
    </Hook>
    <Hook id="HOOK_TYPECHECK_BEFORE_TEST">
      <Command>npx tsc --noEmit</Command>
      <Expected>Exit 0; no TS errors.</Expected>
    </Hook>
    <Hook id="HOOK_NO_FORBIDDEN_TEST_PATTERNS">
      <Purpose>Smoke-grep for forbidden idioms: human narration `Step N`, `includes(` on error message, manual try-catch flag.</Purpose>
      <Command>rg --no-heading -n "Step \d|\.message.*\.includes\(|let\s+threw\s*=" -t ts --glob '**/*.test.ts'</Command>
      <Expected>Empty on new/changed test files.</Expected>
    </Hook>
  </Verification_Hooks>
</NodeTestRules>
