A handler's `expectTypeOf(shape)` only proved the type the compiler inferred; nothing observed the argument `dispatch` passed. Passing `String(shape)` at the call site therefore kept the whole suite green while breaking every key whose property name differs from its value (`true`, `null`, `1`). Pair each handler expectation with a runtime assertion: `assert.equal` where a handler runs for one shape, the disjunction over the set a `_` fallback accepts where it runs for several. Where the exact value matters the fallback returns its shape verbatim and the call site asserts it; a widening pattern absorbs the remainder type into the return union, so the claim stays strict-free. The mutation above now fails 10 of the 33 tests. Rule: CONTRIBUTING.md, rationale: development/testing.md § Handler arguments.
9.2 KiB
Testing
For this library the types are the feature, so a runtime-only test loop would verify the wrong thing. The commands are in CONTRIBUTING.md; this file records why the loop is shaped the way it is.
Type-driven development
The rules — the loop and the pairing rule — are in CONTRIBUTING.md § Testing discipline (type-driven). What follows is why and what was rejected.
Decision (2026-09)
The compile-time expectation is written before the runtime assertion, and both before the implementation.
Why
- A runtime-only test can pass while the type is wrong, so a type-level library would ship a broken feature its tests bless.
- The type error is a more precise spec than a failing assertion, because it states the exact expected type before the logic exists.
Rejected
- Runtime-first (classic red/green): it verifies the value, not the contract, and the contract is the product.
- Testing the type only: it would not catch handler dispatch or the
_fallback (seesrc/primitive.test.ts).
The runner is node --test --strip-types "src/**/*.test.ts" and the tiers are
in
CONTRIBUTING.md § Development commands.
c8 uses V8 coverage, so the --strip-types source is instrumented without a
build step, and the runner relies on the .ts import-extension convention (see
tooling.md).
Handler arguments
Decision (2026-09)
A handler's expectTypeOf(shape) is always paired with an assertion on the
argument dispatch actually passed: assert.equal where the handler runs for
one shape, assert.ok(s === … || s === …) over the set a _ fallback accepts
(assert is imported as strict, so each comparison is Object.is). Where a
test should also prove that the exact value reached the handler unchanged,
the fallback returns the shape verbatim and the call site asserts it.
Why
- The parameter's type is what the compiler inferred from the pattern; the
argument is what the runtime passed. Only the second can drift, and the keys
whose property name differs from their value (
true,null,1) are exactly where it can — see library.md. String(shape)at the call site keeps every type expectation and every return-value assertion green; the argument assertions fail (10 of the 33 tests). Without them the suite never looks at the passed argument.- Returning the shape verbatim costs a widening pattern nothing: the remainder type joins the union of handler returns in place of a marker literal, so the test still shows the widening it is named for.
Rejected
- A recorded
unknown[]of every fallback call compared withdeepEqual: strong, but it couples the assertion to call order, and the sink sits three blocks away from the value it observes. typeofchecks: they cannot separate2from its key text"2", which is the drift a fallback with a numeric remainder can hit.- One expected value asserted inline in a fallback: its argument is a set of shapes, so only the disjunction holds on every call.
AAA ordering
The rule is in CONTRIBUTING.md § Testing discipline (type-driven).
Decision (2026-09)
Test bodies read arrange → act → assert: inputs (the factory) set up first, the
subject exercised once from them, all checks last — types then runtime. The
blocks are labeled with // Arrange / // Act / // Assert comments and
separated by a blank line; an empty block drops its label.
Why
- Interleaved setup/checks hide what runs vs. what is observed; the eye re-reads the block to find the seams.
- A factory built mid-test invites a second throwaway call of the subject;
arranging it once makes the positive construction and the negative
Parameters<…>check share one source of truth. - Labels make the seams explicit, not inferred — grep-able and reviewable without reading the statements.
Rejected
- Unlabeled ordering (bare blank lines): the seams still have to be found by reading; the labels cost nothing.
Test helpers
The rule is enforced by import/no-relative-parent-imports; this section records
why the mechanism is shaped like this.
Decision (2026-09)
A shared test helper — code that scattered *.test.ts files import to do their
testing — lives under src/util/__tests__/ and is addressed by the
#test-utils/… self-reference (package.json#imports:
"#test-utils/*": "./src/util/__tests__/*"), never by a relative path:
import { LspSession } from "#test-utils/lsp-completion.ts";
Why
- The lint rule bans upward (
../) imports, and a cross-cutting helper can always be placed above some scattered consumer, wherever it goes. Name beats path: a#test-utils/…specifier has no direction, so the rule never fires and file moves only touch the one mapping inpackage.json. #…is Node's reserved prefix for private subpath imports: publishingpackage.jsonleaks nothing and resolves nothing for consumers.__tests__as the folder name is not about tests living there; it is the directory patterntsconfig.build.jsonalready excludes, so a helper can never be emitted intodist/and shipped by accident.- Node's own resolver handles
#…under--strip-types, andtscresolves it via the sameimportsfield — one mechanism for runtime and type gate, no loader needed. - The helper uses
node:builtins (it drives a language server), soimport/no-nodejs-modulesis off forsrc/util/__tests__/**in.oxlintrc.json— the same exception thescripts/**scope carries. - Helpers carry no library coupling: their tests probe in-memory documents whose contextual types are written inline, so only the helper's own contract (marker handling, position, label extraction) is under test. A test that asserts a matcher's popup belongs with the matcher.
Rejected
- A helper beside the tests (
src/util/test.ts, flatsrc/): composes only whilesrc/stays flat; the first nested test reaching it reintroduces the banned upward import. - Bare
~/…specifier: not valid inimports(keys must start with#) — resolution fails at runtime withERR_MODULE_NOT_FOUND. A#~/…“home” shorthand was dropped in review;#test-utils/…states what it is. - tsconfig
pathsalias: resolves fortscbut not for plainnode --test --strip-types(no loader hook), breaking the fast tier. - Turning
import/no-relative-parent-importsoff for test files: the rule still guards non-test helpers importing each other, and the exemption is only needed for the one specifier the mapping already solves cleanly.
Autocomplete
Decision (2026-09)
Completion is verified by driving the repo's own language server
(tsc --lsp --stdio, the same server pi's LSP extension talks to) through
the test helper #test-utils/lsp-completion.ts
(src/util/__tests__/lsp-completion.ts), not through the type system:
node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
The script prints the labels the server offers at a /*COMPLETE*/ marker inside
<file> (the marker is stripped before the document is sent). Its LspSession
is imported by src/util/__tests__/lsp-completion.test.ts — which tests the
helper itself against inline documents, never the library's code — and by
src/primitive.test.ts, where the same probe asserts the matcher's popup;
the CLI is for manual inspection.
Why
- Completion is a contextual-type property: it depends on which overload signature TypeScript picks for the object literal, and no type-level assertion observes that.
Parameters<typeof factory>[0]resolves only the last overload, so it is not the popup's contextual type either — see library.md § Matcher shape.- The server is the only ground truth; the script reproduces what the editor shows.
Rejected
expect-typewould not work: there is no operator for “the popup offers these labels”.toExtend/toEqualTypeOftest assignability and cannot say which overload supplied the contextual type.- Checking by hand in the editor: not reproducible in review or by an agent.
@ts-expect-errorat a completion position: it asserts the absence of a compile error, not the presence of specific labels.
Known issue
- Each test spawns its own
tscserver so the tests share no state and pass in any order; the file is an integration test (~1.6 s) that needsnode_modules.didOpenis handled in order before the completion request, so no settle delay is needed. - The server answers some requests with a string id (
client/registerCapability); the client must toleratestring | numberids or the server stalls.
Known issues
- The type-aware linter misidentifies
expectTypeOf()as a floating promise. It is a known false positive, sotypescript/no-floating-promisesis off for**/*.test.tsin the.oxlintrc.jsonoverride rather than repeated as a file-level header (see tooling.md § oxlint-disable directives live next to the code).