test:ci now runs c8 with --all --include "src/**/*.ts" --100, so the build job fails when any runtime file under src/ is untested. --all is what makes the gate non-vacuous: without it c8 counts only the files the suite happened to load, and a new untested module stays invisible. Add src/index.test.ts to load the public barrel, which was previously never imported at runtime and so read as 0% under --all. matcher-shared.ts is types-only (an empty runtime image) and carries a file-level c8 ignore with the reason. Why 100% and the rejected alternatives: development/ci.md § Coverage threshold.
215 lines
9.7 KiB
Markdown
215 lines
9.7 KiB
Markdown
# 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](../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)](../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 (see `src/primitive-union.test.ts`).
|
|
|
|
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
|
|
in
|
|
[CONTRIBUTING.md § Development commands](../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](./tooling.md#source-imports-use-ts-extensions)). CI gates that
|
|
coverage at 100% (see [ci.md § Coverage threshold](./ci.md#coverage-threshold)).
|
|
|
|
## 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](./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 with `deepEqual`:
|
|
strong, but it couples the assertion to call order, and the sink sits three
|
|
blocks away from the value it observes.
|
|
- `typeof` checks: they cannot separate `2` from 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)](../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:
|
|
|
|
```ts
|
|
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 in `package.json`.
|
|
- `#…` is Node's reserved prefix for _private_ subpath imports: publishing
|
|
`package.json` leaks nothing and resolves nothing for consumers.
|
|
- `__tests__` as the folder name is not about tests living there; it is the
|
|
directory pattern `tsconfig.build.json` already excludes, so a helper can
|
|
never be emitted into `dist/` and shipped by accident.
|
|
- Node's own resolver handles `#…` under `--strip-types`, and `tsc` resolves it
|
|
via the same `imports` field — one mechanism for runtime and type gate, no
|
|
loader needed.
|
|
- The helper uses `node:` builtins (it drives a language server), so
|
|
`import/no-nodejs-modules` is off for `src/util/__tests__/**` in
|
|
`.oxlintrc.json` — the same exception the `scripts/**` 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`, flat `src/`): composes only
|
|
while `src/` stays flat; the first nested test reaching it reintroduces the
|
|
banned upward import.
|
|
- Bare `~/…` specifier: not valid in `imports` (keys must start with `#`) —
|
|
resolution fails at runtime with `ERR_MODULE_NOT_FOUND`. A `#~/…` “home”
|
|
shorthand was dropped in review; `#test-utils/…` states what it is.
|
|
- tsconfig `paths` alias: resolves for `tsc` but not for plain
|
|
`node --test --strip-types` (no loader hook), breaking the fast tier.
|
|
- Turning `import/no-relative-parent-imports` off 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:
|
|
|
|
```sh
|
|
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-union.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](./library.md#matcher-shape).
|
|
- The server is the only ground truth; the script reproduces what the editor
|
|
shows.
|
|
|
|
#### Rejected
|
|
|
|
- **`expect-type` would not work**: there is no operator for “the popup offers
|
|
these labels”. `toExtend` / `toEqualTypeOf` test 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-error` at a completion position**: it asserts the absence of a
|
|
compile error, not the presence of specific labels.
|
|
|
|
#### Known issue
|
|
|
|
- Each test spawns its own `tsc` server so the tests share no state and pass in
|
|
any order; the file is an integration test (~1.6 s) that needs `node_modules`.
|
|
`didOpen` is 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 tolerate `string | number` ids or the server stalls.
|
|
- `LspSession.close()` sends `shutdown` and then closes stdin instead of
|
|
sending `exit`. The TS 7 Go server's `handleExit` returns `io.EOF`, cancelling
|
|
the background context while a watch update is still in flight, and logs a
|
|
bare `context canceled` before exiting 1; EOF on stdin exits 0 with no output.
|
|
The kill stays as a fallback for a server that does not exit.
|
|
|
|
## Known issues
|
|
|
|
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise.
|
|
It is a known false positive, so `typescript/no-floating-promises` is off for
|
|
`**/*.test.ts` in the `.oxlintrc.json` override rather than repeated as a
|
|
file-level header (see
|
|
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
|