@orkestrel/scaffold 0.0.67 → 0.0.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +4 -4
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1567 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +507 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +445 -6
  62. package/dist/src/core/index.cjs +38 -16
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +37 -17
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +3 -3
@@ -0,0 +1,2969 @@
1
+ # Test
2
+
3
+ > The test helpers the `@orkestrel` fleet kept rewriting, published once: families of what a test
4
+ > records, what it waits for, and what it owns, with a pair outside all of them and a browser
5
+ > journey layer beside them.
6
+
7
+ **What a test records.** A call recorder, a map of recorders subscribed to an emitter's events, a
8
+ signal's live abort-listener tally, a numbered resource ledger, a captured throw, a drained async
9
+ source, a JSON copy, a required value, a decoded JSON Lines stream, and a cookie jar filled from
10
+ real responses. Each turns what the code under test did into a value you can assert on.
11
+
12
+ **What a test waits for.** A real delay, and — each bounded by a budget, an interval, and an abort
13
+ signal — a named condition, a produced value, a first event delivery, a socket's close, and a
14
+ directory the host has finally let go. One wait takes no bound at all, because it needs none:
15
+ `waitForAbort` parks on a signal's own abort. Nothing here replaces the host clock: every bound is
16
+ a real elapsed interval read with `performance.now()`.
17
+
18
+ **What a test owns and must give back.** A temporary directory, a cleanup list, and a loopback
19
+ server, each carrying `destroy()`. Each one takes something from the host.
20
+
21
+ `resolveRoot` and `readInventory` are the pair outside all of them: together they read the
22
+ real tree a test checks itself against. Neither records anything, neither waits for anything, and
23
+ neither owns anything to give back.
24
+
25
+ `createHostileValues` sits outside them too, on the input side: it is what a test feeds its guards,
26
+ a corpus whose every member throws on a naive read or violates a naive structural assumption. The
27
+ host-capability probes are outside them on the environment side, answering what this filesystem
28
+ does rather than what its platform is called. `invokeUnchecked` and `readProperty` are outside them
29
+ at the type boundary, where a value nothing declares meets a claim its caller owns, and
30
+ `flattenHeaders` is outside them on the comparison side, turning any header initializer into one
31
+ frozen record.
32
+
33
+ The journey layer drives a real interface by role and accessible name through the installed Vitest
34
+ provider, measures what a reader can see of the result, records the scenario and the page's own
35
+ output as it goes, and generates the capture portfolio from the same journeys. Around it sit the
36
+ fixture the journey runs against and the readings a styling claim rests on: an element built and
37
+ mounted, a field driven the way its component listens for, the tokens, colors, and rules the
38
+ cascade resolved, and a database given back at the end of the test that filled it.
39
+
40
+ A helper ships here when it is a reusable test mechanism with a real consumer that no native or
41
+ declared primitive already covers; [Limits](#limits) states that rule and what it refused. This
42
+ package holds one implementation of each and ships as a `devDependency`. Nothing here runs in
43
+ production code. Source: [`src/core`](../src/core), [`src/browser`](../src/browser), and
44
+ [`src/server`](../src/server).
45
+
46
+ It has **zero runtime dependencies**, and no exported type here names an `@orkestrel/*` type. A
47
+ dependency on `@orkestrel/emitter` would install a second copy of it beside the one a consumer
48
+ already pins, and the compiler reads two copies as two distinct types. A foreign type in a
49
+ signature fails the other way, rejecting the consumer's own local value inside the consumer's own
50
+ repository. The zero-runtime-dependencies contract holds both.
51
+
52
+ ## Install
53
+
54
+ Add the package as a development dependency; it ships no runtime code.
55
+
56
+ ```bash
57
+ npm install --save-dev @orkestrel/test
58
+ ```
59
+
60
+ `@orkestrel/test` is the host-independent core. `@orkestrel/test/server` is the Node face — the
61
+ filesystem helpers, the process and socket readings, the cookie jar, and the pure leaves they are
62
+ built from. `@orkestrel/test/browser` is the
63
+ journey layer, which drives a real browser through the installed Vitest provider. Core touches
64
+ neither `node:*` nor the DOM, so a browser test project imports it unchanged.
65
+
66
+ The browser face ships ES only. It is built on `vitest/browser`, which is an ES-only module, so no
67
+ CommonJS consumer can reach it and no `.d.cts` is emitted for it.
68
+
69
+ `@orkestrel/test/browser` loads only inside Vitest Browser Mode. It imports `vitest/browser` at
70
+ module scope, so importing it from a Node host throws at module load rather than deferring the
71
+ failure into the first helper call. A module that must load under Node as well reaches it through a
72
+ dynamic import behind a DOM guard — a setup file that a Node project and a browser project both
73
+ register is the case that needs it.
74
+
75
+ ## Surface
76
+
77
+ The values and types that follow are everything this package exports, from its core, browser, and
78
+ server environments.
79
+
80
+ ```ts
81
+ import { createRecorder, createTeardown, waitForDelay } from '@orkestrel/test'
82
+ import { createScratch } from '@orkestrel/test/server'
83
+
84
+ // What a test owns: one cleanup list, and a temporary directory seeded with the input under test.
85
+ const teardown = createTeardown()
86
+ const scratch = createScratch({ files: { 'input.txt': 'hello' } })
87
+ teardown.add(() => scratch.destroy())
88
+
89
+ // What a test records: a real callback rather than a spy — hand `handler` to the code under test.
90
+ const recorder = createRecorder<[path: string]>()
91
+ loader.on('read', recorder.handler)
92
+
93
+ loader.watch(scratch.path)
94
+ await waitForDelay(10) // let a real host timer elapse
95
+
96
+ recorder.count // how many reads arrived
97
+ recorder.calls // the arguments of each, oldest first
98
+
99
+ await teardown.destroy() // gives every owned resource back, newest first
100
+ ```
101
+
102
+ ### Core
103
+
104
+ Imported from `@orkestrel/test`.
105
+
106
+ #### Types
107
+
108
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional
109
+ member and `plus` introducing its call-signature members, and a type alias's own type literal with
110
+ a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members
111
+ it adds after.
112
+
113
+ | Type | Kind | Shape | Summary |
114
+ | -------------------------- | --------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `WaitOptions` | interface | `{ budget?, interval?, signal? }` | Configures a bounded asynchronous wait with an elapsed-time limit, a delay between readings, and an abort signal. |
116
+ | `RetryOptions` | interface | `WaitOptions` plus `{ attempts? }` | Configures a bounded retry, adding an optional producer-call limit to a bounded wait's bounds. |
117
+ | `EventSubscriber` | type | `(listener) => cleanup \| void` | Subscribes a listener to one event source. |
118
+ | `RecorderInterface` | interface | `{ calls, count, handler }` plus `clear` | Records every call made to its handler. |
119
+ | `EventSourceInterface` | interface | `{} plus on` | Subscribes handlers to a typed event source. |
120
+ | `RecorderMap` | type | `{ readonly [K in TName]: RecorderInterface<TMap[K]> }` | Maps event names to recorders for their delivered argument tuples. |
121
+ | `Success` | interface | `{ success, value }` | Represents one operation that produced a value. |
122
+ | `Failure` | interface | `{ success, error }` | Represents one operation that raised a failure instead of producing a value. |
123
+ | `Result` | type | `Success<T> \| Failure<E>` | Represents the outcome of one operation: the value it produced, or the failure it raised. |
124
+ | `SignalInterface` | interface | `{ controller, signal, count }` | Holds a real abort signal and controller instrumented with its live abort-listener tally. |
125
+ | `SignalRegistration` | type | `readonly [listener, installed, capture, cleanup]` | Represents one abort listener an instrumented signal installed, as its tally holds it. |
126
+ | `ResourceFactoryInterface` | interface | `{ created, destroyed }` plus `create` / `destroy` | Represents a numbered resource factory with records of every creation and destruction. |
127
+ | `TeardownInterface` | interface | `{ count }` plus `add` / `destroy` | Represents the cleanup a test adds as it goes and runs once, newest first, when it is done. |
128
+ | `TeardownHandler` | type | `() => void \| Promise<void>` | Represents the work one teardown entry performs when the list is destroyed. |
129
+ | `JSONValue` | type | `string \| number \| boolean \| null \| readonly JSONValue[] \| { readonly [key: string]: JSONValue }` | Covers any value JSON can represent, so a round trip through JSON preserves the type. |
130
+ | `JSONSafe` | type | `JSONSafe<T>` | Represents the JSON-safe projection of a type: every member JSON preserves, mapped to itself, and every member it does not, mapped to `never`. |
131
+ | `HeadersSource` | type | `NonNullable<ConstructorParameters<typeof Headers>[0]>` | Covers any value the host `Headers` constructor accepts. |
132
+ | `StateTransition` | interface | `{ name, from, event, to }` | Represents one row of a statechart table: the entity's state before an event, the event, and the state that event must leave it in. |
133
+ | `StateScenario` | interface | `{ transition }` plus `arrange` / `act` / `assert` | Drives one `StateTransition` through the three phases that prove it. |
134
+
135
+ Each interface's call-signature members are listed under [Methods](#methods). `Result` defaults `E`
136
+ to `Error`, where `@orkestrel/contract` publishes the same name defaulting to `unknown`;
137
+ [Limits](#limits) rules that divergence.
138
+
139
+ #### Constants
140
+
141
+ A `Shape` cell holds the constant's declared type.
142
+
143
+ | API | Kind | Shape | Summary |
144
+ | ----------------------- | ----- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
145
+ | `STATECHART_ATTRIBUTES` | const | `Readonly<Record<'status' \| 'passed' \| 'failed' \| 'total' \| 'scenario' \| 'result' \| 'state', string>>` | Names the attributes a statechart harness publishes, keyed by the fact each one carries. |
146
+ | `STATECHART_STATUSES` | const | `readonly ['pending', 'idle', 'running', 'passed', 'failed']` | Lists every value a statechart harness reports through its `status` attribute. |
147
+
148
+ A harness renders the attributes and a gate outside the page polls them, so the names are the whole
149
+ contract between the two. `status`, `passed`, `failed`, and `total` belong on the harness root,
150
+ `scenario` and `result` on each row, and `state` on the element rendering the entity's current
151
+ state. `pending` is what a harness carries before a run has produced a result for every row, and
152
+ `passed` and `failed` are the pair a gate waits for rather than waiting a fixed duration.
153
+
154
+ #### Validators
155
+
156
+ In a guard table a `Shape` cell holds the type the guard narrows to.
157
+
158
+ | API | Kind | Shape | Summary |
159
+ | ----------------------- | -------- | -------------------------- | ------------------------------------------------------------------ |
160
+ | `isRecorderMapComplete` | function | `RecorderMap<TMap, TName>` | Checks whether a value contains a recorder for every listed event. |
161
+
162
+ `isRecorderMapComplete` takes the events as a second parameter rather than reading them off the
163
+ value, because the listed events are what completeness is measured against. It reads each listed key
164
+ for a `handler` function and a `calls` array, and answers `false` for a value that is not an object,
165
+ for a missing or inherited key, and for a member carrying neither. Per-key tuple precision is the
166
+ claim the narrowing carries rather than something the reading checks, so a caller relying on it
167
+ establishes the pairing between an event and the recorder stored under that event first;
168
+ `createRecorders` establishes it by wiring each recorder to exactly the event it stores that recorder
169
+ under. Every hostile read is contained, so a value whose keys or getters throw answers `false`
170
+ instead of propagating.
171
+
172
+ #### Helpers
173
+
174
+ | API | Kind | Signature | Summary |
175
+ | --------------------- | -------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
176
+ | `waitForCondition` | function | `(description, condition, options?) => Promise<void>` | Waits until a condition holds within an elapsed-time budget. |
177
+ | `retryUntil` | function | `(description, produce, satisfied, options?) => Promise<T>` | Repeats a producer until one produced value satisfies a predicate. |
178
+ | `waitForEvent` | function | `(subscribe, description, options?) => Promise<TArgs>` | Waits for the first delivery from an event subscription. |
179
+ | `checkBounds` | function | `(subject: string, budget: number, interval: number) => void` | Checks the resolved bounds one bounded wait runs under. |
180
+ | `buildRetryExhausted` | function | `(description, budget, elapsed, last, cause) => Error` | Builds the error `retryUntil` raises when its elapsed-time budget runs out. |
181
+ | `dropRegistration` | function | `(registrations: SignalRegistration[], installed) => SignalRegistration \| undefined` | Drops the registration an instrumented signal installed for one listener. |
182
+ | `decodeJSONLines` | function | `(text: string) => readonly unknown[]` | Decodes newline-delimited JSON values. |
183
+ | `waitForDelay` | function | `(ms?: number) => Promise<void>` | Waits for a host timer to elapse. |
184
+ | `waitForAbort` | function | `(signal: AbortSignal) => Promise<void>` | Waits until an abort signal is aborted. |
185
+ | `captureError` | function | `(thunk: () => unknown) => unknown` | Captures the value thrown by a thunk. |
186
+ | `requireValue` | function | `<T>(value: T \| null \| undefined, message?: string) => T` | Narrows a value away from `null` and `undefined`, throwing when it is absent. |
187
+ | `collect` | function | `<T>(source: AsyncIterable<T>) => Promise<readonly T[]>` | Collects every value from an async iterable. |
188
+ | `collectStream` | function | `<T>(stream: ReadableStream<T>) => Promise<readonly T[]>` | Collects every value from a readable stream. |
189
+ | `roundTripJSON` | function | `<T>(value: T & JSONSafe<T>) => T` | Copies a JSON value through serialization and parsing. |
190
+ | `invokeUnchecked` | function | `<T>(target: unknown, method: unknown, args: readonly unknown[]) => T` | Invokes an unknown method through an explicit unchecked result contract. |
191
+ | `readProperty` | function | `<T>(target: unknown, key: PropertyKey) => T` | Reads a property from an unknown object or function. |
192
+ | `flattenHeaders` | function | `(init: HeadersSource) => Readonly<Record<string, string>>` | Normalizes headers into a frozen plain record. |
193
+ | `resolveRoot` | function | `(meta: ImportMeta) => URL` | Resolves the parent directory of a calling module, which is the workspace root when called from the conventional `tests/setup.ts` location. |
194
+ | `executeScenario` | function | `(scenario, context) => Promise<void>` | Drives one statechart scenario through its arrange, act, and assert phases. |
195
+ | `executeScenarios` | function | `(scenarios, build) => Promise<void>` | Drives a statechart table row by row, each row against a context of its own. |
196
+
197
+ `dropRegistration` is the mechanic `createSignal`'s one-shot and scope-abort paths share, exported
198
+ because both of them call it rather than because a consumer was expected to. Reaching it takes a
199
+ `SignalRegistration` list of your own: `createSignal` hands back `SignalInterface`, whose members are
200
+ `controller`, `signal`, and `count`, so nothing this package returns carries the list to pass.
201
+
202
+ #### Factories
203
+
204
+ | API | Kind | Signature | Summary |
205
+ | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
206
+ | `createHostileValues` | function | `() => readonly unknown[]` | Creates values that make common object readers throw or violate their assumptions. |
207
+ | `createRecorder` | function | `<TArgs extends readonly unknown[]>() => RecorderInterface<TArgs>` | Creates a recorder for callback arguments. |
208
+ | `createRecorders` | function | `<TMap, TName>(source: EventSourceInterface<TMap>, events: readonly TName[]) => RecorderMap<TMap, TName>` | Creates event recorders and subscribes them to the source. |
209
+ | `createSignal` | function | `() => SignalInterface` | Creates a real abort controller whose signal reports its live abort listeners. |
210
+ | `createResourceFactory` | function | `() => ResourceFactoryInterface` | Creates a monotonically numbered resource factory with creation and destruction records. |
211
+ | `createTeardown` | function | `() => TeardownInterface` | Creates a teardown list that runs registered handlers newest-first. |
212
+
213
+ ### Browser
214
+
215
+ Imported from `@orkestrel/test/browser`. Every journey verb here resolves its own target from a
216
+ role and an accessible name and drives it through the installed Vitest provider, and none of them
217
+ takes an element, a component instance, or a selector for the thing it acts on. That is what keeps
218
+ a journey a description of what a person does rather than of what the markup happens to be.
219
+
220
+ The fixture builders, the readers, and the field writers do take an element, and none of them is a
221
+ journey verb. `build` creates a node, `mount` attaches one, and `render` does both from
222
+ markup or from a tag and its classes; `clearStorage` takes nothing at all, and `removeDatabase`
223
+ takes a database name. The predicates, the element readers, and the describers name a node the
224
+ caller already has — `isRendered`, `isReachable`, `readText`, `readRole`, `readName`, `readStates`,
225
+ `describeTree`, `describeFocus`, `extractOrphans`, `readRows`, `readStyle`, `readToken`,
226
+ `readPixels`, `readContrast`, `readLayers`, `readBackdrop`, and `readRing` — and each reads that
227
+ node rather than acting on a target it was handed. `captureFrame` and `place` take an element as
228
+ well, and photographing one is a reading too: neither moves focus, dispatches an event, or changes
229
+ what the element renders. `typeInput` and `commitInput` are the exception, and it stays narrow: they
230
+ write into the field they are given, as the synthetic counterpart of `typeAccessible` for a
231
+ component that listens for `input`. The color leaves, the cascade readers, the pane verbs, and the
232
+ whole-document readers take a value or nothing at all, so they name no target either.
233
+
234
+ #### Types
235
+
236
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional
237
+ member and `plus` introducing its call-signature members, and a type alias's own type literal with
238
+ a union's arms escaped as `\|`.
239
+
240
+ | Type | Kind | Shape | Summary |
241
+ | -------------------- | --------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
242
+ | `Color` | type | `readonly [red, green, blue, alpha]` | Represents one rendered color as straight sRGB channels and its alpha. |
243
+ | `ElementOptions` | interface | `{ classes?, text?, attributes? }` | Configures one built element: its class list, its text, and its attributes. |
244
+ | `FrameOptions` | interface | `{ path, width, height, element? }` | Configures one captured frame: where it is written, the viewport it is shot at, and what it shoots. |
245
+ | `FrameReading` | interface | `{ width, height, floor }` | Represents one written frame read back from the file a capture produced: its size in device pixels, and the single color its bottom row paints. |
246
+ | `CaptureVariant` | interface | `{ name, width, height, apply? }` | Represents one theme-and-viewport pair a capture run renders, and the document change it needs first. |
247
+ | `PortfolioOptions` | interface | `{ states, variants, variant, directory, enabled? }` | Configures a capture portfolio: the state registry, the variant matrix, this run's variant, where it writes, and whether it writes at all. |
248
+ | `PortfolioInterface` | interface | `{ variant, placements, paths, files }` plus `place` | Holds the registry of capture states one run places, and the files it wrote placing them. |
249
+ | `JournalStep` | interface | `{ action, trigger, result }` | Represents one scripted step a journal recorded, and what the surface did about it. |
250
+ | `JournalInterface` | interface | `{ steps, output }` plus `start` / `stop` / `record` | Records one scenario: every step it took and everything the page said while it ran. |
251
+
252
+ #### Constants
253
+
254
+ A `Shape` cell holds the constant's declared type.
255
+
256
+ | API | Kind | Shape | Summary |
257
+ | -------------------- | ----- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- |
258
+ | `ACCESSIBLE_ROLES` | const | `readonly string[]` | Names the interactive ARIA roles a bare accessible name is searched across. |
259
+ | `CANVAS_COLOR` | const | `Color` | Names the color a browser paints an unstyled document with: opaque white. |
260
+ | `CAPTURE_PANE` | const | `string` | Names the attribute marking the runner's tester pane, and the rule that sizes it, while a frame is staged. |
261
+ | `CAPTURE_STAGINGS` | const | `number` | Bounds the restagings one capture takes before it refuses a document whose height never settles. |
262
+ | `CONTENT_ROLES` | const | `readonly string[]` | Names the roles whose accessible name is the text a reader can see inside them. |
263
+ | `FIELD_ROLES` | const | `Readonly<Record<string, string>>` | Names the role each `input` type carries. |
264
+ | `FOCUSABLE_SELECTOR` | const | `string` | Names what sequential keyboard navigation can reach, before disabled and unrendered elements go. |
265
+ | `HEADER_ROLES` | const | `Readonly<Record<string, string>>` | Names the role a `th` carries for the header axis its `scope` names. |
266
+ | `IMPLICIT_ROLES` | const | `Readonly<Record<string, string>>` | Names the role each listed tag carries in the accessibility tree when it declares none of its own. |
267
+
268
+ #### Helpers
269
+
270
+ | API | Kind | Signature | Summary |
271
+ | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
272
+ | `resolveAccessible` | function | `(name: string) => HTMLElement` / `(role: string, name: string) => HTMLElement` | Resolves one visible, focus-reachable interactive element by its exact accessible name. A wholly-off-viewport target is scrolled into view before reachability is measured. |
273
+ | `resolveRendered` | function | `(first: string, second?: string) => HTMLElement` | Resolves one rendered, focus-reachable interactive element without requiring it to intersect the viewport yet. |
274
+ | `computeNamePattern` | function | `(name: string) => RegExp` | Computes the pattern that matches one accessible name a decorative glyph may sit beside. |
275
+ | `isOutsideViewport` | function | `(rectangle: DOMRectReadOnly) => boolean` | Determines whether a rectangle lies wholly outside the browser viewport. |
276
+ | `isRendered` | function | `(element: Element) => boolean` | Determines whether the accessibility tree presents one element at all. |
277
+ | `isReachable` | function | `(element: Element) => boolean` | Determines whether a person can click one element where it sits. |
278
+ | `clickAccessible` | function | `(name: string) => Promise<void>` / `(role: string, name: string) => Promise<void>` | Clicks one visible, focus-reachable control by its accessible name through the browser provider. |
279
+ | `clickAccessibleWithin` | function | `(region: string, role: string, name: string) => Promise<void>` | Clicks one human-reachable control by role and accessible-name text inside a named region. |
280
+ | `clickDisclosure` | function | `(name: string) => Promise<void>` | Opens or closes one native details disclosure by its rendered summary. |
281
+ | `typeAccessible` | function | `(name: string, text: string) => Promise<void>` | Replaces a named field's value through focus, select-all, deletion, and real keystrokes. |
282
+ | `fillAccessible` | function | `(name: string, text: string) => Promise<void>` | Replaces a named field's value in one operation, for text too long to type key by key. |
283
+ | `traverseAccessible` | function | `(name: string) => Promise<HTMLElement>` | Reaches a named control only through natural forward Tab traversal from the current focus. |
284
+ | `readPerception` | function | `(name: string) => string` | Reads the normalized visible text of one named region, dialog, table, tab panel, or alert. |
285
+ | `readPage` | function | `() => string` | Reads the normalized visible text of the whole page. |
286
+ | `readFocus` | function | `() => string \| undefined` | Reads the rendered text of the element that holds focus. |
287
+ | `readValue` | function | `(role: string, name: string) => string` | Reads the value a resolved control renders. |
288
+ | `readText` | function | `(element: Element) => string` | Reads one element's rendered text the way a name computation reads it. |
289
+ | `readRole` | function | `(element: Element) => string \| undefined` | Reads the role one element carries in the accessibility tree. |
290
+ | `readName` | function | `(element: Element) => string` | Reads the accessible name one element is announced under. |
291
+ | `readStates` | function | `(element: Element) => readonly string[]` | Reads the states one element is announced in. |
292
+ | `describeTree` | function | `(element: Element) => string` | Describes the accessible tree one rendered element presents. |
293
+ | `describeFocus` | function | `(element: Element) => string` | Describes the order sequential keyboard navigation visits one element's controls in. |
294
+ | `waitForFrame` | function | `() => Promise<void>` | Waits for one animation frame to settle pending browser paint work. |
295
+ | `build` | function | `<K extends keyof HTMLElementTagNameMap>(tag: K, options?: ElementOptions) => HTMLElementTagNameMap[K]` | Builds one unmounted element of a known tag, wearing the classes, text, and attributes asked for. |
296
+ | `mount` | function | `<T extends Element>(element: T) => T` | Puts one element into the document and hands it straight back. |
297
+ | `render` | function | `(markup: string) => HTMLDivElement` / `(tag: K, classes: string) => HTMLElementTagNameMap[K]` | Renders one fixture into the document from trusted markup. |
298
+ | `typeInput` | function | `(element: HTMLInputElement \| HTMLTextAreaElement, text: string) => void` | Sets one field's value and announces it the way typing into the field does. |
299
+ | `commitInput` | function | `(element: HTMLInputElement \| HTMLTextAreaElement, text: string) => void` | Sets one field's value and commits it, the way typing and then leaving the field does. |
300
+ | `clearStorage` | function | `() => void` | Clears both browser storage surfaces. |
301
+ | `removeDatabase` | function | `(name: string) => Promise<void>` | Deletes one IndexedDB database and reports what the request actually did. |
302
+ | `parseColor` | function | `(value: string) => Color \| undefined` | Parses one computed CSS color value into straight sRGB channels. |
303
+ | `parseCSSColor` | function | `(value: string) => Color \| undefined` | Resolves any CSS color expression to straight sRGB channels, by asking the browser. |
304
+ | `matchesColor` | function | `(first: string \| Color, second: string \| Color) => boolean` | Determines whether two colors render the same, within the rounding a browser does. |
305
+ | `blendColor` | function | `(front: Color, back: Color) => Color` | Composites one color over another. |
306
+ | `measureLuminance` | function | `(color: Color) => number` | Measures one opaque color's WCAG relative luminance. |
307
+ | `measureContrast` | function | `(front: Color, back: Color) => number` | Measures the WCAG 2.x contrast ratio between two opaque colors. |
308
+ | `readLayers` | function | `(element: Element) => readonly Color[]` | Collects the painted layers standing between one element and the surface it sits on. |
309
+ | `readBackdrop` | function | `(element: Element, floor: Color) => Color` | Resolves the opaque color standing behind one element. |
310
+ | `readContrast` | function | `(element: Element, floor?: Color) => number` | Measures the WCAG 2.x contrast ratio between an element's computed text and background colors. |
311
+ | `readRing` | function | `(control: Element, worn?: Element) => number \| undefined` | Measures the contrast the focus chrome painted on one control reaches against its own backdrop. |
312
+ | `measureContent` | function | `() => number` | Measures the row the document's own content ends on, in document coordinates. |
313
+ | `stagePane` | function | `(width: number, height: number) => Promise<void>` | Sets the tester's viewport and renders the runner's pane at the size that viewport claims. |
314
+ | `releasePane` | function | `() => Promise<void>` | Hands the tester pane back to the runner's own layout, at the viewport it had before staging. |
315
+ | `captureFrame` | function | `(options: FrameOptions) => Promise<string>` | Shoots one frame at one viewport size and proves the file on disk holds this run's bytes. |
316
+ | `readFrame` | function | `(path: string) => Promise<FrameReading>` | Reads one written frame back and reports its size and the color its bottom row paints. |
317
+ | `readCascade` | function | `() => ReadonlySet<string>` | Collects every class token the stylesheets loaded into this document actually define. |
318
+ | `readClasses` | function | `(root: ParentNode) => ReadonlySet<string>` | Collects every class token the markup under one root carries. |
319
+ | `readRules` | function | `() => readonly CSSRule[]` | Collects every rule the stylesheets loaded into this document hold, nested grouping rules included. |
320
+ | `findRule` | function | `(selector: string) => CSSStyleRule \| undefined` | Finds the first style rule in the cascade whose selector carries a fragment. |
321
+ | `findKeyframes` | function | `(name: string) => CSSKeyframesRule \| undefined` | Finds the animation the cascade declares under one name. |
322
+ | `readRows` | function | `(root: ParentNode, selector: string) => readonly string[]` | Reads the normalized visible text of every element a selector matches, in document order. |
323
+ | `extractOrphans` | function | `(root: ParentNode, child: string, parent: string) => readonly string[]` | Collects every element carrying a component class rendered outside the container it belongs to. |
324
+ | `extractStyles` | function | `(root: ParentNode) => readonly string[]` | Collects the markup of every element carrying a non-empty `style` attribute and of every `<style>` element, in document order, `root` included in both populations when it is an `Element`. |
325
+ | `readStyle` | function | `(element: Element, property: string) => string` | Reads one resolved CSS property from a real browser element. |
326
+ | `readToken` | function | `(element: Element, name: string) => string` | Reads one custom property from an element's resolved style. |
327
+ | `readRootToken` | function | `(name: string) => string` | Reads one custom property from the document element. |
328
+ | `readPixels` | function | `(element: Element, property: string) => number` | Reads one resolved CSS length as a number of pixels. |
329
+ | `expandCaptures` | function | `(states: readonly string[], variants: readonly CaptureVariant[]) => readonly string[]` | Expands a capture registry across every variant into the filenames a complete portfolio holds. |
330
+
331
+ #### Factories
332
+
333
+ | API | Kind | Signature | Summary |
334
+ | -------------------- | -------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
335
+ | `createPointerEvent` | function | `(name: string, options?: PointerEventInit) => PointerEvent` | Creates one real pointer event, ready to dispatch. |
336
+ | `createDragEvent` | function | `(name: string, options?: DragEventInit) => DragEvent` | Creates one real drag event carrying a live data transfer, ready to dispatch. |
337
+ | `createPortfolio` | function | `(options: PortfolioOptions) => PortfolioInterface` | Creates the capture portfolio one run places its screenshots through. |
338
+ | `createChannel` | function | `(name: string, output: string[], forward: (...data: unknown[]) => void) => (...data: unknown[]) => void` | Creates one console channel that records every call it receives and hands that call on unchanged. |
339
+ | `createJournal` | function | `() => JournalInterface` | Creates the journal one scenario records its steps and the page's own output into. |
340
+
341
+ `resolveAccessible` counts a match as reachable only when every condition holds: it is connected; it
342
+ passes a visibility check honouring opacity and CSS; its box has non-zero width and height; its
343
+ `tabIndex` is at least zero; it matches neither `:disabled` nor `[aria-disabled="true"]`; and it has
344
+ no `[inert]` ancestor. A wholly off-viewport match is scrolled into view once and measured again, so
345
+ a control a person can scroll to is reachable and one that stays outside is not. The bare-name form
346
+ searches `ACCESSIBLE_ROLES`; the two-argument form searches exactly the role it is given, which is
347
+ how a name a tab shares with its own panel is disambiguated.
348
+
349
+ `resolveRendered` applies the same conditions and skips the viewport requirement. It is what every
350
+ acting verb resolves through, so a click does not fail on a target the act itself scrolls into view.
351
+ It is exported because a journey that needs a target before it is on screen needs the same rule
352
+ rather than a second reading of it.
353
+
354
+ `resolveRendered` runs two passes, and only the first one can return an element. The visible pass
355
+ asks the role engine for the exact name over the elements the accessibility tree presents, so the
356
+ name it matches is the one a screen reader announces: an `aria-hidden` icon beside the text
357
+ contributes nothing to it, and a control captioned by a glyph resolves under the words a person
358
+ reads. Every element the resolver returns, refuses as unreachable, or reports as ambiguous comes
359
+ from that pass. The hidden pass runs only when the visible pass found nothing at all, and it decides
360
+ which refusal the caller hears: `No interactive element has the accessible name "X"` when the page
361
+ carries the name nowhere, `Interactive target "X" is not visible and focus-reachable` when a folded
362
+ control carries it. Seeing a folded control means including hidden elements, which puts the glyph
363
+ back into the computed name, so that pass matches `computeNamePattern` rather than the exact string.
364
+
365
+ `computeNamePattern` anchors the name at both ends and admits a run of characters that are neither
366
+ letters nor digits before it and after it. That is what a glyph is, so the pattern separates
367
+ `Add building` beside an icon from `Add`, and the exact contract holds in the hidden pass as it does
368
+ in the visible one. Its tolerance has two edges, and both cost a refusal voice rather than an
369
+ element: a hidden icon whose own content is a word defeats the pattern, and a name differing from
370
+ the requested one by punctuation alone satisfies it.
371
+
372
+ `readPerception` runs one pass, because absence and concealment share its refusal. That pass asks
373
+ over the presented elements too, so a region labelled by a heading that carries a glyph is read
374
+ under the heading's words, and a region the tree does not present is refused as not visible.
375
+
376
+ `clickAccessibleWithin` matches the region's name exactly and the control's name loosely. That
377
+ combination is what a person does with a repeated short verb such as `Add`, or with a line whose
378
+ rendered status completes its accessible name: the region supplies the context, and the name only
379
+ has to be recognisable inside it. The loose match reads a computed name that includes the hidden
380
+ subtrees, so a glyph joins the text rather than displacing it and the control is still recognisable
381
+ under the words beside it. That verb owns one refusal for a control it cannot reach, absent or
382
+ hidden, so it needs no second pass to tell the two apart.
383
+
384
+ `traverseAccessible` charges a step only when focus actually lands on an element, ends when focus
385
+ revisits one — that is a complete cycle of the tab order — and re-resolves the target by name on
386
+ every step, because a framework may replace the node between resolution and focus arrival. Its hard
387
+ cap is three times the page's candidate count plus ten, including disabled controls and elements
388
+ with `tabindex="-1"`, so a page whose focus never settles fails instead of hanging.
389
+
390
+ `build` and `mount` are the halves of a fixture, and `render` is the pair spelled as one call.
391
+ `build` creates the element and applies its class list, its text, and its attributes, and leaves it
392
+ out of the document, so nothing resolves against the cascade and no box is laid out until the
393
+ element is attached. Its text is set as text rather than parsed as markup, so a `<` in it stays a
394
+ `<`. `mount` attaches an element and hands that same element back, which is what makes a computed
395
+ style, an inherited custom property, and a real box available. `render` takes trusted fixture markup
396
+ and returns the attached container holding it, or takes a tag and its class list and returns the
397
+ attached element itself, typed as exactly that tag. The class list is required in the tag form,
398
+ which is what keeps the forms apart: a one-argument call is always markup.
399
+
400
+ None of them records anything. A browser test file shares one page, so a fixture left behind is the
401
+ next test's resolver ambiguity, and removal belongs to the consumer's teardown: build the container
402
+ in a setup module, register its removal on a `createTeardown` list or in an `afterEach` hook, and
403
+ mount every fixture inside it.
404
+
405
+ `typeInput` and `commitInput` write into a field the test already holds. `typeInput` sets the value
406
+ in one write and dispatches one bubbling `input` event, with the value already set by the time a
407
+ listener reads it. `commitInput` does that and then dispatches one bubbling `change`, which is the
408
+ order a browser produces when a person types and then leaves the field. Each dispatched event is a
409
+ plain `Event`, never an `InputEvent`, so a component reading `inputType` or testing
410
+ `instanceof InputEvent` reads neither off them. Neither sends a keystroke either, so a component
411
+ reading `key`, composition, or selection receives nothing from them — drive that one through
412
+ `typeAccessible` instead.
413
+
414
+ `removeDatabase` deletes one IndexedDB database and reports what the request did. Deleting a
415
+ database that was never created succeeds, so an `afterEach` hook calls it whether or not the test
416
+ reached the code that opens one. A block is a rejection rather than a wait: `blocked` fires while
417
+ another connection is still open, and a suite that swallowed it would leave the next test reading
418
+ the previous test's records through a database that reports itself deleted. The connection holding
419
+ it open is the caller's to close, and [Voices](#voices) carries the message each refusal spells.
420
+
421
+ `parseCSSColor` is the live half of the pair `parseColor` opens. `parseColor` reads text and speaks
422
+ only the computed syntaxes a cascade hands back; `parseCSSColor` stages a probe element, hands the
423
+ expression to the real cascade, and reads back what the engine computed — which is the only way a
424
+ keyword, a hex triple, a `var()` reference, or a `color-mix()` becomes channels at all. The probe is
425
+ mounted, so a `var()` reference resolves against the tokens `:root` declares, and it is removed in a
426
+ `finally`. Refusal is the CSSOM's: an expression it will not parse returns `undefined`. A `var()`
427
+ naming an undeclared custom property is not refused, and [Limits](#limits) states what that costs.
428
+
429
+ `matchesColor` compares two colors as a browser renders them. Each string side resolves through
430
+ `parseCSSColor`, so a keyword, a token reference, and the `rgb()` an engine computes for either
431
+ compare equal without a test converting anything first. The tolerance is half a channel step on the
432
+ 0–255 scale, and the alpha is scaled onto that same range before it is compared, so one number
433
+ covers every channel. A side that resolves to nothing makes the answer `false` rather than a throw,
434
+ because this is a predicate.
435
+
436
+ `readContrast` resolves a transparent or translucent background through the element's ancestors:
437
+ every painted layer from the element up to the first opaque one composites top-over-bottom onto that
438
+ opaque base, so a 3% surface tint reads as a tint over what shows through it rather than as a
439
+ full-strength paint. A translucent foreground then resolves against that effective background before
440
+ luminance is measured. With `floor` omitted it refuses every stack whose walk reaches no fully
441
+ opaque layer, rather than assuming a white canvas. Supply a floor — `CANVAS_COLOR` for a document,
442
+ or the color a fragment is really mounted onto — and the same stack composites onto it instead of
443
+ being refused. A detached element is refused either way, because its computed foreground color does
444
+ not exist.
445
+
446
+ `readLayers` is the reading that refusal turns on. It hands back the painted layers themselves,
447
+ element first and the deepest last, so a walk that ended on a real surface is the one whose last
448
+ layer has an alpha of `1`. A composited color cannot answer that question: 64 half-transparent
449
+ layers blend to identical channels over black and over white alike, because the floor's remaining
450
+ share falls below the last bit a channel carries, so comparing two composited readings admits the
451
+ stack the refusal exists for.
452
+
453
+ `readBackdrop` composites that stack and takes its floor as an argument rather than reaching for
454
+ `CANVAS_COLOR` itself, so a measurement over a surface the canvas never shows through names the
455
+ color it actually sits on. When no layer paints it hands that floor straight back.
456
+
457
+ `readRing` reads and never acts. Focus arrives through the published verbs, and this measures what
458
+ the browser painted once it landed: the `outline` the cascade declares, and the first color in a
459
+ `box-shadow`. A control that is not matching `:focus-visible`, a control left the browser's own
460
+ `outline-style: auto` ring, and a focus style that only changes the control's own fill all report
461
+ `undefined` — in each case no measurement taken here would be about focus. `worn` names the element
462
+ the chrome is painted onto when that is not the element holding focus, which is the hidden-input
463
+ control whose visible label wears every pixel of its chrome.
464
+
465
+ `readRules` is the one walk over the shipped cascade, and `readCascade`, `findRule`, and
466
+ `findKeyframes` all read through it. It collects each sheet's own rules in sheet order and then
467
+ expands the grouping rules level by level, so a media query, a supports block, a layer, and a nested
468
+ style rule all surface, and a top-level rule is always met before a rule nested inside an earlier
469
+ one. The descent reaches a grouping rule and nothing else, and a `@keyframes` rule is not one: the
470
+ `@keyframes` rule itself is collected wherever it sits and the keyframe rules inside it are not, so
471
+ `findKeyframes` is the door to those. A stylesheet the document cannot read — a cross-origin sheet
472
+ with no CORS grant — throws from its own `cssRules` getter, and that sheet is skipped rather than
473
+ ending the walk.
474
+
475
+ `readCascade` reports the tokens of that same walk, and both its membership and its order are
476
+ deliberate differences from 0.0.8. A class declared inside a grouping rule counts as defined,
477
+ because a class the cascade defines under a condition is still one the cascade defines; 0.0.8 read
478
+ the top-level rules alone. Insertion order is breadth-first, so a top-level class lands before a
479
+ class declared inside an earlier grouping rule; 0.0.8 popped a stack and inserted the deepest rule
480
+ first. Iterate the set where the order is the subject and read `has` where membership is. A
481
+ `@keyframes` rule's own children are outside the walk, so an animation's stops define no token here.
482
+
483
+ `findRule` and `findKeyframes` differ in how they match, and the subject is what decides it. A
484
+ selector is compound, so `findRule` matches its argument as a substring of the whole selector text:
485
+ `findRule('.card')` finds `.card`, `.card:hover`, and `.panel > .card` alike, and more of the
486
+ selector narrows it. An animation name is one atom, so `findKeyframes` matches it exactly. Each
487
+ answers what a stylesheet declares rather than what an element resolves to, and a rule either one
488
+ finds may be overridden by another — assert on `readStyle` where the rendered result is the subject.
489
+
490
+ `readToken`, `readRootToken`, and `readPixels` are `readStyle` with the question narrowed.
491
+ `readToken` reads a custom property and accepts the name with or without its leading dashes, because
492
+ a token is spoken about both ways — `--surface` in a stylesheet and `surface` in prose. An absent
493
+ token reads as `''`, which is what the CSSOM returns and is indistinguishable from a token declared
494
+ empty, so assert on the value you expect rather than on presence. Resolution is inheritance: a token
495
+ declared on `:root` reads from any mounted descendant, and from an unmounted element it reads as
496
+ `''`. `readRootToken` is that reading taken against `document.documentElement`, which is where a
497
+ theme declares its tokens and where a `[data-theme]` switch retunes them. `readPixels` reads the
498
+ leading number of a resolved length and answers `0` for a value carrying none, because `'auto'`,
499
+ `'none'`, and `''` each contribute no pixels to what a reader sees; read the text with `readStyle`
500
+ where that distinction matters.
501
+
502
+ `stagePane` unscales the runner's tester and lifts it to the window's origin, because a frame shot
503
+ through the runner's fitting scale is a thumbnail of the surface. That couples it to Vitest's own
504
+ tester layout, which is contract rather than accident: `vitest@4.1.11` is the version behind the
505
+ `iframe[data-vitest]` selector and the `--tester-transform`, `--tester-margin-left`,
506
+ `--viewport-width`, and `--viewport-height` custom properties it writes. A release that renames any
507
+ of them reddens the size check rather than writing a wrong frame. Always hand the pane back with
508
+ `releasePane`: a tester left pinned at a viewport taller than the window puts its lower half beyond
509
+ what a pointer can reach, so the next ordinary press fails as a control that is covered. The release
510
+ also resizes the tester to the viewport it had before the staging, which it reads off the rule
511
+ element it removes, so the capture's variant size belongs to the frame rather than to every test
512
+ that runs after it.
513
+
514
+ That hand-back is why the pair is a capture's staging and not a resize. Call `page.viewport` from
515
+ `vitest/browser` where a journey needs its own size — a breakpoint to drive, a variant to act at —
516
+ and leave the tester there. A suite that stages and releases instead resizes the tester and then
517
+ undoes the resize, so its next step runs at the size the file started at. This package publishes no
518
+ verb for that, because `page.viewport` already is one.
519
+
520
+ `captureFrame` stages, shoots, and proves the file. The path a screenshot call returns is the path
521
+ it meant to write, so `captureFrame` reads that file back through the runner's built-in `readFile`
522
+ command and compares it with the shot itself, which is what separates this run's frame from one an
523
+ earlier run left behind. It releases the pane in a `finally`, so a refusal at any stage hands the
524
+ tester back before it propagates.
525
+
526
+ The frame covers the whole document at the width it was given, whatever height it was given. The
527
+ provider shoots the tester's body in the top-level page's own coordinates, so a document taller than
528
+ the pane paints for the pane's height and the rows under it are the runner's page: the frame reads
529
+ as the surface down to the fold and as bare canvas after it. `captureFrame` therefore lays the
530
+ document out at the declared viewport, measures the height the shot needs, and stages the pane again
531
+ at that height for the shot alone.
532
+
533
+ The measurement is `measureContent`, floored at the declared height because the declared viewport is
534
+ the smallest frame a variant asks for. Read the content's edge rather than the body's box: the box
535
+ is the larger of the content and the pane, so every pane staged over the document stretches it and
536
+ reads back as the document's own height. A capture that staged a pane taller than the document could
537
+ not descend from a reading like that — the box, `body.scrollHeight`, `body.offsetHeight`, and
538
+ `documentElement.scrollHeight` each answer with the pane. `measureContent` walks the elements inside
539
+ the body instead, taking the largest bottom edge in document coordinates plus that element's own
540
+ bottom margin, and adds the body's and the root's bottom padding and margin under them. It rounds
541
+ up, which is what covers a body ending part way through a row: a box ending on a fraction under a
542
+ half is a row the integer scroll height drops, and that row comes out as the runner's page.
543
+
544
+ The edge is read again after every staging, because a rule bound to the viewport height — a `vh`
545
+ length, a fixed footer, a full-height panel — lays the document out taller against the taller pane,
546
+ so a surface built out of those photographs as its scrolled-open self rather than as one screen, and
547
+ the reading taken before that staging is stale by exactly what the reflow added. Restaging at the
548
+ edge alone converges on such a document without arriving: a rule that keeps half the pane reads
549
+ 1322, 1561, 1681, and 1741 against a fixed point of 1800, halving what is left each time. Each
550
+ staging
551
+ therefore carries the growth the one before it produced, staging at the edge plus that growth, which
552
+ lands on the fixed point instead of creeping toward it: 1322, then 1800. The first staging carries
553
+ no growth, because nothing has grown yet, so a document of fixed content is staged at its own edge
554
+ and shot there — a 1600-row document reads back as a 1600-row frame rather than as one an overshoot
555
+ stretched.
556
+
557
+ The re-reading stops when the pane and the edge agree, which is the pane the shot is taken at. A
558
+ rule that adds height with every pane never reaches that point, so the re-reading is bounded by
559
+ `CAPTURE_STAGINGS` and the shot is refused with
560
+ `Capture frame at <path> never settled after <n> restagings: <h> over a <h> pane` rather than
561
+ written at a height that is already wrong. That bound is 4: a document holding half the pane plus a
562
+ fixed block settles in two restagings, one whose growth is capped part way settles in three, and
563
+ the fourth is headroom.
564
+
565
+ `readFrame` reads a written frame back: its size in device pixels, and the single color its bottom
566
+ row paints. The reading comes off the file through the browser's own image decoding rather than off
567
+ the document that produced it, which is what makes it evidence about the capture rather than a
568
+ second look at the style that fed it — a clipped frame reports the runner's white canvas as its
569
+ floor while every style in the document still resolves to the document's own background. Pass the
570
+ absolute path `captureFrame` returned: the runner's `readFile` command resolves a relative path
571
+ against its own root rather than against the calling test file.
572
+
573
+ `createPortfolio` refuses an unregistered variant name at creation, so a run cannot write a filename
574
+ naming a combination it did not render. A portfolio left un-`enabled` is the ordinary run: `place`
575
+ resizes nothing, writes nothing, and records nothing, so a journey calls it unconditionally. An
576
+ enabled `place` applies the variant and writes `<directory>/<state>--<variant>.png` through
577
+ `captureFrame`, so it stages the pane at the variant's size, covers the whole document, verifies the
578
+ written bytes, and records only a path that read back as this run's own frame. The tester comes back
579
+ at the viewport it had before the placement, so a journey that places a state carries on at its own
580
+ size rather than at the variant's. `placements` and `paths` hand out snapshots, so a list read
581
+ before a placement stays what it was.
582
+
583
+ `createJournal` records rather than replaces. Every intercepted console call is forwarded to the
584
+ channel that was there when the journal started, so a run under a journal prints exactly what it
585
+ prints without one, and `stop` puts those same function references back by identity. `start` clears
586
+ both lists whether or not the journal was already recording, so a restart never stacks one wrapper
587
+ on another. Uncaught errors and unhandled rejections are recorded too, through listeners the journal
588
+ drops when it stops. There is no shared instance: a file that needs one journal per scenario creates
589
+ one per scenario.
590
+
591
+ The filename law is injective within one run: one variant is selected, and every filename is
592
+ `<state>--<variant>.png`. A duplicate filename therefore implies a duplicate placement, which
593
+ `place` already refuses. Any future naming change that breaks this injectivity must reintroduce a
594
+ collision refusal before writing.
595
+
596
+ ### Server
597
+
598
+ Imported from `@orkestrel/test/server`.
599
+
600
+ #### Types
601
+
602
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional
603
+ member and `plus` introducing its call-signature members, and a type alias's own type literal with
604
+ a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members
605
+ it adds after.
606
+
607
+ | Type | Kind | Shape | Summary |
608
+ | -------------------- | --------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
609
+ | `ScratchInterface` | interface | `{ path }` plus `write` / `read` / `has` / `names` / `ensure` / `link` / `remove` / `destroy` | Holds a temporary directory a test owns, writes into, reads back, and removes when it is done. |
610
+ | `ScratchIdentity` | interface | `{ device, inode, birth }` | Represents the fields that together identify one allocated directory on its host. |
611
+ | `ScratchOptions` | interface | `{ parent?, prefix?, files? }` | Configures a scratch directory allocation. |
612
+ | `LoopbackInterface` | interface | `{ url, port }` plus `destroy` | Holds a server a test owns, listening on an ephemeral loopback port until the test releases it. |
613
+ | `CookieJarInterface` | interface | `{ header }` plus `read` / `capture` | Holds a name-keyed cookie store a test drives one origin with, filled from real responses. |
614
+ | `InventoryOptions` | interface | `{ extensions?, exclude? }` | Configures a source inventory read. |
615
+ | `UpgradeOptions` | interface | `WaitOptions` plus `{ path?, protocols? }` | Configures a client upgrade request. |
616
+ | `UpgradeResult` | type | `{ claimed, protocol } \| { claimed, status }` | Represents what one server did with a client upgrade request. |
617
+
618
+ #### Constants
619
+
620
+ A `Shape` cell holds the constant's declared type.
621
+
622
+ | API | Kind | Shape | Summary |
623
+ | ----------------------------- | ----- | ------------------- | ---------------------------------------------------------------------------------- |
624
+ | `REMOVE_TREE_MAX_ATTEMPTS` | const | `number` | Caps the attempts `removeTree` makes before rethrowing a retryable removal error. |
625
+ | `REMOVE_TREE_RETRY_DELAY_MS` | const | `number` | Names the synchronous delay, in milliseconds, `removeTree` waits between attempts. |
626
+ | `REMOVE_TREE_RETRYABLE_CODES` | const | `readonly string[]` | Names the error codes `removeTree` retries; every other code rethrows immediately. |
627
+
628
+ #### Helpers
629
+
630
+ | API | Kind | Signature | Summary |
631
+ | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
632
+ | `readInventory` | function | `(root: URL \| string, targets: readonly string[], options?: InventoryOptions) => Readonly<Record<string, string>>` | Reads files from selected targets below a root directory. |
633
+ | `resolveContained` | function | `(root: string, target: string) => string \| undefined` | Resolves a target that stays below a root directory. |
634
+ | `requireContained` | function | `(root: string, target: string) => string` | Resolves a target that stays below a root directory, refusing an escape. |
635
+ | `isExcluded` | function | `(key: string, exclusions: readonly string[]) => boolean` | Reports whether a root-relative key matches an exclusion. |
636
+ | `readIdentity` | function | `(status: Stats) => ScratchIdentity` | Reads the identity of one allocated directory off a host status. |
637
+ | `matchesIdentity` | function | `(current: ScratchIdentity, allocation: ScratchIdentity) => boolean` | Reports whether two directory identities name the same allocation. |
638
+ | `readErrorCode` | function | `(error: unknown) => string \| undefined` | Reads the `code` an unknown thrown value carries. |
639
+ | `createLink` | function | `(path: string, source: string) => void` | Creates a symbolic link with a directory-junction fallback for hosts that refuse symbolic links. |
640
+ | `removeTree` | function | `(path: string) => void` | Removes a directory tree, retrying past a transient Windows handle-release race. |
641
+ | `isRunning` | function | `(pid: number) => boolean` | Reports whether a process id names a live process. |
642
+ | `waitForSocketClose` | function | `(socket: Socket, options?: WaitOptions) => Promise<void>` | Waits for a socket to close, accepting a peer reset as a forced close. |
643
+ | `destroyScratch` | function | `(scratch: ScratchInterface, options?: WaitOptions) => Promise<void>` | Destroys a scratch directory, retrying until the host releases it. |
644
+ | `requestUpgrade` | function | `(port: number, options?: UpgradeOptions) => Promise<UpgradeResult>` | Drives a real client upgrade request against a loopback port and reports what the server did. |
645
+ | `supportsDirectoryLinks` | function | `() => boolean` | Checks whether this host links a directory, by creating one link and reading through it. |
646
+ | `supportsFileLinks` | function | `() => boolean` | Checks whether this host links a file, by creating one link and reading the file through it. |
647
+ | `supportsMode` | function | `() => boolean` | Checks whether POSIX permission bits round-trip through this host's `chmod` and `stat`. |
648
+ | `supportsCase` | function | `() => boolean` | Checks whether this host treats two names differing only by case as distinct files. |
649
+ | `supportsBytes` | function | `() => boolean` | Checks whether this host accepts a filename carrying a raw byte no UTF-8 decoder resolves. |
650
+
651
+ `resolveContained` is the one lexical containment check, and `readInventory` and `createScratch`
652
+ both call it. It resolves the target against the root — relative or absolute — and returns
653
+ `undefined` when the result is not below it. An absolute target inside the root resolves, so a
654
+ caller hands it the path it already has rather than making it root-relative first. It is exported
655
+ because a consumer writing its own filesystem fixture needs the same check and would otherwise write
656
+ another copy of it.
657
+
658
+ `@orkestrel/scaffold` publishes `resolveContainedPath`, one word away, and the two are not the same
659
+ predicate. This one is lexical only and dependency-free. That one is lexical plus physical — it also
660
+ refuses a link that leaves the root and a dangling link whose raw target contains a `..` segment —
661
+ and it lives in a build tool. A test helper with zero runtime dependencies does not take a runtime
662
+ dependency on the scaffolding tool to obtain a path predicate. If that difference ever stops holding,
663
+ delete `resolveContained` and import `resolveContainedPath` from `@orkestrel/scaffold`, which this
664
+ package already carries as a `devDependency`, rather than adding a third variant.
665
+
666
+ `requireContained` is that same resolution with the refusal every contained scratch operation makes
667
+ of an escape: it throws `Path outside scratch directory: <target>` where `resolveContained` answers
668
+ `undefined`. Every `ScratchInterface` member and the `files` seeding pass through it, so the check
669
+ and its one message are stated once rather than at each member. Read `resolveContained` where an
670
+ escape is an answer the caller handles rather than a refusal it wants raised.
671
+
672
+ `isExcluded` is the exclusion rule itself, and `readInventory` applies it to a named target and a
673
+ walked entry alike. An exclusion matches whole segments of a root-relative key, so it drops the key
674
+ it names and every key below it, and it leaves a sibling whose name merely starts the same way. It
675
+ takes exclusions already normalized: `readInventory` normalizes the spellings its `exclude` option
676
+ accepts before calling it, so a caller applying the rule to its own keys normalizes its own list. It
677
+ is exported because a consumer walking its own tree wants that rule rather than a second reading of
678
+ what `exclude` means.
679
+
680
+ `matchesIdentity` is the comparison `destroy()` makes before it removes anything: whether the
681
+ identity read from the allocated path now is the identity recorded when the directory was allocated.
682
+ All three fields are compared because none of them alone identifies an allocation. A device is
683
+ shared by every directory on one filesystem, an index node is reused once its directory is removed,
684
+ and a creation time repeats within the host's timestamp resolution. It is exported so a fixture that
685
+ manages its own directory can make the same check rather than trusting a path. `readIdentity` reads
686
+ that triple off a `node:fs` `Stats`, and it is the one reading `createScratch` takes at allocation,
687
+ before a `remove`, and before a `destroy`, so the three sites cannot drift on which fields name an
688
+ allocation.
689
+
690
+ `readErrorCode` reads the `code` off an unknown thrown value, which is what a host refusal is at a
691
+ `catch`. It answers `undefined` for a value that is not an object, one carrying no `code`, and one
692
+ carrying a `code` that is not a string, so a caller compares against the code it cares about rather
693
+ than narrowing an unknown first. `createLink` and `removeTree` both classify their refusals through
694
+ it, and it is exported because a fixture catching its own host refusal wants the same contained read.
695
+
696
+ `waitForSocketClose` and `destroyScratch` are the bounded waits on this entry, and both take the
697
+ core entry's `WaitOptions`. Import that type from `@orkestrel/test` beside the helpers themselves
698
+ from `@orkestrel/test/server`: the shape has one home, and this entry names it in a signature rather
699
+ than re-exporting it.
700
+
701
+ `createLink` is the link mechanism `ScratchInterface.link` calls, and
702
+ [Hosts that create no symbolic link](#hosts-that-create-no-symbolic-link) states what it does. Its
703
+ `path` parameter is where the link is created and its `source` parameter is the destination that
704
+ link points at, which is `link`'s vocabulary rather than `node:fs`'s. It is exported because a
705
+ fixture creating its own links wants the same host handling rather than a second reading of it.
706
+
707
+ `requestUpgrade` drives one real client upgrade request against a loopback port and reports what the
708
+ server did. The request carries `Connection: Upgrade` and `Upgrade: websocket`, which is what routes
709
+ it to a server's `upgrade` handler, and the offered subprotocols travel as one comma-separated
710
+ `Sec-WebSocket-Protocol` field. `claimed` is the discriminant, and each arm carries only what its own
711
+ path produced. The claimed arm carries `protocol`, the field the server sent, so `undefined` there
712
+ says the server selected none rather than that it refused; it carries no status, because a claimed
713
+ upgrade produced no plain answer and the `101` on the wire is deliberately not reported as one. The
714
+ refused arm carries `status`, the plain answer's status, and no subprotocol. Reading `status` off an
715
+ unnarrowed result is a compile error rather than an `undefined`, so a test names the arm it expects
716
+ before it reads the detail.
717
+
718
+ The wait is bounded, because a server that accepts the connection and answers nothing raises no
719
+ transport error. `UpgradeOptions` extends `WaitOptions`, the budget defaults to `1000` milliseconds,
720
+ and the rejection names the port and path the call was waiting on. The interval is validated for
721
+ consistency with the wait family and is not used, because this helper parks on the request's events
722
+ rather than reading for an answer. A bound that is not finite and non-negative is refused before the
723
+ request is made, and an already-aborted signal is refused there too. The promise settles once, on
724
+ whichever of `upgrade`, `response`, `error`, the budget, and the abort arrives first, and a transport
725
+ error — the `ECONNREFUSED` a closed port answers — is the rejection.
726
+
727
+ The client socket is destroyed on every settlement path, the budget's and the abort's included, and
728
+ the request is made with no agent, so no pooled connection outlives the call to keep a suite's event
729
+ loop alive. The socket the server keeps is the fixture's own: [Limits](#limits) states why
730
+ `createLoopback` cannot take it back.
731
+
732
+ The capability probes read this host rather than branching on `process.platform`.
733
+ `supportsDirectoryLinks`, `supportsFileLinks`, `supportsMode`, `supportsCase`, and `supportsBytes`
734
+ each allocate a directory under the host temporary directory, attempt the operation, read the result
735
+ back, and remove the allocation in a `finally`. Each reads a host refusal as `false` rather than
736
+ throwing, so the answer is a fact about this run rather than an error to handle, and each probes
737
+ afresh on every call rather than remembering an answer a host can change. Gate a proof on the probe
738
+ that names the mechanism it needs: an unprivileged Windows host answers `supportsDirectoryLinks`
739
+ `true` through a junction while answering `supportsFileLinks` `false`, so a fixture that reads a
740
+ file through a link asks the second question rather than the first. `supportsMode` answers whether a
741
+ permission bit is stored, which is narrower than whether it is enforced — a POSIX host running as
742
+ uid `0` stores every bit faithfully and bypasses the access check those bits describe, so a proof
743
+ that needs a refusal probes that refusal itself.
744
+
745
+ #### Factories
746
+
747
+ | API | Kind | Signature | Summary |
748
+ | ----------------- | -------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
749
+ | `createScratch` | function | `(options?: ScratchOptions) => ScratchInterface` | Allocates an owned temporary directory with contained file operations. |
750
+ | `createLoopback` | function | `(server: Server) => Promise<LoopbackInterface>` | Starts a server on an ephemeral IPv4 loopback port. |
751
+ | `createCookieJar` | function | `() => CookieJarInterface` | Creates a cookie jar that records a real response's cookies and replays them as one header. |
752
+
753
+ A refused `ScratchOptions` key leaves nothing behind, by two different mechanisms. `parent` and
754
+ `prefix` are checked before `mkdtempSync` runs, so a refused value allocates nothing. `files` is
755
+ seeded after the directory exists, so a refused key removes the directory that was recently made and
756
+ rethrows.
757
+
758
+ `parent` is the existing directory the allocation is created in, and defaults to the host temporary
759
+ directory; allocation throws when it is missing, is a symbolic link, or is not a directory. `prefix`
760
+ starts the generated directory name, and defaults to `orkestrel-test-`; allocation throws when it
761
+ contains `/` or `\`, which is what stops a prefix steering the allocation out of its parent. Nothing
762
+ else is refused: a fragment carrying no separator is one path segment, so `release-0..2-` allocates.
763
+ `files` seeds files on allocation, keyed by path below the scratch directory; allocation removes the
764
+ directory it recently made and rethrows when a key escapes or the host refuses a write.
765
+
766
+ `createLoopback` takes a `node:net` `Server` — `node:http`'s and `node:https`'s both extend it — and
767
+ never constructs one. It listens on port `0` at `127.0.0.1`, waits for the `listening` event, and
768
+ reads the assigned port off `address()`, throwing
769
+ `Loopback address must have a numeric port; found <address>` when that address carries none. The
770
+ `createLoopback` contract states what `destroy()` drops, what `url` does and does not spell, and why
771
+ this package never reserves a port number.
772
+
773
+ ## Methods
774
+
775
+ The call-signature members of each behavioral interface. Their `readonly` data members stay in the
776
+ earlier [Surface](#surface) rows.
777
+
778
+ #### `RecorderInterface`
779
+
780
+ | Method | Returns | Summary |
781
+ | ------- | ------- | ---------------------------------------------------------- |
782
+ | `clear` | `void` | Discards the recorded calls and keeps the recorder usable. |
783
+
784
+ #### `EventSourceInterface`
785
+
786
+ | Method | Returns | Summary |
787
+ | ------ | ------- | --------------------------------- |
788
+ | `on` | `void` | Subscribes a handler to an event. |
789
+
790
+ #### `ResourceFactoryInterface`
791
+
792
+ | Method | Returns | Summary |
793
+ | --------- | -------- | ----------------------------- |
794
+ | `create` | `number` | Creates a numbered resource. |
795
+ | `destroy` | `void` | Destroys a numbered resource. |
796
+
797
+ #### `TeardownInterface`
798
+
799
+ | Method | Returns | Summary |
800
+ | --------- | --------------- | --------------------------------------------------------------------------------------------------------- |
801
+ | `add` | `void` | Registers a handler to run when the list is destroyed. |
802
+ | `destroy` | `Promise<void>` | Runs every registered handler in reverse registration order, awaiting each in turn, and empties the list. |
803
+
804
+ #### `StateScenario`
805
+
806
+ | Method | Returns | Summary |
807
+ | --------- | ----------------------- | ----------------------------------------------------------- |
808
+ | `arrange` | `Promise<void> \| void` | Puts the entity into the transition's `from` state. |
809
+ | `act` | `Promise<void> \| void` | Applies the transition's event to the arranged entity. |
810
+ | `assert` | `Promise<void> \| void` | Checks that the entity reached the transition's `to` state. |
811
+
812
+ #### `LoopbackInterface`
813
+
814
+ | Method | Returns | Summary |
815
+ | --------- | --------------- | ------------------------------------------------------------------------------------------------------------------- |
816
+ | `destroy` | `Promise<void>` | Drops every live connection on a server that carries `closeAllConnections`, stops listening, and releases the port. |
817
+
818
+ #### `CookieJarInterface`
819
+
820
+ | Method | Returns | Summary |
821
+ | --------- | --------------------- | ---------------------------------------------------- |
822
+ | `read` | `string \| undefined` | Reads one stored cookie value. |
823
+ | `capture` | `readonly string[]` | Applies every `Set-Cookie` field a response carries. |
824
+
825
+ #### `PortfolioInterface`
826
+
827
+ | Method | Returns | Summary |
828
+ | ------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
829
+ | `place` | `Promise<string \| undefined>` | Places one registered state: applies the variant, stages the pane, and writes the verified screenshot. |
830
+
831
+ #### `JournalInterface`
832
+
833
+ | Method | Returns | Summary |
834
+ | -------- | ------- | ----------------------------------------------------------------------------- |
835
+ | `start` | `void` | Starts a fresh recording, dropping whatever the previous scenario left. |
836
+ | `stop` | `void` | Stops recording and hands every intercepted console channel back by identity. |
837
+ | `record` | `void` | Records one step, when the journal is started. |
838
+
839
+ #### `ScratchInterface`
840
+
841
+ | Method | Returns | Summary |
842
+ | --------- | --------------------- | ------------------------------------------------------------------------------------- |
843
+ | `write` | `string` | Writes a file, creating each parent directory that does not exist. |
844
+ | `read` | `string \| undefined` | Reads a file. |
845
+ | `has` | `boolean` | Reports whether a path exists without following its final symbolic link. |
846
+ | `names` | `readonly string[]` | Lists the names directly inside a directory in sorted order. |
847
+ | `ensure` | `string` | Creates a directory and every missing parent. |
848
+ | `link` | `string` | Creates a symbolic link at a contained path, creating its missing parent directories. |
849
+ | `remove` | `void` | Removes a file, an empty directory, or a directory and its descendants. |
850
+ | `destroy` | `void` | Removes the allocated directory and everything in it when its identity still matches. |
851
+
852
+ An empty target names the allocation root. `ensure('')` returns the root path, `has('')` reports
853
+ `true`, and `names('')` lists the root. `write('', …)` surfaces the host's `EISDIR` and `link('', …)`
854
+ its `EEXIST`, because the root is a directory that already exists. The host code is the accurate
855
+ answer there, so this package adds no refusal of its own.
856
+
857
+ `remove` is the exception, and it refuses: `remove('')`, `remove('.')`, and `remove` of the absolute
858
+ root all throw `Scratch directory is not a removable target: <target>`. Every other member reads the
859
+ root as harmless or as a question about the allocation, and only `remove` would read it as an
860
+ instruction to delete one — which is what an empty computed path produces. Ending the allocation is
861
+ `destroy()`'s job. What the refusal buys is that the degenerate argument is loud: the empty computed
862
+ path is this member's most destructive input, and it throws rather than acting. It buys no more than
863
+ that. A directory that has replaced the allocation at the same path is not protected by it —
864
+ `remove('x')` still removes `<replacement>/x`, exactly as `write`, `ensure`, and `link` still act
865
+ inside one.
866
+
867
+ ### Traversal
868
+
869
+ Every member that takes a target resolves that target's **intermediate** segments through a symbolic
870
+ link inside the allocation and acts at the destination. `write`, `read`, `has`, `names`, `ensure`,
871
+ `link`, and `remove` all do this, so a lexically contained path can read, list, create, write, and
872
+ remove outside the allocation. That is the contract rather than a hole in it: containment here is
873
+ lexical, `link` is the member that creates such a link, and the [threat model](#threat-model) names
874
+ who else creates one.
875
+
876
+ `remove` carries the one physical exception, and it is narrow. It reads the final entry it reaches
877
+ with `lstat` and refuses when that entry carries the allocation's identity, so a target that arrives
878
+ back at the allocation through an intermediate link throws instead of emptying it. A sibling reached
879
+ through that same link is still removed. The exception stops at the allocation itself and is
880
+ deliberately not narrowed further, because narrowing it further would be the per-segment walk this
881
+ package declines to do.
882
+
883
+ The **final** segment is where `has`, `link`, and `remove` differ from the rest. `has` reads it with
884
+ `lstat` rather than following it, so `has('gate')` reports the link itself and stays `true` after
885
+ whatever `gate` pointed at is removed. `link` acts at the final segment rather than through it, so a
886
+ second `link('gate', …)` surfaces the host's `EEXIST` instead of creating a link inside the
887
+ destination. `remove` acts there too, so `remove('gate')` unlinks `gate` and leaves the directory it
888
+ pointed at standing; following the link would instead remove a whole tree outside the allocation
889
+ through one contained path. `write`, `read`, `names`, and `ensure` act at what a final-segment link
890
+ points at, so `ensure` against a dangling final link throws the host's `ENOENT` and creates nothing
891
+ at the destination.
892
+
893
+ `ensure` returns the lexical path it was given rather than the destination, so `ensure('gate/made')`
894
+ returns `<allocation>/gate/made` while the directory is made wherever `gate` points, and
895
+ `ensure('gate')` returns `<allocation>/gate` and leaves the directory it points at alone.
896
+
897
+ ### Hosts that create no symbolic link
898
+
899
+ `link` and `createLink` attempt an untyped `symlinkSync` first, and that is the whole mechanism on a
900
+ host that grants it. Windows grants the symbolic-link privilege only under Developer Mode or to an
901
+ administrator, and refuses the call with `EPERM` otherwise. Only `EPERM` falls back: every other code
902
+ rethrows untouched, so an occupied path still surfaces the host's own `EEXIST`. The fallback creates
903
+ a directory junction, which needs no privilege and points only at a directory. Everything that
904
+ junction changes is in this section.
905
+
906
+ The source resolves against the link's own directory rather than against the process working
907
+ directory, so `link('nested/gate', 'source')` points at `<allocation>/nested/source` — where a
908
+ symbolic link made from the same relative source lands.
909
+
910
+ A source that exists and is not a directory is refused: the original `EPERM` is rethrown and nothing
911
+ is left at the link path. A file source is the case that varies by host. Where the host makes
912
+ symbolic links, `read` follows a link whose source names a file and returns that file's text; where
913
+ the host makes junctions, the same `link` call throws and creates nothing. Link a directory and read
914
+ the file through it when a fixture must run on a host of either kind.
915
+
916
+ A missing source is accepted, and the dangling junction it creates answers the way a dangling
917
+ symbolic link does: `has` reports `true` and `read` returns `undefined`. It resolves later only if a
918
+ **directory** appears at the source. A file appearing there leaves it unresolvable, so a fixture that
919
+ links a path first and writes a file there second reads back nothing on such a host.
920
+
921
+ Where the host creates a junction, the stored value is the source resolved to an absolute path, so
922
+ `readlink` on a link made from a relative source returns an absolute path. That is why `link`
923
+ promises the stored value names the destination and promises nothing about its exact text. Assert on
924
+ what the link reaches, not on what it stores.
925
+
926
+ ## Voices
927
+
928
+ Every message `src/browser` throws. Keep them distinct: a journey asserts the one it means, and
929
+ absent, present-but-gated, and ambiguous are different findings about an interface.
930
+
931
+ | Voice | Thrown by |
932
+ | ------------------------------------------------------------------------------------- | ----------------------- |
933
+ | `No interactive element has the accessible name "<name>"` | `resolveRendered` |
934
+ | `Interactive target "<name>" is not visible and focus-reachable` | `resolveRendered` |
935
+ | `Interactive target "<name>" is ambiguous across <n> elements` | `resolveRendered` |
936
+ | `Interactive target "<name>" could not be resolved` | `resolveRendered` |
937
+ | `Interactive target "<name>" is unreachable after scrolling` | `resolveAccessible` |
938
+ | `Interactive target "<name>" is not reachable inside "<region>"` | `clickAccessibleWithin` |
939
+ | `Interactive target "<name>" is ambiguous across <n> elements inside "<region>"` | `clickAccessibleWithin` |
940
+ | `Interactive target "<name>" could not be resolved inside "<region>"` | `clickAccessibleWithin` |
941
+ | `Native disclosure "<name>" is not visible and focus-reachable` | `clickDisclosure` |
942
+ | `Native disclosure "<name>" is ambiguous across <n> elements` | `clickDisclosure` |
943
+ | `Native disclosure "<name>" could not be resolved` | `clickDisclosure` |
944
+ | `Interactive target "<name>" is not reachable through forward Tab traversal: <trail>` | `traverseAccessible` |
945
+ | `Named region "<name>" is not visible` | `readPerception` |
946
+ | `Named region "<name>" is ambiguous across <n> elements` | `readPerception` |
947
+ | `Named region "<name>" could not be resolved` | `readPerception` |
948
+ | `Interactive target "<name>" does not carry a value` | `readValue` |
949
+ | `Computed foreground color is unavailable` | `readContrast` |
950
+ | `Computed background color is unavailable` | `readContrast` |
951
+ | `Tester pane is unavailable for a capture` | `stagePane` |
952
+ | `Tester pane rendered <w>x<h> for a <w>x<h> viewport` | `stagePane` |
953
+ | `Capture frame at <path> never settled after <n> restagings: <h> over a <h> pane` | `captureFrame` |
954
+ | `Capture frame was written to <path> where <path> was asked for` | `captureFrame` |
955
+ | `Capture frame at <path> is not the one this run shot` | `captureFrame` |
956
+ | `Capture frame at <path> could not be read` | `readFrame` |
957
+ | `Capture frame at <path> is not an image this browser decodes` | `readFrame` |
958
+ | `Capture frame at <path> cannot be measured without a 2D canvas` | `readFrame` |
959
+ | `Capture variant "<name>" is not registered` | `createPortfolio` |
960
+ | `Capture state "<state>" is not registered` | `place` |
961
+ | `Capture state "<state>" is already placed` | `place` |
962
+ | `IndexedDB database "<name>" could not be deleted` | `removeDatabase` |
963
+ | `IndexedDB database "<name>" is blocked by an open connection` | `removeDatabase` |
964
+
965
+ Some of them are narrowing rather than findings, and no input reaches them. Each `could not be
966
+ resolved` is one: a preceding length check does not narrow the later lookup under
967
+ `noUncheckedIndexedAccess`, so the branch gives the value its type.
968
+
969
+ The capture guards are the other population no test drives, because each answers for a runner or a
970
+ provider this package does not control. `Tester pane is unavailable for a capture` fires where
971
+ Vitest stops laying its tester out inside a pane. `Capture frame was written to <path> where <path>
972
+ was asked for` fires where the provider resolves a screenshot path against a base other than the
973
+ calling test file. `Capture frame at <path> is not the one this run shot` fires where the file on
974
+ disk disagrees with the bytes the provider handed back — which a provider that overwrites its target
975
+ never produces, so the suite proves that comparison discriminates with a planted file rather than by
976
+ reaching the refusal. `Capture frame at <path> cannot be measured without a 2D canvas` is narrowing
977
+ of the same kind: a canvas allocated for this reading and asked for no other context type hands one
978
+ back. `readFrame`'s other two refusals are driven, by a path holding no file and by a file holding
979
+ no image, and so is `Capture frame at <path> never settled after <n> restagings`, by a fixture whose
980
+ full-height panel grows with every pane the capture stages.
981
+
982
+ ### Refusals outside the journey layer
983
+
984
+ The unchecked boundary refuses before it acts, and these are its own messages.
985
+
986
+ | Voice | Thrown by |
987
+ | -------------------------------------- | ----------------- |
988
+ | `Method must be callable` | `invokeUnchecked` |
989
+ | `Target must be an object or function` | `readProperty` |
990
+
991
+ Each is a `TypeError` rather than an `Error`, because what failed is the argument's own type rather
992
+ than a state the caller could have read first. Every other refusal `src/core` and `src/server` raise
993
+ is documented with the member that raises it: a wait names the description it was given, a scratch
994
+ member names the target it refused, and the [Contract](#contract) rule that owns each one spells the
995
+ message out.
996
+
997
+ ## Contract
998
+
999
+ These hold across `src/core`, `src/browser`, `src/server`, and this guide.
1000
+
1001
+ 1. **Doc ↔ source bijection, and every flagship fence transcribed.** Every `## Surface` row is a real
1002
+ export, and every export is a row — exhaustive in each direction, name and kind together. The
1003
+ same suite anchors its further comparisons to source rather than to the guide: the barrel exposes
1004
+ exactly what the modules declare, `## Methods` documents exactly the interfaces that carry call
1005
+ signatures, and every name a `ts` fence imports from this package is a real export. Deleting a
1006
+ documented section therefore fails rather than passing with nothing left to check.
1007
+ Resolution is not behavior, though: a name can resolve while the sentence beside it is false, so
1008
+ the same suite transcribes each flagship fence this package's own runtime can run and asserts the
1009
+ values that fence's comments claim. A fence naming a browser is left to the browser suite, which
1010
+ is where its values are pinned. Change a fence, change its transcription in the same edit.
1011
+ [`tests/guides.test.ts`](../tests/guides.test.ts) proves all of it, and builds its own file
1012
+ inventory with this package's `readInventory` and `resolveRoot`.
1013
+ 2. **`clear()` truncates.** It empties the backing array rather than replacing it, so a `calls`
1014
+ reference captured before the call reads as empty after it. Capture `calls` after the last
1015
+ `clear()` you care about, or read `count` instead.
1016
+ 3. **`captureError` converts a synchronous throw into a value.** It returns the thrown value
1017
+ exactly, including `null` and `undefined`, and it never throws for a thunk that completed. Limits
1018
+ come with that. A thunk that throws `undefined` is indistinguishable from one that
1019
+ completed, because both return `undefined`; assert on a thrown value's identity, not on its
1020
+ absence. And an `async` thunk never throws synchronously — it returns a rejected promise — so
1021
+ `captureError` returns `undefined` and the rejection escapes unhandled. It converts; it decides
1022
+ nothing. The variant that throws when nothing was thrown is an assertion, and it is not published
1023
+ here.
1024
+ 4. **`requireValue` tests presence, not truth.** `0`, `''`, and `false` pass through unchanged; only
1025
+ `null` and `undefined` throw. It exists because `!` and `as` are banned, so a throwing narrowing
1026
+ helper is the sanctioned way to reach a value's non-nullable type.
1027
+ 5. **`roundTripJSON` bounds its parameter by `JSONSafe<T>`, and throws rather than returning `null`
1028
+ quietly.** The parameter is `T & JSONSafe<T>` rather than `T extends JSONValue`, because a
1029
+ `JSONValue` constraint rejects every `interface`: TypeScript grants an implicit index signature
1030
+ to a type alias and never to an interface, and interfaces are what this fleet's public types are.
1031
+ The projection accepts an interface-typed value, keeps `T` as the return type, and refuses a
1032
+ `Date`, a `Map`, or any method-bearing type at the member that carries it. More members meet
1033
+ `never` because the copy would not carry what the type claims. Serialization drops a
1034
+ member typed `undefined` from an object and rewrites it to `null` in an array, so
1035
+ `{ a: undefined }` and a top-level `undefined` are both refused at the call. `JSON.stringify`
1036
+ never enumerates a symbol-keyed member, so the copy arrives without it. And a member declared as
1037
+ the opaque `object` type projects over no members at all, so a `Date` under it would copy back as
1038
+ a string, off the type the member declares. `unknown` is the member type that does pass through, so
1039
+ `Record<string, unknown>` is accepted and what its values hold is a runtime question rather than
1040
+ a typed one. The bound is not enough on its own either: `NaN`, `Infinity`, and `-Infinity` are
1041
+ numbers, they satisfy it, and `JSON.stringify` turns each of them into `null`. So the helper
1042
+ rejects a non-finite number at any depth with `JSON values must contain finite numbers`, and the
1043
+ copy's type claim holds for every value it does return. The replacer alone would not close it: a
1044
+ `JSON.rawJSON` value carries text `JSON.stringify` emits without inspecting, so
1045
+ `JSON.rawJSON('1e400')` passes the replacer untouched and parses back as `Infinity`. The helper
1046
+ therefore checks the parsed graph as well, and the replacer and the parsed-graph check report the
1047
+ same message. A normalization remains and is not an error: `-0` serializes as `0`, so the copy is
1048
+ `0`.
1049
+ 6. **`readInventory` refuses links.** A target is a file or a directory. A named file is read and
1050
+ keyed whatever `extensions` says, which is what lets one call take a package's root files and its
1051
+ source tree together; a named directory is walked under the filter. It throws when the root or a
1052
+ named target is a symbolic link, when the root is not a directory, when a target is neither a file
1053
+ nor a directory, or when a target resolves outside the root. A missing target surfaces the host's
1054
+ own `ENOENT` rather than a message from this package. It skips a symlink met while walking rather
1055
+ than following it. A target may be written relative to the root or as an absolute path inside it,
1056
+ and one that escapes is refused either way.
1057
+
1058
+ A link in the **middle** of a named target is a separate refusal, and the only one that reaches
1059
+ it: the symbolic-link check reads the final segment, which such a link is not. The named target is
1060
+ resolved with `realpath` and refused when the real path leaves the root, so
1061
+ `readInventory(root, ['link/file.txt'])` with `link` pointing outside throws
1062
+ `Target outside root: link/file.txt`. When that link stays inside the root the target resolves,
1063
+ and the entry is keyed by its **real** path rather than by the path the caller named.
1064
+
1065
+ An exclusion applies to a named target as well as a walked entry, so exclusion beats naming:
1066
+ `readInventory(root, ['src/core/index.ts'], { exclude: ['src/core'] })` returns `{}`. A
1067
+ `tsconfig` reader expects the more specific entry to win, the way a `files` entry survives
1068
+ `exclude`; here the more specific entry is the one that disappears. Express an exception with a
1069
+ second call that names the kept file and passes no exclusion, and merge the maps. An
1070
+ exclusion is normalized before the rule applies: a leading `./` and a trailing `/` are stripped,
1071
+ and `''` and `'.'` both name the root, so either drops every key. Keys are root-relative and
1072
+ separated by `/` whatever the host separator is: `readInventory` takes the spelling `relative`
1073
+ returns and rejoins its segments split on `sep` with `/`. The suite gates that proof on a host
1074
+ reading rather than on a platform name. It spells a nested path with `join`, and where that
1075
+ spelling carries `sep` and no `/` it asserts that a walked key and a named key both read
1076
+ `alpha/beta/deep.txt` and carry no `sep`. A host that already spells the path with `/` skips the
1077
+ case, because the conversion is a no-op there and discriminates nothing. The map is built by
1078
+ inserting the keys in sorted order. Read back, non-integer keys hold that order. Integer-like
1079
+ keys do not, because a plain object enumerates them numerically first: files named `0`, `2`,
1080
+ `10`, and `a.txt` insert as `0`, `10`, `2`, `a.txt` and enumerate as `0`, `2`, `10`, `a.txt`.
1081
+ Returning a `ReadonlyMap` would keep the order and break the structural match with
1082
+ `@orkestrel/guide`'s `SourceOptions.files` that the whole helper is shaped for, so the guarantee
1083
+ narrows instead. Case is the host's decision, not this package's: whether names differing only in
1084
+ case are the same file varies by filesystem, so the suite probes the running host and asserts
1085
+ what the probe returned instead of assuming either answer.
1086
+
1087
+ 7. **`createScratch` refuses a lexical escape, not a symbolic link.** It allocates with
1088
+ `mkdtempSync` below `parent`, which creates the directory at mode `0700` on a host that applies
1089
+ POSIX permission bits. The suite gates that assertion on `supportsMode`, so the mode is proven
1090
+ where the probe answers `true` and the case is skipped where it answers `false`. A Windows host
1091
+ answers `false` and reads the same allocation back as `0666`, so the bits describe nothing it
1092
+ applies. Every member that takes a target — `write`, `read`, `has`, `names`, `ensure`, `link`,
1093
+ and `remove` — throws when that target lexically escapes the allocated directory, and a failed
1094
+ seed removes the directory before rethrowing. `remove` adds a refusal the others do not need: a
1095
+ target naming the allocation itself, lexically or through an intermediate symbolic link. The
1096
+ lexical half compares paths. The physical half reads only the final entry with `lstat` and
1097
+ compares it with `matchesIdentity`; it walks no path segments and follows no final link. That is
1098
+ the comparison `destroy()` makes, so it carries the same birth-time limit stated for `destroy()`
1099
+ later. `link` checks its target and not its source, so a contained link may point anywhere.
1100
+ `createScratch` does not walk the path's segments for symbolic links: that is sandbox behavior
1101
+ and this is not a sandbox. So a lexically contained path can still act outside the allocation,
1102
+ and only one that lands back on the allocation itself is refused; [Traversal](#traversal) states
1103
+ what each member does with a link it meets, and the [threat model](#threat-model) says who
1104
+ creates one.
1105
+
1106
+ `names` sorts, and the suite discriminates a dropped `.sort()`. Filenames written from raw
1107
+ bytes give `readdirSync` the reverse of sorted order: `0x80` is an invalid UTF-8 lead byte, so
1108
+ that name reaches JavaScript as `U+FFFD` and sorts after `é`, while on disk `0x80` sorts before
1109
+ `é`'s leading `0xc3`. The suite asserts the host's order, the sorted order, and that they
1110
+ differ, so it fails rather than going quiet if that population ever stops discriminating.
1111
+
1112
+ That population carries a limit, and it is Node's rather than this package's. A name the host
1113
+ refuses to decode reaches JavaScript as `U+FFFD`, and that string re-encodes to the bytes
1114
+ `EF BF BD`, which are not the bytes on disk. So the string `names()` hands back never addresses
1115
+ the entry it came from: `has` on it reports `false`, and `remove(names()[i])` removes nothing and
1116
+ throws nothing, because a missing target is a no-op. The silence is the cost — a caller looping
1117
+ over `names()` to clear a directory leaves such a file behind and reads success. Reach that file
1118
+ with a `Buffer` path through `node:fs` directly.
1119
+
1120
+ `destroy()` is idempotent, and identity is what makes it safe rather than location: it removes
1121
+ the entry at the allocated path only while `matchesIdentity` holds against the allocation. A
1122
+ replacement directory left at that path is not removed, and an allocation moved elsewhere is not
1123
+ removed at all. That comparison never consulted the host temporary directory, so it holds
1124
+ unchanged wherever `parent` puts the allocation. Limits sit beside the `0700` one. The check
1125
+ reads the entry and then removes the path as separate steps, so an allocation swapped between
1126
+ them is removed anyway; whoever swaps it runs as the same uid, which is the population the threat
1127
+ model already declines to defend against. And birth time is the host's to supply. This host
1128
+ supplies a real one — the allocation's `birthtimeMs` does not move when files are written into
1129
+ it, while its `ctimeMs` does — so `destroy()` is sound here. Where a host has none, libuv reports
1130
+ `ctime` in its place, the first seeded write moves it, and `destroy()` takes its early return. It
1131
+ returns `void`, so that refusal is indistinguishable from success and the allocation leaks
1132
+ silently.
1133
+
1134
+ 8. **A destroyed allocation answers presence and refuses action.** `read` returns `undefined` and
1135
+ `has` returns `false`; `write`, `names`, `ensure`, `link`, and `remove` throw
1136
+ `Scratch directory does not exist`. The split follows the return type: a member whose return type
1137
+ carries absence answers with it, and a member whose return type does not, refuses. `names` asks a
1138
+ question and changes nothing, and it refuses anyway, because `readonly string[]` has no value
1139
+ meaning gone. `write`, `ensure`, and `link` are why the refusal is written out rather than left to
1140
+ the host: each calls `mkdirSync` with `recursive`, which recreates every missing parent, so
1141
+ without the check any of them would rebuild the allocation root and leave a destroyed
1142
+ fixture looking alive. `remove` is written out for the opposite reason: `rmSync` with `force`
1143
+ does not throw on a path that is not there, so without the check it would report success against
1144
+ a fixture that is gone.
1145
+ 9. **Zero runtime dependencies, and no foreign type in a signature.** `dependencies` is empty and
1146
+ stays empty. No exported signature names an `@orkestrel/*` type, so no consumer can be handed a
1147
+ two-copies type failure by installing this package.
1148
+ 10. **`createTeardown` runs newest-first, and every handler runs.** `destroy()` takes the registered
1149
+ handlers in reverse registration order and awaits each one before starting the next, so a
1150
+ handler that undoes what a later registration depends on runs after it. A handler that throws or
1151
+ rejects does not stop the run: every remaining handler still runs, and the failures are raised
1152
+ at the end. Exactly one failure is rethrown by identity, so a test can assert on the value it
1153
+ threw. Several are wrapped in an `AggregateError` whose `errors` are in run order — newest
1154
+ first — rather than in registration order. `destroy()` empties the list before it starts, so a
1155
+ handler registered while the run is in progress stays registered for the next call rather than
1156
+ joining this one, and `count` read from inside a running handler counts only those late
1157
+ registrations. A repeated `destroy()` runs nothing that already ran, which is what makes it
1158
+ idempotent. The list registers no Vitest hook itself: the consumer writes
1159
+ `afterEach(() => teardown.destroy())` once, in its own setup. That one line is the price of the
1160
+ zero-dependency contract, because registering the hook here would take a runtime dependency on
1161
+ the test runner and the zero-runtime-dependencies contract forbids one.
1162
+ 11. **`createLoopback` binds a server the caller made.** The caller constructs its own unstarted
1163
+ server and keeps every protocol handler on it; this package supplies the bind and the release
1164
+ and nothing else. It listens on port `0` at `127.0.0.1`, so the host assigns the port and the
1165
+ address is always IPv4 loopback — never `::1`, which a host resolving `localhost` can hand back
1166
+ instead, and never a fixed port a parallel worker may already hold. `port` is that assigned
1167
+ number, read off `address()`. `url` is `http://127.0.0.1:<port>` with no trailing slash, and the
1168
+ scheme is spelled `http` unconditionally, so a TLS server's origin is `port` plus a scheme the
1169
+ caller writes itself. `destroy()` drops every live connection before it closes, so a keep-alive
1170
+ client cannot hold the port past the test that opened it; the drop reaches the `node:http` and
1171
+ `node:https` servers that carry `closeAllConnections`. A plain `node:net` server has no such
1172
+ method to call, so `destroy()` waits for its open sockets to end. It is idempotent — the first
1173
+ call's promise is returned to every later one — and a server already closed underneath it
1174
+ resolves rather than throwing. The package never reserves a port number and releases it for the
1175
+ caller to rebind; [Limits](#limits) states why that shape is refused.
1176
+ 12. **`createHostileValues` is a growing totality corpus with a negative control.** Each call
1177
+ returns a frozen array of fresh values, and [Prove a guard is total](#prove-a-guard-is-total)
1178
+ lists every member with the reading it breaks. Every member makes a naive read throw or violates
1179
+ a naive structural assumption; a total guard survives every member without throwing. Each has a
1180
+ direct probe for that failure. The negative control keeps an inert value from entering the corpus
1181
+ under a hostile name. Whether it accepts or refuses one is that guard's own contract. Membership
1182
+ may grow in a release, so consumers loop over the whole array, assert their guard's expected
1183
+ answer per index, and attribute each failure by that index instead of naming or counting members
1184
+ locally.
1185
+ 13. **The journey layer resolves its own targets, and imports almost nothing.** No journey verb in
1186
+ `src/browser` accepts an element, a component instance, or a selector for the target it acts on:
1187
+ each finds its own from a role and an accessible name, which is what stops a journey drifting
1188
+ into a description of the markup. `build` creates a node, `mount` attaches one, `render` does
1189
+ both, `clearStorage` takes nothing at all, and `removeDatabase` takes a database name. The
1190
+ predicates, the element readers, and the describers do take a node —
1191
+ `isRendered`, `isReachable`, `readText`, `readRole`, `readName`, `readStates`, `describeTree`,
1192
+ `describeFocus`, `extractOrphans`, `readRows`, `readStyle`, `readToken`, `readPixels`,
1193
+ `readContrast`, `readLayers`, `readBackdrop`, and `readRing` — and each is a reader of a node
1194
+ the caller already has rather than a verb that acts on a target. `captureFrame` and `place` take
1195
+ one as the subject of a photograph, which is a reading too: neither moves focus, dispatches an
1196
+ event, nor changes what the element renders. `typeInput` and `commitInput` are the one pair that
1197
+ acts on the element it is handed, and the exception is deliberately narrow: they are the
1198
+ synthetic counterpart of `typeAccessible`, for a component that listens for `input` and a test
1199
+ that already holds the field. Drive the field by name wherever the keystrokes are part of what
1200
+ the journey claims. `readRing` is the case that makes the split explicit. It measures the focus
1201
+ chrome a browser painted and never brings the focus about, so a journey reaches the control
1202
+ through `traverseAccessible` or `userEvent.keyboard` from `vitest/browser` and then measures what
1203
+ landed. The whole environment imports `vitest/browser` and DOM globals and nothing else — no
1204
+ `src/core` import, no framework, no `node:*`, and no `import.meta.env`, so whether a run writes
1205
+ captures is the consumer's decision through `PortfolioOptions.enabled` rather than an environment
1206
+ variable this package reads. `vitest` is a peer dependency, so the provider the layer drives is
1207
+ the one the consumer already installed, and the zero-runtime-dependencies contract's empty
1208
+ `dependencies` is untouched.
1209
+ 14. **The wait family polls only where nothing publishes an event.** The no-polling architecture law
1210
+ governs a product's idle wakeup: a running system parks on the event or the abort signal that
1211
+ fires. A test instrument is the other case. It waits on a fact another process produces — a file
1212
+ a build wrote, a port a child bound, a handle a host has not released — and that fact publishes
1213
+ no event to park on, so `waitForCondition` re-reads the condition through `waitForDelay` inside a
1214
+ budget measured with `performance.now()`. Where an event does exist, `waitForEvent` is the door:
1215
+ it parks on the subscription, validates the interval for consistency with the family and never
1216
+ uses it, and invokes the cleanup the subscriber returned on timeout, on abort, and on delivery
1217
+ alike. `waitForCondition`, `retryUntil`, and `waitForEvent` each name what they are waiting for,
1218
+ and that description is what the timeout message carries — a wait nobody described times out
1219
+ saying nothing about what failed. Every bound is validated finite and non-negative before
1220
+ anything is read, a budget of `0` still permits the immediate first reading, and an abort rejects
1221
+ with the signal's own reason rather than with a message of this package's. `retryUntil` also
1222
+ renders the last unsatisfying value into its exhaustion message, through `JSON.stringify` with a
1223
+ string conversion behind it and a cut at 200 characters, so an exhausted retry reports what it
1224
+ kept producing rather than only that it kept failing. `waitForCondition` and
1225
+ `retryUntil` throw opposite ways, and the split is deliberate: `waitForCondition` propagates a
1226
+ condition's throw
1227
+ unchanged, because a broken reading does not become true by being taken again, while `retryUntil`
1228
+ counts a `produce` throw as an unsatisfied attempt and hands the last one to the exhaustion
1229
+ error's `cause`, because a producer that throws is exactly what a retry exists for. A throw from
1230
+ `satisfied` propagates unchanged for `waitForCondition`'s reason: the predicate is broken.
1231
+ 15. **A role map's membership is the contract.** `IMPLICIT_ROLES`, `FIELD_ROLES`, `HEADER_ROLES`, and
1232
+ `CONTENT_ROLES` are each read as a closed list rather than as a cache of an ARIA computation. A
1233
+ tag `IMPLICIT_ROLES` omits carries no implicit role, so `readRole` returns `undefined` for it,
1234
+ `describeTree` writes no line for it, and the walk continues straight into its children at the
1235
+ depth the omitted element sat at; an `input` type `FIELD_ROLES` omits exposes none the same way.
1236
+ `A`, `INPUT`, and `SELECT` are absent from `IMPLICIT_ROLES` deliberately, because each takes its
1237
+ role from an attribute rather than from its tag, and `readRole` answers for them from their own
1238
+ anatomy. Read a description that omits an element as the map's answer rather than as a defect.
1239
+ Widening what a description carries is a change to the map here, not a workaround at the call
1240
+ site.
1241
+ 16. **`isRendered` and `isReachable` are the announced half and the clickable half.** `isRendered`
1242
+ reads no geometry at all: only the removals a browser honours — `aria-hidden` anywhere above the
1243
+ element, the `hidden` attribute, a hidden input, and a `display` or `visibility` that takes it
1244
+ off the page. `isReachable` reads geometry, and adds connectedness, a visibility check that
1245
+ honours opacity, a non-zero box, the sequential focus order, `:disabled` and `aria-disabled`, and
1246
+ the `[inert]` ancestor. A control clipped to a
1247
+ zero-size rectangle is the case that separates them: the accessibility tree still announces it,
1248
+ so `isRendered` accepts it and `isReachable` refuses it. `isReachable` is the one reachability
1249
+ filter the layer applies — `resolveRendered`, `clickAccessibleWithin`, and `clickDisclosure` each
1250
+ narrow their own candidates and then keep the ones it accepts — so a journey meets one rule
1251
+ rather than near-copies of it. Neither asks about the viewport; `resolveAccessible` scrolls a
1252
+ wholly off-viewport target into view and measures that separately with `isOutsideViewport`.
1253
+ 17. **A journal forwards every console call and swallows nothing.** A browser publishes no listener
1254
+ for its own output, so `createJournal` stands in front of the console and hands each call on to
1255
+ the channel that was there when `start` armed it. A run under a journal therefore prints exactly
1256
+ what it prints without one, which is what stops a recording from hiding the diagnostics it exists
1257
+ to keep. `stop` puts those same function references back by identity, so a channel another tool
1258
+ installed survives the journal rather than being replaced by a copy of it. `start` clears `steps`
1259
+ and `output` whether or not the journal was already recording and leaves a standing interception
1260
+ alone, so a restart never wraps its own wrappers. `record` does nothing while the journal is
1261
+ stopped, and `steps` and `output` hand out snapshots. There is no shared instance: a file that
1262
+ needs one journal per scenario creates one per scenario.
1263
+ 18. **The capture layer depends on the Vitest runner's own tester layout, deliberately.** A
1264
+ screenshot is taken off the page the runner painted, and `vitest@4.1.11` lays its tester out
1265
+ inside a smaller page and fits it by scaling the pane the tester sits in, so a frame shot through
1266
+ that scale is a thumbnail of the surface. `stagePane` therefore reaches into that layout: the
1267
+ `iframe[data-vitest]` selector and the `--tester-transform`, `--tester-margin-left`,
1268
+ `--viewport-width`, and `--viewport-height` custom properties are the runner's, not this
1269
+ package's, and `captureFrame` reads its written file back through the runner's built-in
1270
+ `readFile` command. That is contract rather than accident. A Vitest release that renames any of
1271
+ them reddens `stagePane`'s size check, which throws
1272
+ `Tester pane rendered <w>x<h> for a <w>x<h> viewport` rather than writing a wrong frame. The
1273
+ coupling therefore fails loudly, and the version this rule names moves with the fix instead of a
1274
+ suite shipping thumbnails nobody inspects. The same layout decides what a frame covers: the
1275
+ provider shoots the tester's body in the top-level page's coordinates, so `captureFrame` stages
1276
+ the pane again at the document's own height wherever the document outruns the declared one, and
1277
+ a frame is neither shorter nor taller than the document it photographs. That height is
1278
+ `measureContent`, the content's own edge rounded up, rather than the body's box: the box is the
1279
+ larger of the content and the pane, so a taller pane stretches it and a capture cannot read its
1280
+ way back down. The edge is read again after every staging, because a rule bound to the viewport
1281
+ height lays the document out taller against the taller pane, and each staging carries the growth
1282
+ the one before it produced so a converging document lands on its fixed point rather than
1283
+ creeping toward it. The re-reading is bounded by `CAPTURE_STAGINGS`, and a document still
1284
+ growing at that bound is refused rather than photographed at a stale height. What the capture borrows it gives back —
1285
+ `releasePane` returns the tester to the viewport it held before the staging, so the variant a
1286
+ frame was shot at belongs to that frame alone, and a suite that wants a size of its own calls
1287
+ `page.viewport` rather than this pair.
1288
+
1289
+ ### Threat model
1290
+
1291
+ The filesystem helpers make different promises, because they work on different directories.
1292
+ Read the `createScratch` contract against the `createScratch` paragraphs later and the
1293
+ `readInventory` contract against the `readInventory` one.
1294
+
1295
+ `createScratch` allocates its own directory with `mkdtempSync`, which sets mode `0700` on a host
1296
+ that applies POSIX permission bits, and the suite asserts that mode where `supportsMode` answers
1297
+ `true`. Where the host applies those bits, the mode keeps another uid out; where `supportsMode`
1298
+ answers `false` the mode protects nothing, and the allocation carries only the access its parent
1299
+ directory already gives. The mode does not keep out a sibling test worker or the code under test on
1300
+ any host, because both run as the same uid, and they are the population that would create a link
1301
+ here. Its containment check is lexical: it refuses a relative path that escapes the allocated
1302
+ directory, which is the accident that actually happens — a test writing `../foo`. It does not walk
1303
+ the path's segments for symbolic links, because per-segment walking is sandbox behavior and this is
1304
+ not a sandbox.
1305
+
1306
+ So a link inside the allocation was created by the test process or by the code the test drives, and
1307
+ handing `scratch.path` to the code under test is the ordinary use of this helper. `link` is this
1308
+ package's own entry in that population: it creates a symbolic link on request, it refuses only an
1309
+ escaping target, and its source may name anything. The source stays unchecked except on the fallback
1310
+ path, which stats it to decide whether a junction can point there. [Traversal](#traversal) states
1311
+ what each member does with a link it meets, and a contained path reaching outside the allocation
1312
+ through one is the result. This helper does not defend against that.
1313
+
1314
+ `parent` adds a limit, and it is visibility. An allocation under the host temporary directory is
1315
+ seen by nothing in the repository. An allocation under a path inside a package tree is seen by
1316
+ everything that walks that tree while it exists — `tsc`, the formatter, the linter, the policy
1317
+ sweep, and the test runner's own globs. Set `parent` to a path those tools already ignore, or leave
1318
+ it unset and take the host temporary directory. `destroy()` is unaffected either way, because it
1319
+ matches on identity rather than on where the allocation sits.
1320
+
1321
+ `readInventory` walks a directory the caller supplies, usually a real checkout the test did not
1322
+ create, so it does refuse links. It keeps its refusals separate: it throws on a symlinked root,
1323
+ throws on a symlinked named target, throws on a named target whose real path leaves the root through
1324
+ a link in the middle, and skips a symlink met while walking. They are distinct decisions rather than
1325
+ a shared rule, and each is its own check at the door it guards.
1326
+
1327
+ Neither helper stops hard links. A hard link is an ordinary directory entry: `lstat` reports a
1328
+ regular file, so `readInventory` reads the outside inode and `createScratch` writes through it.
1329
+ Detecting that would need inode bookkeeping on every entry, and it would buy nothing, because
1330
+ anyone able to create a hard link where the test process writes already writes there. So no
1331
+ hard-link detection is added, and the boundary is documented instead.
1332
+
1333
+ ## Limits
1334
+
1335
+ This package ships what the fleet repeats, not everything the fleet has.
1336
+
1337
+ A candidate ships when it is a reusable test mechanism, has a real consumer, fits this package's
1338
+ environment boundaries, and duplicates no native or declared-dependency primitive. Repeated demand
1339
+ across the fleet is what raises a candidate, and it is evidence rather than the gate. A shape many
1340
+ packages wrote is still refused when it is one suite's policy, a redeclaration of a type another
1341
+ `@orkestrel` package already publishes, or a race; a shape few packages wrote still ships when the
1342
+ mechanism is a contract every consumer has to implement identically.
1343
+
1344
+ The journey layer in `src/browser` is that second case. It is what `orkestrel-prove-journey`
1345
+ requires every browser workspace to implement, and a workspace writing its own copy of it writes a
1346
+ slightly different resolver, a slightly different set of failure voices, and a journey that reads as
1347
+ if it proved something it did not. Publishing it once is what keeps those implementations identical.
1348
+ Touching the DOM buys nothing on its own: the browser candidates the survey raised are ruled one at
1349
+ a time in the following table, and some of them ship while others do not.
1350
+
1351
+ A member is one implementation group carried by one package, under whatever name that package
1352
+ spells it and whether it exports the helper or declares it inside a test file. Repeated calls routed
1353
+ through one shared implementation stay one member, and a set of adversarial values fed through one
1354
+ totality loop is one member rather than one per value. Read the first column as the group rather
1355
+ than as an export: **nothing in this section is importable**, and the only names you can install are
1356
+ in [Surface](#surface).
1357
+
1358
+ The table records the evidence and the ruling for each candidate the fleet survey raised. Revisit a
1359
+ row when the candidate's shape changes, when a native or declared primitive appears that covers it,
1360
+ or when a consumer appears the ruling did not consider.
1361
+
1362
+ | Candidate | Ruling | Why |
1363
+ | ------------------------------------------------------------------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1364
+ | A recorder map over an emitter's events, with its map, event-map, subscriber, and totality types | Ships | It ships as `createRecorders`, with `RecorderMap` and `EventSourceInterface` beside it. Inference is what the earlier refusal turned on, and the shape improves it without settling it: a source parameter typed `EventSourceInterface<TMap>` is an inference site, so a call against one names no type argument, while a concrete class supplies none and the call names both. A keying limit survives that: `TName` derives from the events array's element type, so an array declared with a wider union than its contents keys the map past the events actually listed, and [Bounds a shipped helper carries](#bounds-a-shipped-helper-carries) states what to pass instead. A published signature still cannot import a consumer's event map, so the interface asks for the subscribe half alone and the consumer's own map is what it is instantiated with. |
1365
+ | Hostile guard-input sets | Ships | `form`, `table`, and `supervisor` each feed one adversarial set through total readers, and the set is a mechanism rather than a policy: a guard's own contract decides the answers, not the corpus. It ships as `createHostileValues`; its members do not each become a factory, and every member carries a naive-reader negative control. |
1366
+ | Raw invocation — `invokeRaw` | Ships | It ships as `invokeUnchecked`, with `readProperty` beside it for the read. Native `Reflect.apply` still makes the call; what these add is the boundary — a callability refusal before the call, a target refusal before the read, and one named place where an unchecked runtime result meets the type its caller claims. The claim stays the caller's, and so does the guard that narrows what came back. Without them a consumer that bans `as` cannot drive a foreign object at all. |
1367
+ | Condition polling — wall-clock predicate loops | Ships | The wait-family contract states the distinction: the no-polling architecture law governs a product's idle wakeup, and a test instrument waiting on a fact another process produces has no event to park on. It ships as `waitForCondition`. `retryUntil` ships on the same reading, because retrying a real operation is not re-reading a predicate; and where an event does exist, `waitForEvent` is the door. |
1368
+ | Deep nesting beyond a guard's cap | Refused | `table` builds a record chain and `supervisor` builds nested arrays. The two nest different containers, so one shared factory needs a selector argument that changes the construction algorithm — a mode switch rather than a mechanism. |
1369
+ | Canonical wire fixpoint assertions | Refused | `form` and `table` each serialize, parse untrusted JSON, serialize again, and compare exact bytes. The comparison is an assertion over the consumer's own codecs rather than a reusable mechanism, so the shape stays consumer-local and [Prove a wire fixpoint](#prove-a-wire-fixpoint) publishes the pattern instead. |
1370
+ | Numeric corpora, hostile-key tables, and deep-freeze | Refused | A numeric corpus or a hostile-object table is test policy — what a given suite decided to check — rather than a mechanism, and one factory covering the variants would need a mode argument. `createHostileValues` ships because a guard's totality is a property of the guard; these encode a decision about coverage. |
1371
+ | Clearing web storage between tests | Ships | Emptying local and session storage together is one mechanism, and the `afterEach` hook that must run after a failed test too is where every browser suite needs it. It ships as `clearStorage`. |
1372
+ | Class-ancestry orphan detection | Ships | A rendered element carrying a child class with no container class above it is a real invariant a stylesheet cannot state, and the check is mechanism when the class names are parameters rather than one framework's. It ships as `extractOrphans(root, child, parent)`. |
1373
+ | A DOM element builder | Ships | It ships as `build` for the element and `mount` for the attachment, and `render` widened to take a tag and its class list as well as markup. A class list, a text, and an attribute map are what a fixture actually varies, and expressing that variation through markup means assembling a string. Nothing here assembles a tree one call at a time: a fixture with children is still written as markup. |
1374
+ | A surface digest — `describeSurface` | Refused | Its digest format is one workspace's policy about what a summary of a surface contains, and it is assembled from the excluded `extractControls` besides. `describeTree` and `describeFocus` publish the readings a digest is built from instead. |
1375
+ | A control extractor — `extractControls` | Refused | Generalized past its one caller it is a wrapper over `querySelectorAll` that adds no boundary, invariant, composition, or narrower contract, which is what the superfluous-wrapper rule refuses. |
1376
+ | Text resolution by selector — `resolveText` | Refused | The journey-layer contract is the one it breaks: a journey verb resolves its own target from a role and an accessible name, and one that takes a selector turns a journey into a description of the markup. Taking a node the test already holds is a different thing, which is what the element readers do; `findRule` takes a selector because its subject is the stylesheet rather than a target to act on. |
1377
+ | A hand-driven timer — `terminal`, `toolbox` | Refused | `toolbox` runtime-depends on `terminal`, so the two are one implementation rather than independent demand. The shape is also `@orkestrel/terminal`'s published `TimerHandler`, which a copy here would redeclare unversioned and hand consumers a second incompatible type. |
1378
+ | A hand-driven clock — `mcp`, `middleware` | Refused | `AGENTS.md` bans replacing the host clock outright, so publishing one from the fleet's own test package would sanction across every workspace the substitution those rules refuse. `waitForDelay` waits on a real host timer and `waitForCondition` bounds a real elapsed interval with `performance.now()`. |
1379
+ | A reserve-then-release port picker | Refused | It binds a port, closes it, and hands the number to a child that binds it again, and the window between that close and that rebind is a race another process on the host can win. Have the child bind `0` and report back the port it was given; `createLoopback` does exactly that for a server the test owns itself. |
1380
+ | An abort-signal wait — `waitForAbort` | Ships | It ships as `waitForAbort`. Every bounded member still takes `WaitOptions.signal` and rejects with the signal's own reason, so a bounded wait needs nothing here; this answers the other case, where the abort is itself the fact the test waits for. It parks on a one-shot listener with no timer and no budget, so a signal that never aborts is the caller's own deadlock rather than a timeout this could name. |
1381
+ | Abort-signal instrumentation | Ships | It ships as `createSignal`. A recorder handed to `addEventListener('abort', …)` still records what one listener heard; what no recorder can answer is how many listeners stand on the signal at this moment, which is the question a leak asks. The instrumented signal counts its own abort registrations, keyed by the original callback and the capture mode, so a helper that removes what it added proves the removal. A registration leaves the tally on removal, on a one-shot delivery, and when a signal scoping it aborts, which is what makes the reading a live tally rather than an install count. |
1382
+ | An outcome triple — a produced arm, a failed arm, and their union | Ships | It ships as `Success`, `Failure`, and `Result`. The rule governing it permits a local declaration only where no declared dependency already carries one, and this package declares no runtime dependency at all, so it cannot import `@orkestrel/contract`'s. That buys a divergence a consumer holding both packages meets: the two `Success<T>` declarations carry identical members and so do the two `Failure<E>` declarations, while `@orkestrel/test`'s `Result<T, E = Error>` defaults its failure type to `Error` and `@orkestrel/contract`'s `Result<T, E = unknown>` leaves it `unknown`. No signature published here returns one — `retryUntil` reads the type internally — so a workspace holding both packages takes its outcome from `@orkestrel/contract` and reaches for this one only where the value came from this package. |
1383
+ | A statechart transition table and its runner | Ships | It ships as `StateTransition` and `StateScenario`, driven by `executeScenario` and `executeScenarios`, with `STATECHART_ATTRIBUTES` and `STATECHART_STATUSES` for the harness a browser workspace renders. `elements` and `veneer` each declare the field-identical pair of interfaces in their own setup file, so the fleet already writes this twice and a third copy drifts the moment one of them adds a phase. The runner ships in its walking form rather than its registering one: a package helper registers no test, so `describe` and `it.each` stay in the workspace and this drives whatever rows it is handed. The row's name is what a failure carries, because a table's rows run under one test name and a bare assertion message never says which row produced it. No published package declares a generic transition record or a closure-walking runner. `@orkestrel/workflow` names a task's behavior with a string and sequences structurally, so it neither takes a scenario's closures nor drives `arrange`, `act`, and `assert` in order, and adopting it would move this package off layer 0 and pull that package's whole runtime graph into every consumer's test install. `STATECHART_STATUSES` names a harness's reported run state rather than a task's derived status, so it does not restate `LifecycleStatus`. `STATECHART_ATTRIBUTES` is the fleet contract the journey skill's statechart reference fixes for every harness and every gate, so it is a mechanism the fleet shares rather than one suite's policy. |
1384
+
1385
+ `ScratchInterface`'s own members were ruled the same way, and coherence rather than demand decided
1386
+ them. `ensure` ships because it is the one member that produces an empty directory — `write` always
1387
+ creates a file. `names` and `link` ship because a fixture that seeds a tree has to list it and to
1388
+ plant the link the [threat model](#threat-model) names. `remove` ships because `write`, `ensure`, and
1389
+ `link` each create something and nothing took one of them back short of `destroy()`. `has` renames
1390
+ the `exists` this interface already carried, so it was never a candidate, and `path`, `write`,
1391
+ `read`, and `destroy` are what an owned directory is rather than candidates at all.
1392
+
1393
+ Some shapes the fleet repeats often are refused anyway, because a primitive already covers them. An
1394
+ error-recording wrapper is a short delegate to the recorder that already ships. A deferred gate is
1395
+ native `Promise.withResolvers`. A shared random seed is a bare literal.
1396
+
1397
+ The remaining local candidates are element and text requiring (redundant under
1398
+ `noUncheckedIndexedAccess`), unique naming (hidden module state), socket flushing (an unjustified
1399
+ constant), the throwing variant of `captureError`, and pattern requiring. Every product-specific
1400
+ peer, protocol fixture, and domain builder stays in the package that owns it.
1401
+
1402
+ ### Bounds a shipped helper carries
1403
+
1404
+ A shipped helper can still decline the question it looks like it answers. Each bound here belongs to
1405
+ the helper rather than to the host, and each names what to reach for instead.
1406
+
1407
+ - **`parseCSSColor` resolves an undeclared token to the inherited color.** A `var()` naming a custom
1408
+ property nothing declares is not a parse failure: the cascade accepts it and computes the
1409
+ inherited color, so `parseCSSColor('var(--absent)')` hands back channels rather than `undefined`.
1410
+ Read `readToken` or `readRootToken` where a missing token is the subject.
1411
+ - **`createLoopback` cannot take back an upgraded socket.** A server that claims an upgrade keeps
1412
+ that connection, detached from the server itself, so `destroy()`'s `closeAllConnections` never
1413
+ reaches it and the close waits on it. A fixture that upgrades records the socket its `upgrade`
1414
+ handler took and destroys it before destroying the loopback.
1415
+ - **`destroyScratch`'s behavior under a permission hold is unproven where the hold cannot bind.** The
1416
+ retry path is proven against a real host refusal, and a host that produces no such refusal cannot
1417
+ exercise it: a container running as uid `0` bypasses the access check the mode bits describe, so
1418
+ the suite reads a runtime probe and skips that case rather than asserting either answer. Read
1419
+ `supportsMode` for the narrower question of whether the bits are stored at all.
1420
+ - **`createRecorders` keys its map from the events array's declared element type.** An array declared
1421
+ with a wider union than its contents widens `TName` past the events actually listed, so the omitted
1422
+ key reads `undefined` at runtime under a non-optional type and `isRecorderMapComplete` still reports
1423
+ `true`, because it checks the events it was given rather than the type it was keyed by. Pass a
1424
+ literal array or a tuple, so the element type is exactly what was listed.
1425
+ - **`readProperty`'s `TypeError` names the target, never the read.** It refuses a target that is
1426
+ neither an object nor a function before it reads anything, and a getter that throws on an accepted
1427
+ target hands that throw straight to the caller. Wrap the call in `captureError` where a hostile
1428
+ getter is the subject.
1429
+ - **`readPixels` reports a measured contribution rather than a parsed length.** A resolved value
1430
+ carrying no leading number — `'auto'`, `'none'`, `''` — reads as `0`, because none of them
1431
+ contributes a pixel to what a reader sees, so a caller cannot tell an unparsable value from a
1432
+ genuine zero. Read the text with `readStyle` where that distinction is the subject.
1433
+
1434
+ ## Patterns
1435
+
1436
+ ### Record calls without a spy
1437
+
1438
+ A recorder is a real callback, so the code under test is driven exactly as a consumer drives it.
1439
+ `clear()` truncates in place, which is what lets a captured reference stay correct.
1440
+
1441
+ ```ts
1442
+ import { createRecorder } from '@orkestrel/test'
1443
+
1444
+ const recorder = createRecorder<[id: string, size: number]>()
1445
+ recorder.handler('a', 1)
1446
+ recorder.handler('b', 2)
1447
+ recorder.count // 2
1448
+ recorder.calls // [['a', 1], ['b', 2]]
1449
+
1450
+ const captured = recorder.calls
1451
+ recorder.clear()
1452
+ recorder.count // 0
1453
+ captured.length // 0 — the same array, truncated
1454
+ recorder.handler('c', 3)
1455
+ recorder.count // 1 — still usable
1456
+ ```
1457
+
1458
+ ### Record an emitter's events
1459
+
1460
+ One call subscribes a recorder to each event you name and hands back a map keyed by those names. In
1461
+ the following fence, `createLoader` returns a loader that emits `read` for every file it reads and `fail`
1462
+ for every file it cannot.
1463
+
1464
+ ```ts
1465
+ import { createRecorders } from '@orkestrel/test'
1466
+
1467
+ type LoaderEvents = {
1468
+ readonly read: readonly [path: string]
1469
+ readonly fail: readonly [reason: string, retryable: boolean]
1470
+ }
1471
+
1472
+ const loader = createLoader()
1473
+
1474
+ // A concrete class is no inference site for the event map, so this call names both type arguments.
1475
+ const recorders = createRecorders<LoaderEvents, 'read' | 'fail'>(loader, ['read', 'fail'])
1476
+
1477
+ await loader.scan('src')
1478
+
1479
+ recorders.read.count // 2
1480
+ recorders.read.calls // [['src/index.ts'], ['src/types.ts']]
1481
+ recorders.fail.calls // [['locked', true]]
1482
+ ```
1483
+
1484
+ Where the source arrives as a parameter typed `EventSourceInterface<TMap>`, the same call infers both
1485
+ type arguments and names neither.
1486
+
1487
+ ```ts
1488
+ import type { EventSourceInterface } from '@orkestrel/test'
1489
+ import { createRecorders } from '@orkestrel/test'
1490
+
1491
+ function record(source: EventSourceInterface<LoaderEvents>) {
1492
+ // `TMap` infers from the parameter and `TName` from the array.
1493
+ return createRecorders(source, ['read', 'fail'])
1494
+ }
1495
+ ```
1496
+
1497
+ The interface asks for the subscribe half alone, so any source carrying a typed `on` satisfies it,
1498
+ whatever else it publishes. A duplicate event name installs a fresh recorder for each occurrence and
1499
+ the map keeps the last one, so name each event once unless the duplicate subscription is the subject.
1500
+
1501
+ ### Count the listeners on a signal
1502
+
1503
+ `createSignal` hands back a real `AbortController`, its signal, and the tally of abort listeners
1504
+ standing on that signal at this moment. The tally is what a leak is asserted against: a recorder
1505
+ reports what one listener heard, and only the tally reports what is still installed.
1506
+
1507
+ ```ts
1508
+ import { createRecorder, createSignal, waitForAbort } from '@orkestrel/test'
1509
+
1510
+ const instrument = createSignal()
1511
+ instrument.count // 0
1512
+
1513
+ const heard = createRecorder<[event: Event]>()
1514
+ instrument.signal.addEventListener('abort', heard.handler)
1515
+ instrument.count // 1
1516
+ instrument.signal.addEventListener('abort', heard.handler)
1517
+ instrument.count // 1 — the same callback and capture mode register once
1518
+
1519
+ const parked = waitForAbort(instrument.signal)
1520
+ instrument.count // 2
1521
+
1522
+ const scoped = createRecorder<[event: Event]>()
1523
+ const lifetime = new AbortController()
1524
+ instrument.signal.addEventListener('abort', scoped.handler, { signal: lifetime.signal })
1525
+ instrument.count // 3
1526
+
1527
+ lifetime.abort()
1528
+ instrument.count // 2 — the scoped registration left when its own lifetime aborted
1529
+
1530
+ instrument.controller.abort()
1531
+ await parked
1532
+ instrument.count // 1 — the one-shot listener left the tally when it fired
1533
+ heard.count // 1
1534
+ scoped.count // 0 — its lifetime ended before the abort it was waiting for
1535
+
1536
+ instrument.signal.removeEventListener('abort', heard.handler)
1537
+ instrument.count // 0 — removal takes the original callback, not the wrapper
1538
+ ```
1539
+
1540
+ A registration leaves the tally on removal, on a one-shot delivery, and when a signal scoping it
1541
+ aborts. An `addEventListener` call whose scope has already aborted installs nothing and records
1542
+ nothing, so it never enters the tally at all.
1543
+
1544
+ Read `instrument.count` where you want the reading. It is a getter over the live registrations, so a
1545
+ number pulled out by destructuring is the tally as it stood at that line and stops tracking.
1546
+
1547
+ ### Number the resources a fixture allocates
1548
+
1549
+ `createResourceFactory` answers the question a leak test asks — what was created, what was destroyed,
1550
+ and in what order — without the fixture keeping its own arrays.
1551
+
1552
+ ```ts
1553
+ import { createResourceFactory } from '@orkestrel/test'
1554
+
1555
+ const resources = createResourceFactory()
1556
+
1557
+ const first = resources.create() // 1
1558
+ const second = resources.create() // 2
1559
+ resources.destroy(first)
1560
+
1561
+ resources.created.calls // [[1], [2]]
1562
+ resources.destroyed.calls // [[1]]
1563
+ resources.created.count - resources.destroyed.count // 1 — what the fixture still holds
1564
+ ```
1565
+
1566
+ The id is the creation record's length plus one, so it counts allocations rather than live resources
1567
+ and a destroyed id is never reissued. `destroy` records the id it was given and frees nothing, so it
1568
+ accepts an id that was never created and an id destroyed twice; assert on the record rather than
1569
+ expecting a refusal. Clearing `created` restarts the numbering at `1`, which is why the recorders are
1570
+ read rather than cleared mid-test.
1571
+
1572
+ ### Capture a throw, then assert on it
1573
+
1574
+ Assert on what came back, not on the fact that something came back. `undefined` is both "nothing was
1575
+ thrown" and "`undefined` was thrown", and the helper cannot tell you which.
1576
+
1577
+ ```ts
1578
+ import { captureError } from '@orkestrel/test'
1579
+
1580
+ const thrown = captureError(() => JSON.parse('{'))
1581
+ thrown instanceof SyntaxError // true
1582
+
1583
+ captureError(() => 'fine') // undefined — the thunk completed
1584
+ captureError(() => {
1585
+ throw undefined
1586
+ }) // undefined — the same result, from a thunk that threw
1587
+
1588
+ // An async thunk returns a rejected promise instead of throwing, so nothing is captured
1589
+ // and the rejection escapes. Await the call and catch it yourself.
1590
+ ```
1591
+
1592
+ ### Narrow without `!` or `as`
1593
+
1594
+ `requireValue` passes a falsy value through unchanged and throws only on `null` or `undefined`.
1595
+
1596
+ ```ts
1597
+ import { requireValue } from '@orkestrel/test'
1598
+
1599
+ requireValue(0) // 0
1600
+ requireValue('') // ''
1601
+ requireValue(false) // false
1602
+ requireValue(undefined) // throws Error: Value is required
1603
+ requireValue(null, 'port is required') // throws Error: port is required
1604
+ ```
1605
+
1606
+ ### Cross an unchecked boundary
1607
+
1608
+ `invokeUnchecked` and `readProperty` are the door out of a typed program and into a value nothing
1609
+ declares. Each refuses its own argument first, makes the unchecked access, and hands the result back
1610
+ under the type the caller named. In the following fence, `handle` comes back from a foreign module that
1611
+ ships no declarations.
1612
+
1613
+ ```ts
1614
+ import { invokeUnchecked, readProperty } from '@orkestrel/test'
1615
+
1616
+ const close: unknown = readProperty(handle, 'close')
1617
+ invokeUnchecked<void>(handle, close, [])
1618
+
1619
+ const label = readProperty<unknown>(handle, 'label')
1620
+ typeof label === 'string' // narrow what came back before asserting on it
1621
+
1622
+ readProperty<string>(handle, 'absent') // undefined — nothing checks that the key is there
1623
+ invokeUnchecked<void>(handle, 'close', []) // throws TypeError: Method must be callable
1624
+ readProperty<string>(null, 'label') // throws TypeError: Target must be an object or function
1625
+ ```
1626
+
1627
+ The claim is yours. `readProperty<string>` narrows nothing at runtime, so a test that goes on to
1628
+ assert on the value reads it back as `unknown` and guards it, and a test that only drives the foreign
1629
+ object claims `void` and asserts on what the driving produced. That is the whole reason these ship:
1630
+ a consumer that bans `as` and `!` still has to reach a value the compiler cannot see, and this is the
1631
+ one named place where that happens.
1632
+
1633
+ ### Flatten headers into one record
1634
+
1635
+ `flattenHeaders` turns any header initializer into a frozen plain record, so a header assertion is
1636
+ one `toStrictEqual` rather than a walk. Hand it a real response's own `headers`, a record, or an
1637
+ entries array.
1638
+
1639
+ ```ts
1640
+ import { flattenHeaders } from '@orkestrel/test'
1641
+
1642
+ flattenHeaders({ 'Content-Type': 'application/json' }) // { 'content-type': 'application/json' }
1643
+
1644
+ flattenHeaders([
1645
+ ['x-run', '1'],
1646
+ ['X-Run', '2'],
1647
+ ]) // { 'x-run': '1, 2' } — one name, its values combined
1648
+
1649
+ Object.isFrozen(flattenHeaders(new Headers({ accept: 'text/plain' }))) // true
1650
+ ```
1651
+
1652
+ The normalization is the host `Headers` constructor's own, so a record, an entries array, and a
1653
+ `Headers` value all answer the same way, and a name's case never decides whether an assertion
1654
+ matches. `HeadersSource` is that accepted input, derived from the constructor rather than named from
1655
+ a library, so it resolves the same in every project this package compiles under.
1656
+
1657
+ ### Drain an async source
1658
+
1659
+ `collect` and `collectStream` drain an async iterable and a readable stream into arrays, in yield
1660
+ order.
1661
+
1662
+ ```ts
1663
+ import { collect, collectStream } from '@orkestrel/test'
1664
+
1665
+ async function* letters() {
1666
+ yield 'a'
1667
+ yield 'b'
1668
+ }
1669
+
1670
+ await collect(letters()) // ['a', 'b']
1671
+
1672
+ const stream = new ReadableStream<number>({
1673
+ start(controller) {
1674
+ controller.enqueue(1)
1675
+ controller.enqueue(2)
1676
+ controller.close()
1677
+ },
1678
+ })
1679
+
1680
+ await collectStream(stream) // [1, 2]
1681
+ ```
1682
+
1683
+ ### Wait for a named condition
1684
+
1685
+ Pick the member by what publishes the fact you are waiting for. Reach for `waitForCondition` when
1686
+ nothing publishes it and the test has to read for it. Reach for `retryUntil` when the reading itself
1687
+ is the value you want and producing it can fail. Reach for `waitForEvent` when the fact does publish
1688
+ an event, because parking on it beats reading for it. The wait-family contract states why a test
1689
+ instrument polls where a product must not.
1690
+
1691
+ Name the wait in every case. The description is what the timeout message carries, and a wait nobody
1692
+ described times out saying nothing about what failed.
1693
+
1694
+ In the following fence, `isBuilt` reports whether a build running outside the test has produced its
1695
+ artifact, `origin` is the URL of a server the test started, and `child` is a process it spawned.
1696
+
1697
+ ```ts
1698
+ import { retryUntil, waitForCondition, waitForEvent } from '@orkestrel/test'
1699
+
1700
+ // Nothing publishes an event for "the build finished", so read until the reading holds.
1701
+ await waitForCondition('artifact is on disk', () => isBuilt(), { budget: 2000, interval: 25 })
1702
+
1703
+ // The reading itself is the value you want, and the producer throws until the port answers.
1704
+ const body = await retryUntil(
1705
+ 'health endpoint answers',
1706
+ async () => (await fetch(`${origin}/health`)).text(),
1707
+ (text) => text === 'ok',
1708
+ { budget: 2000, attempts: 20 },
1709
+ )
1710
+ body // 'ok' — the first produced value the predicate accepted
1711
+
1712
+ // An event exists, so park on it. The cleanup the subscriber returns runs on delivery, on timeout,
1713
+ // and on abort alike.
1714
+ const [code] = await waitForEvent<[number]>((listener) => {
1715
+ child.on('exit', listener)
1716
+ return () => {
1717
+ child.off('exit', listener)
1718
+ }
1719
+ }, 'child exits')
1720
+ code // 0
1721
+ ```
1722
+
1723
+ The two throw the other way round, which is what makes the pair worth having. A condition that
1724
+ throws is a broken reading, and taking it again does not make it true, so `waitForCondition` hands
1725
+ the throw straight on. A producer that throws is what a retry exists for, so `retryUntil` counts it
1726
+ as an unsatisfied attempt and hands the last one to the exhaustion error's `cause`.
1727
+
1728
+ ```ts
1729
+ import { retryUntil, waitForCondition } from '@orkestrel/test'
1730
+
1731
+ const unreachable = new Error('registry unreachable')
1732
+
1733
+ // waitForCondition: the condition's throw is the rejection, by identity.
1734
+ const refused: unknown = await waitForCondition('never holds', () => {
1735
+ throw unreachable
1736
+ }).catch((reason: unknown) => reason)
1737
+ refused === unreachable // true
1738
+
1739
+ // retryUntil: the producer's throw is an attempt, and the last one rides the exhaustion error.
1740
+ const exhausted: unknown = await retryUntil(
1741
+ 'registry answers',
1742
+ (): string => {
1743
+ throw unreachable
1744
+ },
1745
+ () => true,
1746
+ { budget: 30, interval: 10 },
1747
+ ).catch((reason: unknown) => reason)
1748
+ if (exhausted instanceof Error) {
1749
+ exhausted.message.startsWith('Retry "registry answers" did not succeed within 30ms') // true
1750
+ exhausted.cause === unreachable // true — the last producer throw, kept as the cause
1751
+ }
1752
+ ```
1753
+
1754
+ Every bounded member takes an `AbortSignal` and rejects with the signal's own reason, so one
1755
+ controller ends a whole file's waits. A budget of `0` still permits the immediate first reading, and
1756
+ a bound that is not finite and non-negative is refused before anything is read.
1757
+
1758
+ ### Copy a JSON value
1759
+
1760
+ This demonstration builds an interface-typed value, copies it through JSON serialization, and
1761
+ shows the guard `roundTripJSON` raises on a non-finite member.
1762
+
1763
+ ```ts
1764
+ import { captureError, roundTripJSON } from '@orkestrel/test'
1765
+
1766
+ // An interface, not a type alias: the bound is a projection rather than an index signature, so an
1767
+ // interface-typed value copies and keeps its own type.
1768
+ interface Snapshot {
1769
+ readonly name: string
1770
+ readonly tags: readonly string[]
1771
+ }
1772
+
1773
+ const original: Snapshot = { name: 'a', tags: ['x'] }
1774
+ const copy: Snapshot = roundTripJSON(original)
1775
+ copy // { name: 'a', tags: ['x'] }
1776
+ copy.tags === original.tags // false — fresh references all the way down
1777
+
1778
+ // roundTripJSON(new Date()) — does not compile; a member JSON cannot carry is typed `never`.
1779
+
1780
+ roundTripJSON(-0) // 0 — JSON has no negative zero
1781
+ captureError(() => roundTripJSON({ a: [{ b: NaN }] }))
1782
+ // Error: JSON values must contain finite numbers — at any depth, rather than a silent null
1783
+ ```
1784
+
1785
+ ### Prove a guard is total
1786
+
1787
+ Every member throws on a naive read or violates a naive structural assumption. A total guard
1788
+ survives every member without throwing. Whether it accepts or refuses one is that guard's own
1789
+ contract. Run the whole corpus, attribute a throw or wrong answer to the loop index, and compare
1790
+ with the answer that guard's contract requires for that member.
1791
+
1792
+ The fence is the body of a parameterized consumer test. `guard` is the total guard under test, and
1793
+ `expected` is its readonly list of required answers in corpus order.
1794
+
1795
+ ```ts
1796
+ import { expect } from 'vitest'
1797
+ import { createHostileValues } from '@orkestrel/test'
1798
+
1799
+ const values = createHostileValues()
1800
+ expect(expected.length).toBe(values.length)
1801
+
1802
+ for (const [index, value] of values.entries()) {
1803
+ let accepted: boolean | undefined
1804
+ expect(() => {
1805
+ accepted = guard(value)
1806
+ }, `hostile value ${index}`).not.toThrow()
1807
+ expect(accepted, `hostile value ${index}`).toBe(expected[index])
1808
+ }
1809
+ ```
1810
+
1811
+ The corpus is the positive proof input. Keep a negative control for every member too: perform the
1812
+ naive read or the naive structural reading that member is meant to break, and prove it answers the
1813
+ way the member's own hostility says. Without that control, an inert value can make the totality loop
1814
+ look stronger without exercising another hostile boundary.
1815
+
1816
+ This package's own suite carries one control per member, in corpus order, and each names the reading
1817
+ that member breaks:
1818
+
1819
+ - the self-referential record — `JSON.stringify` throws on the cycle;
1820
+ - the revoked proxy — `Reflect.ownKeys` throws;
1821
+ - the property proxy — reading a named property throws;
1822
+ - the key proxy — `Reflect.ownKeys` throws;
1823
+ - the prototype proxy — `Object.getPrototypeOf` throws;
1824
+ - the null-prototype record — a direct `hasOwnProperty` call throws;
1825
+ - the array-target proxy — `Array.isArray` answers `true` and an index read throws;
1826
+ - the self-referential array — `JSON.stringify` throws on the cycle;
1827
+ - the sparse array — its enumerable keys are fewer than its `length`, and nothing throws;
1828
+ - the hidden-key record — its enumerable keys are fewer than its own keys, and nothing throws;
1829
+ - the named getter — reading the property it declares throws.
1830
+
1831
+ The sparse array and the hidden-key record are why the corpus is not described as a set of throwing
1832
+ values: each answers a naive reading with a wrong number rather than with an exception, which is the
1833
+ failure a totality loop alone would not surface.
1834
+
1835
+ ### Prove a wire fixpoint
1836
+
1837
+ A wire fixpoint proves that a consumer's parser and serializer reproduce canonical bytes after the
1838
+ wire has crossed an untrusted JSON boundary. This is **not** `roundTripJSON`: that helper makes a
1839
+ typed JSON copy and returns the copied value. No wire-fixpoint export exists, because the comparison
1840
+ is the consumer's assertion over its own codecs. In this consumer-test fence, `schema` is the local
1841
+ fixture and `parseSchema` and `serializeSchema` are its local codecs.
1842
+
1843
+ ```ts
1844
+ import { expect } from 'vitest'
1845
+ import { requireValue } from '@orkestrel/test'
1846
+
1847
+ const wire = JSON.stringify(serializeSchema(schema))
1848
+ const received = requireValue(parseSchema(JSON.parse(wire)))
1849
+
1850
+ expect(JSON.stringify(serializeSchema(received))).toBe(wire)
1851
+ ```
1852
+
1853
+ ### Drive a statechart table
1854
+
1855
+ A statechart table is a row per transition, and a row is the transition plus the three phases that
1856
+ prove it: `arrange` puts the entity into `from`, `act` applies the `event`, and `assert` reads the
1857
+ entity for `to`. `executeScenarios` walks the table and hands each row a context of its own; it
1858
+ registers nothing, so `describe` and `it` stay where you write them. In the following fence,
1859
+ `Disclosure` is the entity under test, and it is closed until something shows it.
1860
+
1861
+ ```ts
1862
+ import type { StateScenario } from '@orkestrel/test'
1863
+ import { executeScenarios } from '@orkestrel/test'
1864
+ import { expect, it } from 'vitest'
1865
+
1866
+ type DisclosureState = 'closed' | 'open'
1867
+ type DisclosureEvent = 'show' | 'hide'
1868
+
1869
+ interface DisclosureContext {
1870
+ readonly disclosure: Disclosure
1871
+ }
1872
+
1873
+ const SCENARIOS: ReadonlyArray<StateScenario<DisclosureState, DisclosureEvent, DisclosureContext>> =
1874
+ [
1875
+ {
1876
+ transition: { name: 'closed opens on show', from: 'closed', event: 'show', to: 'open' },
1877
+ arrange(context, state) {
1878
+ if (state === 'open') context.disclosure.show()
1879
+ },
1880
+ act(context, event) {
1881
+ if (event === 'show') context.disclosure.show()
1882
+ else context.disclosure.hide()
1883
+ },
1884
+ assert(context, state) {
1885
+ expect(context.disclosure.state).toBe(state)
1886
+ },
1887
+ },
1888
+ // One row per transition. Each row reuses the three phases shown earlier.
1889
+ ]
1890
+
1891
+ it('walks the disclosure statechart', async () => {
1892
+ await executeScenarios(SCENARIOS, () => ({ disclosure: new Disclosure() }))
1893
+ })
1894
+ ```
1895
+
1896
+ Both unions are the entity's own vocabulary, so a row naming a state or an event the entity does not
1897
+ have fails to typecheck rather than at runtime. Each phase reads its subject from its own parameters
1898
+ rather than from the row, which is what lets one set of phases serve every row in the table.
1899
+
1900
+ The rows run one after another, because a statechart's rows drive one entity and a parallel run
1901
+ would have them arranging over each other. The run stops at the first row that fails, and the row's
1902
+ name opens the message — a table runs under one test name, so a bare assertion message never says
1903
+ which row produced it. A builder that refuses stops the run the same way and its row's name opens
1904
+ that message too, because a fixture is built under the same test name its phases run under.
1905
+
1906
+ ```ts
1907
+ const MISMATCHED: ReadonlyArray<
1908
+ StateScenario<DisclosureState, DisclosureEvent, DisclosureContext>
1909
+ > = [
1910
+ {
1911
+ transition: { name: 'show leaves it closed', from: 'closed', event: 'show', to: 'closed' },
1912
+ // The same three phases. Nothing about the row is malformed; the `to` state is unreachable.
1913
+ },
1914
+ ]
1915
+
1916
+ await executeScenarios(MISMATCHED, () => ({ disclosure: new Disclosure() }))
1917
+ // Error: show leaves it closed: expected 'open' to be 'closed'
1918
+
1919
+ await executeScenarios(MISMATCHED, () => {
1920
+ throw new Error('no fixture')
1921
+ })
1922
+ // Error: show leaves it closed: build refused
1923
+ ```
1924
+
1925
+ Whatever the phase threw arrives as that error's `cause`, by identity, so an assertion's own detail
1926
+ survives the renaming. A phase that throws something other than an `Error` is named by its type —
1927
+ `arrange refuses: threw a non-error object value` — and the value itself is still the `cause`. A
1928
+ builder's refusal arrives as the `cause` the same way, and the phases of the row it was building for
1929
+ never start.
1930
+
1931
+ Drive one row on its own with `executeScenario`, which takes the context rather than building it.
1932
+
1933
+ A harness that renders the same table in a browser publishes its progress through attributes, and
1934
+ `STATECHART_ATTRIBUTES` and `STATECHART_STATUSES` are the names a gate polls from outside the page.
1935
+
1936
+ ```ts
1937
+ import { STATECHART_ATTRIBUTES, STATECHART_STATUSES } from '@orkestrel/test'
1938
+
1939
+ STATECHART_ATTRIBUTES.status // 'data-statechart-status'
1940
+ STATECHART_ATTRIBUTES.scenario // 'data-statechart-scenario'
1941
+
1942
+ STATECHART_STATUSES[0] // 'pending' — carried until a run has a result for every row
1943
+ STATECHART_STATUSES.includes('running') // true
1944
+ ```
1945
+
1946
+ The harness writes the attributes onto its own markup: `status`, `passed`, `failed`, and `total` on
1947
+ its root, `scenario` and `result` on each row, `state` on the element rendering the entity's current
1948
+ state. A gate reads the root until `status` reads `passed` or `failed`, then reads the tally and
1949
+ names each row whose `result` reads `failed`. Neither side spells a `data-statechart-*` string of
1950
+ its own, so the two cannot drift apart.
1951
+
1952
+ ### Read a source inventory
1953
+
1954
+ The pairing `resolveRoot` and `readInventory` is what a guides-parity suite needs: the workspace
1955
+ root from `import.meta`, then the file map. The `readInventory` contract states what an exclusion
1956
+ matches; the last call that follows is the part that surprises people.
1957
+
1958
+ ```ts
1959
+ import { resolveRoot } from '@orkestrel/test'
1960
+ import { readInventory } from '@orkestrel/test/server'
1961
+
1962
+ // From tests/guides.test.ts, one directory up is the workspace root.
1963
+ const root = resolveRoot(import.meta)
1964
+
1965
+ Object.keys(readInventory(root, ['src/core'], { extensions: ['.ts'] }))
1966
+ // ['src/core/constants.ts', 'src/core/factories.ts', 'src/core/helpers.ts',
1967
+ // 'src/core/index.ts', 'src/core/types.ts', 'src/core/validators.ts']
1968
+
1969
+ // A named file is included whatever `extensions` says, so one call takes the root files a suite
1970
+ // needs and the source tree it walks.
1971
+ Object.keys(readInventory(root, ['package.json', 'src/core'], { extensions: ['.ts'] }))
1972
+ // ['package.json', 'src/core/constants.ts', 'src/core/factories.ts', 'src/core/helpers.ts',
1973
+ // 'src/core/index.ts', 'src/core/types.ts', 'src/core/validators.ts']
1974
+
1975
+ Object.keys(
1976
+ readInventory(root, ['src/core'], {
1977
+ extensions: ['.ts'],
1978
+ exclude: ['src/core/index.ts'],
1979
+ }),
1980
+ )
1981
+ // ['src/core/constants.ts', 'src/core/factories.ts', 'src/core/helpers.ts',
1982
+ // 'src/core/types.ts', 'src/core/validators.ts']
1983
+
1984
+ // A directory key takes every key below it.
1985
+ Object.keys(readInventory(root, ['src'], { extensions: ['.ts'], exclude: ['src/server'] }))
1986
+ // ['src/browser/constants.ts', 'src/browser/factories.ts', 'src/browser/helpers.ts',
1987
+ // 'src/browser/index.ts', 'src/browser/types.ts', 'src/core/constants.ts',
1988
+ // 'src/core/factories.ts', 'src/core/helpers.ts', 'src/core/index.ts', 'src/core/types.ts',
1989
+ // 'src/core/validators.ts']
1990
+
1991
+ // An exclusion also applies to a target you name, so naming one file below an excluded directory
1992
+ // does not reinstate it.
1993
+ readInventory(root, ['src/core/index.ts'], { extensions: ['.ts'], exclude: ['src/core'] })
1994
+ // {} — take the exception in a second call, and merge the two maps
1995
+ ```
1996
+
1997
+ ### Own a temporary directory
1998
+
1999
+ This demonstration builds a scratch directory seeded with a file, writes and reads inside it,
2000
+ refuses an escaping write, and nests one allocation inside another.
2001
+
2002
+ ```ts
2003
+ import { createScratch } from '@orkestrel/test/server'
2004
+
2005
+ const scratch = createScratch({ prefix: 'guide-', files: { 'src/index.ts': 'export {}\n' } })
2006
+
2007
+ scratch.read('src/index.ts') // 'export {}\n'
2008
+ scratch.has('src') // true
2009
+ scratch.read('src') // throws Error: Scratch path is a directory: src
2010
+ scratch.read('missing.ts') // undefined
2011
+ scratch.write('../escape.ts', '') // throws Error: Path outside scratch directory: ../escape.ts
2012
+
2013
+ // `write` answers the contained path it wrote, the way `ensure` and `link` answer theirs, so the
2014
+ // path goes straight to the code under test without joining it again.
2015
+ scratch.write('src/notes.ts', 'export {}\n') // `${scratch.path}/src/notes.ts`
2016
+
2017
+ // `ensure` is how you get an empty directory, because every `write` creates a file.
2018
+ scratch.ensure('empty')
2019
+ scratch.names() // ['empty', 'src']
2020
+ scratch.names('empty') // []
2021
+
2022
+ // `parent` puts the allocation somewhere other than the host temporary directory.
2023
+ const child = createScratch({ parent: scratch.path, prefix: 'child-' })
2024
+ scratch.names().length // 3 — 'empty', 'src', and the child allocation
2025
+ child.destroy()
2026
+ scratch.names().length // 2 — the child removed itself and nothing else
2027
+
2028
+ // `link` creates the symbolic link the threat model names, and `read` follows it. A directory
2029
+ // source runs on a host that creates no symbolic link too; see "Hosts that create no symbolic
2030
+ // link" for what such a host does with a file source.
2031
+ const outside = createScratch({ prefix: 'outside-', files: { 'read.ts': 'export {}\n' } })
2032
+ scratch.link('gate', outside.path) // `${scratch.path}/gate` — the link's own path, not its destination
2033
+ scratch.read('gate/read.ts') // 'export {}\n' — read through the link, at its destination
2034
+
2035
+ // A link pointing out of the allocation is resolved through, so a contained path acts outside it.
2036
+ scratch.ensure('gate/made') // `${scratch.path}/gate/made` — the lexical path, not the destination
2037
+ outside.names() // ['made', 'read.ts'] — the directory was made under `outside.path`
2038
+ scratch.names('gate') // ['made', 'read.ts'] — the same entries, listed through the link
2039
+
2040
+ // `link` acts at the final segment rather than through it, so `gate` is occupied.
2041
+ scratch.link('gate', outside.path) // throws Error: EEXIST: file already exists
2042
+
2043
+ // `has` reads the final segment without following it, and `read` follows it.
2044
+ scratch.link('dangling', 'missing.ts')
2045
+ scratch.has('dangling') // true — the link is there
2046
+ scratch.read('dangling') // undefined — what it points at is not
2047
+
2048
+ // `remove` takes one contained entry and acts at the final segment, so a link goes and whatever it
2049
+ // pointed at stays. A missing target is a no-op.
2050
+ scratch.remove('dangling')
2051
+ scratch.has('dangling') // false
2052
+ scratch.remove('missing.ts') // no throw — there was nothing there
2053
+ scratch.remove('src') // the directory and everything under it
2054
+ scratch.names() // ['empty', 'gate']
2055
+
2056
+ scratch.destroy()
2057
+ scratch.destroy() // no-op — destroy is idempotent
2058
+ outside.has('made') // true — destroy unlinks `gate` and leaves what it pointed at
2059
+ outside.destroy()
2060
+ ```
2061
+
2062
+ `destroy()` is synchronous, and it already outlasts the short `EPERM` a Windows host reports for a
2063
+ directory a recently exited child held as its working directory: `removeTree` retries that removal ten
2064
+ times 100 milliseconds apart, which bounds the blocking wait at roughly a second. Nothing extra is
2065
+ needed for a child the test has already reaped.
2066
+
2067
+ Reach for `destroyScratch` where the hold outlasts that second — a holder still running, a host
2068
+ still flushing, a network filesystem taking its time. It retries `destroy()` inside a budget that
2069
+ defaults to `10000` milliseconds at a `25` millisecond interval, awaits between attempts instead of
2070
+ blocking the thread, takes a `signal` that ends the wait early, and hands the host's own last
2071
+ refusal back as the exhaustion error's `cause` when the directory is never released. Every refusal
2072
+ is retried, not a named list of codes, so a fault no wait can clear costs the whole budget before it
2073
+ surfaces.
2074
+
2075
+ ```ts
2076
+ import { createScratch, destroyScratch } from '@orkestrel/test/server'
2077
+
2078
+ const workspace = createScratch({ prefix: 'build-' })
2079
+
2080
+ // The child that had `workspace.path` as its working directory is still shutting down.
2081
+ await destroyScratch(workspace) // resolves as soon as the host lets the directory go
2082
+ ```
2083
+
2084
+ ### Give everything back in one hook
2085
+
2086
+ Register the cleanup where you take the resource, then let one hook run all of it. The list reverses
2087
+ registration order, so each handler runs while what it depends on is still standing.
2088
+
2089
+ ```ts
2090
+ import { afterEach, it } from 'vitest'
2091
+ import { createTeardown } from '@orkestrel/test'
2092
+
2093
+ const teardown = createTeardown()
2094
+
2095
+ // This package registers no hook of its own, so the consumer writes this line once.
2096
+ afterEach(() => teardown.destroy())
2097
+
2098
+ it('runs its cleanup newest-first', async () => {
2099
+ const order: string[] = []
2100
+ teardown.add(() => {
2101
+ order.push('opened first')
2102
+ })
2103
+ teardown.add(async () => {
2104
+ await Promise.resolve()
2105
+ order.push('opened second')
2106
+ })
2107
+ teardown.count // 2
2108
+
2109
+ await teardown.destroy()
2110
+ order // ['opened second', 'opened first'] — reversed, and each awaited before the next
2111
+ teardown.count // 0 — the list is empty, so the hook shown earlier then runs nothing
2112
+ })
2113
+ ```
2114
+
2115
+ ### Answer a real request on a loopback port
2116
+
2117
+ This demonstration starts a real server on an ephemeral loopback port, fetches from it, and closes
2118
+ it idempotently.
2119
+
2120
+ ```ts
2121
+ import { createServer } from 'node:http'
2122
+ import { createLoopback } from '@orkestrel/test/server'
2123
+
2124
+ // The server is yours, so every route, header, and status stays yours.
2125
+ const server = createServer((_request, response) => {
2126
+ response.end('ok')
2127
+ })
2128
+
2129
+ const loopback = await createLoopback(server)
2130
+
2131
+ loopback.url === `http://127.0.0.1:${loopback.port}` // true — IPv4 loopback, no trailing slash
2132
+ loopback.port > 0 // true — the host picked it; this package neither picks nor reserves a number
2133
+
2134
+ const response = await fetch(loopback.url)
2135
+ await response.text() // 'ok'
2136
+
2137
+ await loopback.destroy() // drops any live connection, then closes
2138
+ await loopback.destroy() // undefined — destroy is idempotent
2139
+ server.listening // false
2140
+ ```
2141
+
2142
+ ### Request an HTTP upgrade
2143
+
2144
+ `requestUpgrade` drives a real client upgrade request at a loopback port and reports what the server
2145
+ did with it. The fixture keeps every socket its `upgrade` handler took, because an upgraded
2146
+ connection is detached from the server and `loopback.destroy()` cannot reach it.
2147
+
2148
+ ```ts
2149
+ import type { Duplex } from 'node:stream'
2150
+ import { createLoopback, requestUpgrade } from '@orkestrel/test/server'
2151
+ import { createServer } from 'node:http'
2152
+
2153
+ const detached: Duplex[] = []
2154
+ const server = createServer((request, response) => {
2155
+ response.statusCode = 426
2156
+ response.end('upgrade required')
2157
+ })
2158
+ const loopback = await createLoopback(server)
2159
+
2160
+ try {
2161
+ // With no upgrade handler installed, the plain handler answers and the client reads that answer.
2162
+ await requestUpgrade(loopback.port, { path: '/socket' })
2163
+ // { claimed: false, status: 426 } — the refused arm carries the status alone
2164
+
2165
+ server.on('upgrade', (request, socket) => {
2166
+ detached.push(socket)
2167
+ // The silent path takes the socket and answers nothing, which is what the budget ends.
2168
+ if (request.url !== '/socket') return
2169
+ socket.write(
2170
+ 'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Protocol: ledger.v2\r\n\r\n',
2171
+ )
2172
+ })
2173
+
2174
+ const claimed = await requestUpgrade(loopback.port, {
2175
+ path: '/socket',
2176
+ protocols: ['ledger.v2', 'ledger.v1'],
2177
+ })
2178
+ // { claimed: true, protocol: 'ledger.v2' } — the claimed arm carries the subprotocol alone
2179
+ if (claimed.claimed) claimed.protocol // 'ledger.v2'; `status` does not exist on this arm
2180
+
2181
+ await requestUpgrade(loopback.port, { path: '/silent', budget: 50 })
2182
+ // rejects: Upgrade request to 127.0.0.1:<port>/silent was not answered within 50ms
2183
+ } finally {
2184
+ for (const socket of detached) socket.destroy()
2185
+ await loopback.destroy()
2186
+ }
2187
+ ```
2188
+
2189
+ Narrow on `claimed` before reading the detail, because each arm carries only its own member: the
2190
+ refused arm carries `status` and no subprotocol, and the claimed arm carries `protocol` — `undefined`
2191
+ there says the server selected none rather than that it refused — and no status at all. A closed port
2192
+ rejects with the client's own `ECONNREFUSED` rather than reporting a refusal, and a server that
2193
+ accepts the connection and answers nothing rejects on the budget, which defaults to `1000`
2194
+ milliseconds and names the port and path it was waiting on.
2195
+
2196
+ ### Probe what the host supports
2197
+
2198
+ Gate a proof on the mechanism it needs rather than on the platform name. Each probe allocates its own
2199
+ directory, attempts the operation, reads the result back, and removes what it made.
2200
+
2201
+ ```ts
2202
+ import { createScratch, supportsFileLinks } from '@orkestrel/test/server'
2203
+ import { expect, it } from 'vitest'
2204
+
2205
+ it.skipIf(!supportsFileLinks())('reads a file through a link', () => {
2206
+ const scratch = createScratch({ files: { 'source.txt': 'linked' } })
2207
+ try {
2208
+ scratch.link('gate.txt', 'source.txt')
2209
+ expect(scratch.read('gate.txt')).toBe('linked')
2210
+ } finally {
2211
+ scratch.destroy()
2212
+ }
2213
+ })
2214
+ ```
2215
+
2216
+ Pick the probe whose question is the one the proof rests on. `supportsDirectoryLinks` and
2217
+ `supportsFileLinks` split where an unprivileged Windows host does: it makes a directory junction and
2218
+ refuses a file link. `supportsCase` and `supportsBytes` answer for the filenames a walk can meet, and
2219
+ `supportsMode` answers whether a permission bit is stored rather than whether it is enforced. Nothing
2220
+ is remembered between calls, so a probe reads the host as it stands when the decision is taken.
2221
+
2222
+ ### Replay response cookies
2223
+
2224
+ `fetch` sends no cookie back on its own, so a test driving a session across requests has to carry
2225
+ the `Cookie` header itself. `createCookieJar` takes that header off real `Set-Cookie` fields rather
2226
+ than off a string the test wrote, so the flow under test is the one the origin actually asked for.
2227
+
2228
+ The boundary is name-only, and it is deliberate. The jar selects by cookie name and reads past
2229
+ `Domain`, `Path`, `Expires`, and `Secure`; a field spelling `Max-Age=0` deletes its cookie and every
2230
+ other field stores or replaces one. That is enough to drive one controlled fixture origin, which is
2231
+ what this jar is for, and it is not a user agent's cookie store: it enforces no scope, honours no
2232
+ expiry, and nothing in it outlives the jar. Drive a real browser wherever the scoping rules are the
2233
+ claim.
2234
+
2235
+ In the following fence, `loopback` is the origin the preceding section bound, and its `/session`
2236
+ route answers with real `Set-Cookie` fields.
2237
+
2238
+ ```ts
2239
+ import { createCookieJar } from '@orkestrel/test/server'
2240
+
2241
+ const jar = createCookieJar()
2242
+
2243
+ // Signing in sets the session on a real response; `capture` returns those fields unmodified.
2244
+ const signIn = await fetch(`${loopback.url}/session`, { method: 'POST' })
2245
+ jar.capture(signIn) // ['session=abc; Path=/; HttpOnly', 'theme=dark; Max-Age=600']
2246
+ jar.read('session') // 'abc'
2247
+ jar.header // 'session=abc; theme=dark' — in the order the jar first met each name
2248
+
2249
+ // The next request carries what the origin set, so the fixture sees the session it issued.
2250
+ const profile = await fetch(`${loopback.url}/profile`, { headers: { cookie: jar.header ?? '' } })
2251
+ await profile.text() // 'signed in'
2252
+
2253
+ // Signing out is `Max-Age=0`, in whatever case and spacing the origin spells it.
2254
+ jar.capture(await fetch(`${loopback.url}/session`, { method: 'DELETE' }))
2255
+ jar.read('session') // undefined
2256
+ jar.header // 'theme=dark'
2257
+ ```
2258
+
2259
+ ### Refuse an escaping path in your own fixture
2260
+
2261
+ `readInventory` and `createScratch` refuse an escape with this predicate. Reach for it when a
2262
+ fixture of your own resolves a caller-supplied path below a root.
2263
+
2264
+ ```ts
2265
+ import { createScratch, resolveContained } from '@orkestrel/test/server'
2266
+
2267
+ const scratch = createScratch({ files: { 'src/index.ts': 'export {}\n' } })
2268
+ const root = scratch.path
2269
+
2270
+ resolveContained(root, 'src/index.ts') // `${root}/src/index.ts`
2271
+ resolveContained(root, `${root}/src/index.ts`) // `${root}/src/index.ts` — absolute and inside
2272
+ resolveContained(root, '../escape.ts') // undefined — lexically outside
2273
+ resolveContained(root, `${root}/../escape.ts`) // undefined — absolute and outside
2274
+ resolveContained(root, '/etc/passwd') // undefined — absolute and outside
2275
+
2276
+ scratch.destroy()
2277
+ ```
2278
+
2279
+ ### Build and mount a fixture
2280
+
2281
+ `build` makes the element, `mount` attaches it, and `render` is the pair in one call. Register the
2282
+ removal as you go: nothing here records what it created, and a browser test file shares one page, so
2283
+ a fixture left behind is the next test's resolver ambiguity.
2284
+
2285
+ ```ts
2286
+ import { createTeardown } from '@orkestrel/test'
2287
+ import { build, mount, render } from '@orkestrel/test/browser'
2288
+ import { afterEach } from 'vitest'
2289
+
2290
+ const teardown = createTeardown()
2291
+ afterEach(() => teardown.destroy())
2292
+
2293
+ const panel = mount(
2294
+ build('section', { classes: 'surface', attributes: { 'aria-label': 'Ledger' } }),
2295
+ )
2296
+ teardown.add(() => panel.remove())
2297
+
2298
+ // Built and appended inside the mounted panel, so it resolves against the shipped cascade.
2299
+ panel.append(build('button', { classes: 'primary', text: 'Save', attributes: { type: 'button' } }))
2300
+
2301
+ const markup = render('<button type="button">Save</button>') // the attached container
2302
+ const heading = render('h2', 'title') // the attached element itself, typed as HTMLHeadingElement
2303
+ teardown.add(() => markup.remove())
2304
+ teardown.add(() => heading.remove())
2305
+ ```
2306
+
2307
+ Mount before measuring. An unmounted element inherits no custom property, resolves against no rule,
2308
+ and lays out no box, so `readStyle`, `readToken`, and `readPixels` each answer with the initial
2309
+ value — which reads as a styling defect rather than as a detached node — and `readContrast` refuses
2310
+ the element outright, because its computed foreground color does not exist. `build` sets its `text`
2311
+ as text rather than as markup, so a `<` in it stays a `<`; write the fixture as markup where the
2312
+ fixture is markup.
2313
+
2314
+ ### Drive an interface the way a person does
2315
+
2316
+ Every verb finds its own target, so a journey names what a person names. Nothing here takes an
2317
+ element, and nothing dispatches a constructed event.
2318
+
2319
+ ```ts
2320
+ import {
2321
+ clickAccessible,
2322
+ clickAccessibleWithin,
2323
+ readPerception,
2324
+ readValue,
2325
+ traverseAccessible,
2326
+ typeAccessible,
2327
+ } from '@orkestrel/test/browser'
2328
+
2329
+ await typeAccessible('Runs', '3')
2330
+ readValue('textbox', 'Runs') // '3' — the value the control renders, not the state behind it
2331
+
2332
+ // Role first when a bare name answers for more than one element. A tab and its own panel collide
2333
+ // by construction, because the panel is labelled by the tab.
2334
+ await clickAccessible('tab', 'Drafts')
2335
+
2336
+ // Region first when a short verb repeats, or when a rendered status completes the name.
2337
+ await clickAccessibleWithin('Ledger', 'button', 'Monthly income')
2338
+
2339
+ // Focus arrives the way the interface offers it. Nothing calls element.focus().
2340
+ await traverseAccessible('Evaluate')
2341
+
2342
+ readPerception('Run') // one visible named region, whitespace collapsed, hidden-but-read text kept
2343
+ ```
2344
+
2345
+ ### Drive a field the component listens to
2346
+
2347
+ Drive a field by name wherever the keystrokes are part of what the journey claims. Reach for these
2348
+ where the test already holds the element and the subject is what the component does with the value.
2349
+
2350
+ ```ts
2351
+ import { requireValue } from '@orkestrel/test'
2352
+ import { commitInput, render, typeInput } from '@orkestrel/test/browser'
2353
+
2354
+ const container = render('<input aria-label="Runs" value="0">')
2355
+ const field = requireValue(container.querySelector('input'))
2356
+
2357
+ typeInput(field, '3') // one bubbling `input`, with the value already set when a listener reads it
2358
+ field.value // '3'
2359
+
2360
+ commitInput(field, '4') // one `input`, then one `change`, both bubbling
2361
+ field.value // '4'
2362
+
2363
+ // Each dispatched event is a plain `Event`. Nothing here constructs an `InputEvent`.
2364
+
2365
+ container.remove()
2366
+ ```
2367
+
2368
+ `typeInput` dispatches no `change`, which is the split: a component that acts on every keystroke
2369
+ hears `input` alone, and one that waits for the field to be committed needs `commitInput`. Each
2370
+ dispatched event is a plain `Event`, never an `InputEvent`, so a component reading `inputType` or
2371
+ testing `instanceof InputEvent` reads neither off them. Neither sends a keystroke either, so a
2372
+ component reading `key`, composition, or selection receives nothing from them — `typeAccessible` is
2373
+ the door for all of those.
2374
+
2375
+ ### Measure what a reader sees
2376
+
2377
+ `readContrast` measures the ratio between an element's rendered text and what is actually behind it,
2378
+ not between the two colors its own rule declares. It walks the ancestors from the element up to the
2379
+ first opaque layer and composites them top over bottom, so a 3% surface tint reads as a tint over
2380
+ what shows through it. A translucent foreground then resolves against that effective background
2381
+ before luminance is measured.
2382
+
2383
+ The `floor` parameter is the opaque color that walk ends on, and omitting it is deliberately strict.
2384
+ Omit it and every stack the floor would still show through is refused — the one where nothing from
2385
+ the element upwards paints, and the one whose painted layers are all translucent — because assuming
2386
+ a white canvas turns "this surface declares no background" into a number that reads like a
2387
+ measurement. Supply it and the same stack composites onto it instead. Supply `CANVAS_COLOR` for a
2388
+ document a browser paints onto its own canvas, and the color a fragment is really mounted onto
2389
+ everywhere else. A floor is what you know the surface sits on, rather than a fallback for not
2390
+ knowing.
2391
+
2392
+ ```ts
2393
+ import { requireValue } from '@orkestrel/test'
2394
+ import { CANVAS_COLOR, readContrast, render } from '@orkestrel/test/browser'
2395
+
2396
+ const surface = render('<main style="background:#fff"><p style="color:#767676">Ready</p></main>')
2397
+ const text = requireValue(surface.querySelector('p'))
2398
+
2399
+ readContrast(text).toFixed(2) // '4.54' — measured against the white the ancestor really paints
2400
+ readContrast(text) >= 4.5 // true — the WCAG 2.x floor for body text
2401
+
2402
+ // A fragment with no painted ancestor is refused rather than assumed.
2403
+ const fragment = render('<p style="color:#767676">Ready</p>')
2404
+ const orphan = requireValue(fragment.querySelector('p'))
2405
+ readContrast(orphan) // throws Error: Computed background color is unavailable
2406
+
2407
+ // Name the surface it is really on, and the same stack measures.
2408
+ readContrast(orphan, CANVAS_COLOR).toFixed(2) // '4.54'
2409
+ ```
2410
+
2411
+ `readRing` is the same reading for focus chrome, and it reads only: focus arrives through
2412
+ `traverseAccessible`, `userEvent.keyboard` from `vitest/browser`, or a real click, and this measures
2413
+ what the browser painted after it landed. It reports `undefined` for a control that is not matching
2414
+ `:focus-visible`, for one left the browser's own `outline-style: auto` ring, and for a focus style
2415
+ that only repaints the control's fill — in each case no measurement taken here would be about focus.
2416
+
2417
+ ```ts
2418
+ import { requireValue } from '@orkestrel/test'
2419
+ import { readRing, traverseAccessible } from '@orkestrel/test/browser'
2420
+
2421
+ const focused = await traverseAccessible('Evaluate')
2422
+ readRing(focused) // the ratio the painted outline or box-shadow reaches against its backdrop
2423
+
2424
+ // Some controls are two elements. `worn` names the one the chrome is painted onto.
2425
+ readRing(focused, requireValue(document.querySelector('label[for="evaluate"]')))
2426
+ ```
2427
+
2428
+ ### Read the tokens and colors a theme declares
2429
+
2430
+ `readToken` and `readRootToken` read what the cascade resolved, and `parseCSSColor` resolves any
2431
+ color expression by asking the same browser. In the following fence the document declares
2432
+ `--ink: rgb(1, 2, 3)` on `:root`, `.card` sets `padding-left: 12px`, and `card` is a mounted inline
2433
+ element carrying that class, so its `width` resolves to `auto`.
2434
+
2435
+ ```ts
2436
+ import {
2437
+ matchesColor,
2438
+ readPixels,
2439
+ readRootToken,
2440
+ readToken,
2441
+ parseCSSColor,
2442
+ } from '@orkestrel/test/browser'
2443
+
2444
+ readRootToken('ink') // 'rgb(1, 2, 3)'
2445
+ readRootToken('--ink') // 'rgb(1, 2, 3)' — the dashes are optional
2446
+ readToken(card, 'ink') // 'rgb(1, 2, 3)' — inherited from `:root` by a mounted element
2447
+ readToken(card, 'absent') // '' — an undeclared token reads as a token declared empty does
2448
+
2449
+ parseCSSColor('var(--ink)') // [1, 2, 3, 1]
2450
+ parseCSSColor('rebeccapurple') // [102, 51, 153, 1]
2451
+ parseCSSColor('not-a-color') // undefined — the CSSOM refused the expression
2452
+ matchesColor('rebeccapurple', 'rgb(102, 51, 153)') // true
2453
+ matchesColor(readToken(card, 'ink'), 'rgb(1, 2, 3)') // true
2454
+
2455
+ readPixels(card, 'padding-left') // 12
2456
+ readPixels(card, 'width') // 0 — a width resolving to `auto` carries no number
2457
+ ```
2458
+
2459
+ Assert on the value rather than on presence. An absent token and one declared empty both read as
2460
+ `''`, and `parseCSSColor` resolves a `var()` naming an undeclared property to the inherited color
2461
+ rather than refusing it, so a test that means to catch a missing token compares what `readToken`
2462
+ returned.
2463
+
2464
+ ### Find a rule in the cascade
2465
+
2466
+ Assert on the stylesheet where the stylesheet is the subject, and on `readStyle` where the rendered
2467
+ result is. In the following fence the cascade declares `.card { padding: 8px }` inside a media query, and
2468
+ an animation named `slide` carrying a `from` stop and a `to` stop.
2469
+
2470
+ ```ts
2471
+ import { findKeyframes, findRule, readRules } from '@orkestrel/test/browser'
2472
+
2473
+ findRule('.card')?.style.getPropertyValue('padding') // '8px'
2474
+ findRule('.never-declared') // undefined
2475
+
2476
+ findKeyframes('slide')?.cssRules.length // 2
2477
+ findKeyframes('slid') // undefined — an animation name matches exactly
2478
+
2479
+ readRules().filter((rule) => rule instanceof CSSKeyframesRule) // every animation the cascade declares
2480
+ ```
2481
+
2482
+ `findRule` matches its argument as a substring of the whole selector text, so `findRule('.card')`
2483
+ finds `.card`, `.card:hover`, and `.panel > .card` alike; pass more of the selector to narrow it.
2484
+ Both finders read through `readRules`, which expands a media query, a supports block, a layer, and a
2485
+ nested style rule level by level, so a top-level rule is always met before a rule nested inside an
2486
+ earlier one. That descent reaches a grouping rule and nothing else, and a `@keyframes` rule is not
2487
+ one: the last line of the fence finds the `@keyframes` rule itself because the walk collects it where
2488
+ it sits, and the keyframe stops inside it never appear in that list, which is why `findKeyframes` is
2489
+ the door to them. A rule either finder returns may still be overridden by another, which is why a
2490
+ claim about what a reader sees is asserted through `readStyle`, `readToken`, `readPixels`, or
2491
+ `readContrast` instead.
2492
+
2493
+ ### Read the classes and styles the markup carries
2494
+
2495
+ Two readings answer whether rendered markup uses the design system or works around it. `readCascade`
2496
+ reads what the stylesheets define and `readClasses` reads what the markup carries, so the set
2497
+ difference between them is the authored-class census: the classes the markup uses and no loaded
2498
+ stylesheet declares. `extractStyles` collects the markup of everything that styles itself instead —
2499
+ an inline `style` attribute, wherever it sits, and a `<style>` element, whatever it holds.
2500
+
2501
+ In the following fence the cascade declares `.card` and nothing else, and `section` is the rendered
2502
+ `<section class="card">`.
2503
+
2504
+ ```ts
2505
+ import { extractStyles, readCascade, readClasses } from '@orkestrel/test/browser'
2506
+
2507
+ // <section class="card">
2508
+ // <p class="lead" style="color: red">Ready</p>
2509
+ // <style>.late { color: blue }</style>
2510
+ // </section>
2511
+
2512
+ const authored = readClasses(section)
2513
+ authored.has('card') // true — the root's own classes count
2514
+ authored.has('lead') // true
2515
+
2516
+ // `readCascade` reads what the stylesheets define and `readClasses` reads what the markup carries,
2517
+ // so their set difference is the authored-class census.
2518
+ const undeclared = [...authored].filter((name) => !readCascade().has(name))
2519
+ undeclared // ['lead'] — no loaded stylesheet declares it
2520
+
2521
+ extractStyles(section)
2522
+ // ['<p class="lead" style="color: red">Ready</p>', '<style>.late { color: blue }</style>']
2523
+ ```
2524
+
2525
+ Both readers take a `ParentNode`, so a detached element and a `DocumentFragment` work as well as an
2526
+ attached tree, and a `DocumentFragment` contributes its descendants alone because it is not an
2527
+ element. Every class is read through `classList` rather than through `className`, which is what makes
2528
+ an SVG element count the same as an HTML one: `className` on an SVG element is an `SVGAnimatedString`
2529
+ rather than a string.
2530
+
2531
+ Nothing but an inline declaration is reported. A class and a `data-*` attribute name something the
2532
+ cascade resolves, so neither is reported however unusual it looks, and an inline `style` on a
2533
+ `<path>` inside an SVG is reported because a namespace changes nothing about what an inline
2534
+ declaration is. A `style` attribute holding nothing but whitespace declares nothing and is read past.
2535
+
2536
+ The `extractStyles` reading is named for what it returns rather than `extractEscapes`, because the
2537
+ `escape` term already carries the encoding sense in the `@orkestrel/html` and `@orkestrel/console`
2538
+ packages.
2539
+
2540
+ ### Remove an IndexedDB database
2541
+
2542
+ Close the connections the test opened, then delete. A live connection blocks the deletion, and the
2543
+ block is a rejection rather than a wait. In the following fence, `connection` is the `IDBDatabase` the
2544
+ test opened.
2545
+
2546
+ ```ts
2547
+ import { removeDatabase } from '@orkestrel/test/browser'
2548
+ import { afterEach } from 'vitest'
2549
+
2550
+ // Runs after a failed test as well as a passing one, whether or not the test opened anything.
2551
+ afterEach(() => removeDatabase('ledger'))
2552
+
2553
+ await removeDatabase('never-created') // resolves — deleting an absent database succeeds
2554
+
2555
+ connection.close()
2556
+ await removeDatabase('ledger')
2557
+
2558
+ // With that connection still open, the same call rejects instead:
2559
+ // Error: IndexedDB database "ledger" is blocked by an open connection
2560
+ ```
2561
+
2562
+ The rejection is the point. A suite that swallowed the block would leave the next test reading the
2563
+ previous test's records through a database that reports itself deleted, so the connection holding it
2564
+ open is handed back to the caller that owns it.
2565
+
2566
+ ### Record a browser journal
2567
+
2568
+ A journal records what a scenario did and everything the page said while it did it. It is the
2569
+ evidence a failing journey hands back: the steps in order, beside the console lines and uncaught
2570
+ failures the surface produced under them.
2571
+
2572
+ Wrap the scenario in `try`/`finally` and stop the journal in the `finally`. `start` replaces the
2573
+ console channels, so a scenario that throws before an unguarded `stop` leaves this journal's
2574
+ wrappers standing for every later test in the file.
2575
+
2576
+ ```ts
2577
+ import { clickAccessible, createJournal, readPerception } from '@orkestrel/test/browser'
2578
+ import { expect, it } from 'vitest'
2579
+
2580
+ const journal = createJournal()
2581
+
2582
+ it('evaluates a draft', async () => {
2583
+ journal.start()
2584
+ try {
2585
+ await clickAccessible('button', 'Evaluate')
2586
+ journal.record('click', 'Evaluate', readPerception('Run'))
2587
+
2588
+ expect(journal.steps).toStrictEqual([
2589
+ { action: 'click', trigger: 'Evaluate', result: 'Scored 3 of 3' },
2590
+ ])
2591
+ expect(journal.output).toStrictEqual([]) // the page logged nothing and threw nothing
2592
+ } finally {
2593
+ journal.stop()
2594
+ }
2595
+ })
2596
+ ```
2597
+
2598
+ The journal contract is the one behind that: the journal forwards every console call to the channel
2599
+ that was there when it started, so a run under a journal prints exactly what it prints without one,
2600
+ and `stop` puts those same function references back by identity. `record` does nothing while the
2601
+ journal is stopped, so a step taken before `start` or after `stop` is not recorded, and `steps` and
2602
+ `output` hand out snapshots. There is no shared instance: create one journal per scenario.
2603
+
2604
+ ### Place a capture portfolio
2605
+
2606
+ The registry is declared once, the run renders one variant, and the same expansion answers both
2607
+ "what must exist" and "what did".
2608
+
2609
+ ```ts
2610
+ import { createPortfolio, expandCaptures } from '@orkestrel/test/browser'
2611
+
2612
+ const states = ['start-empty', 'answer-ideal']
2613
+ const variants = [
2614
+ { name: 'light-1440', width: 1440, height: 1000 },
2615
+ {
2616
+ name: 'dark-390',
2617
+ width: 390,
2618
+ height: 844,
2619
+ apply: () => document.documentElement.setAttribute('data-theme', 'dark'),
2620
+ },
2621
+ ]
2622
+
2623
+ const portfolio = createPortfolio({
2624
+ states,
2625
+ variants,
2626
+ variant: 'dark-390',
2627
+ directory: '../../../tmp/capture/states',
2628
+ // This example is an enabled capture run. A real suite can supply its own gate here.
2629
+ enabled: true,
2630
+ })
2631
+
2632
+ expandCaptures(states, variants).length // 4 — the registry times the variants
2633
+ portfolio.files // the same four names, so a proof compares one expansion against the disk
2634
+
2635
+ // Placed from inside the journey that reached the state, right after the assertion that proves it.
2636
+ await portfolio.place('start-empty')
2637
+ // A run that omits `enabled` returns undefined here, resizes nothing, and records nothing.
2638
+
2639
+ portfolio.place('answer-partial') // rejects: Capture state "answer-partial" is not registered
2640
+ ```
2641
+
2642
+ ### Measure a document's content edge
2643
+
2644
+ A capture stages the pane at the height the document needs, and the body's box cannot answer for
2645
+ that height once the pane is taller than the document. In the following fence the document holds
2646
+ 1600 rows of fixed content and nothing is bound to the viewport.
2647
+
2648
+ ```ts
2649
+ import { measureContent, releasePane, stagePane } from '@orkestrel/test/browser'
2650
+
2651
+ await stagePane(390, 844)
2652
+ measureContent() // 1600 — the row the last content ends on
2653
+ document.documentElement.scrollHeight // 1600 — the box, which is the content under this pane
2654
+
2655
+ await stagePane(390, 2356)
2656
+ measureContent() // 1600 — unchanged, because nothing in the document moved
2657
+ document.documentElement.scrollHeight // 2356 — the box, stretched to the pane
2658
+
2659
+ await releasePane()
2660
+ ```
2661
+
2662
+ The second pair is what the reading exists for. Every box a document exposes — the body's rectangle,
2663
+ `body.scrollHeight`, `body.offsetHeight`, `documentElement.scrollHeight` — is the larger of the
2664
+ content and the pane, so a caller that has staged too tall a pane reads that pane back and cannot
2665
+ descend from it. `measureContent` walks the elements inside the body instead, so it descends. Where
2666
+ the document is laid out against the viewport, it moves with the viewport and reports what the
2667
+ reflow produced rather than what the pane claimed.
2668
+
2669
+ ### Read a written frame back
2670
+
2671
+ A capture is a claim about pixels, so prove it against the pixels. In the following fence the
2672
+ document declares `html { background: rgb(0, 128, 0) }` on its root element and carries content
2673
+ shorter than a 390x844 pane.
2674
+
2675
+ ```ts
2676
+ import { captureFrame, readFrame } from '@orkestrel/test/browser'
2677
+
2678
+ const written = await captureFrame({
2679
+ path: '../../../tmp/capture/frame/read.png',
2680
+ width: 390,
2681
+ height: 844,
2682
+ })
2683
+
2684
+ const reading = await readFrame(written)
2685
+ reading.width // 390 — the pane's width, in device pixels
2686
+ reading.height // 844 — the pane's height, because this document is shorter than the pane
2687
+ reading.floor // 'rgb(0, 128, 0)' — the document's own background, all the way down
2688
+ ```
2689
+
2690
+ The floor is what separates a covered frame from a clipped one. The rows a capture cannot paint are
2691
+ the runner's own page rather than the document, so a clipped frame reads `'rgb(255, 255, 255)'`
2692
+ there while every style in the document still resolves to the background it declared. Shoot the same
2693
+ document at a taller fixture and the reading answers for the whole of it and no more: the height is
2694
+ the content edge `measureContent` read, and the floor is that same background. A bottom row painting
2695
+ more than one color — a split gradient, a two-column footer — reports `undefined` rather than
2696
+ picking one of them.
2697
+
2698
+ ### Practices
2699
+
2700
+ - **Adopt one helper at a time.** Replace a package's local recorder, then its delay, then its
2701
+ temporary directory. Nothing here re-exports another package's symbol, so each swap is
2702
+ independent.
2703
+ - **Take the cleanup list before the resources.** `createTeardown` is what makes the rest of the
2704
+ owned family safe to reach for, because one hook then releases everything the test took.
2705
+ - **Import by environment.** Reach for `@orkestrel/test` first; drop to `@orkestrel/test/server`
2706
+ only for the filesystem helpers, and to `@orkestrel/test/browser` only inside a browser test
2707
+ project.
2708
+ - **Let the journey layer be the only door.** A journey that works around a missing helper by
2709
+ reaching for a selector is a layer defect. Add the capability here instead.
2710
+ - **Keep the helper out of the assertion.** `captureError` converts a throw into a value and
2711
+ `requireValue` converts absence into a throw; the test still does the asserting.
2712
+ - **Replace a fixed sleep with a named wait.** A `waitForDelay(500)` guarding a fact is a guess that
2713
+ is either slower than it needs to be or shorter than the slowest host, and it reports nothing when
2714
+ it fails. Name the fact instead, and let `waitForCondition`, `retryUntil`, or `waitForEvent` decide
2715
+ when it holds.
2716
+ - **Let `readInventory` refuse.** A symlinked root or an escaping target is an error, not a
2717
+ filtered result, so a misconfigured walk fails loudly instead of returning a short map.
2718
+ - **Reach for `parent` only when the allocation must be somewhere named.** The default keeps it out
2719
+ of the repository, and a path inside a package tree is walked by every tool that reads that tree.
2720
+
2721
+ ## Tests
2722
+
2723
+ Each entry names the contracts its file proves. The test names carry the cases.
2724
+
2725
+ - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — the `captureError`,
2726
+ `requireValue`, `roundTripJSON`, and wait-family contracts, plus `waitForDelay` against a real
2727
+ elapsed interval and `resolveRoot` against the calling file. The wait
2728
+ family takes its bounds and its throw directions: `waitForCondition` takes an immediate read
2729
+ under a zero budget, a later read that holds, an asynchronous condition, the timeout naming the
2730
+ condition and the budget, a condition throw propagated unchanged, an abort that rejects with the
2731
+ signal's reason and stops reading, a true reading taken after the final interval, and the refused
2732
+ bounds. `retryUntil` takes a first satisfying attempt and a later one, the exact satisfying value,
2733
+ exhaustion by attempts and by budget, producer throws counted as attempts with the last one kept as
2734
+ the cause, a predicate throw propagated unchanged, and an aborted retry. `waitForEvent` takes the
2735
+ exact delivered tuple, a timeout and an abort each naming the cleanup they invoked, and a second
2736
+ delivery ignored after settlement. `decodeJSONLines` takes empty input, a trailing newline, CRLF,
2737
+ line order, primitive lines, and a malformed physical line named with the native `SyntaxError` as
2738
+ its cause. `collect` and `collectStream` drain an empty and an ordered source, and the stream's
2739
+ reader lock is released afterwards. `roundTripJSON` takes a copy of a flat and a nested
2740
+ interface-typed value with fresh references, a record of `unknown` values, the projection's `never`
2741
+ at an opaque `object` member and at a symbol-keyed one, `undefined`, a function, and a symbol
2742
+ refused at depth under an `unknown` member, a `Date` under one copied as its serialized string, the
2743
+ non-finite refusal at every depth and through `JSON.rawJSON`, the `-0` normalization, and a large
2744
+ array and object copied without exceeding the host's argument limit. The leaves the wait family
2745
+ shares take their own inputs: `checkBounds` takes a zero and a positive bound, each refused budget
2746
+ and interval named for the subject it was given, and the budget named first where both are
2747
+ invalid; `buildRetryExhausted` takes the message with and without a rendered last value and the
2748
+ cause kept by identity; `dropRegistration` takes a scoped registration dropped with its cleanup
2749
+ aborted, an unscoped one carrying no cleanup, and a listener the list does not hold, which changes
2750
+ nothing. The statechart runners drive a real disclosure: `executeScenario` takes the phase order
2751
+ with each phase's own part of the transition, an asynchronous act awaited before the assertion,
2752
+ and — as the control drawn from outside the passing table — a row whose `to` state the event
2753
+ cannot reach, failing at `assert` with the row's name opening the message and the assertion kept
2754
+ as the `cause`, beside a non-error throw named by its type and handed back as the `cause`
2755
+ unchanged. `executeScenarios` takes a table walked in written order against a fresh context per
2756
+ row, a builder called for the row it is building and awaited when it returns a promise, and a run
2757
+ stopped at the first failing row with the rows after it never started.
2758
+ - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — the truncating
2759
+ `clear()`, `createTeardown`, and `createHostileValues` contracts. `createRecorder` records typed
2760
+ tuples in call order, and truncates a `calls` array the test
2761
+ captured before the `clear()`. `createTeardown` takes newest-first order across synchronous and
2762
+ asynchronous handlers, a synchronous throw and an asynchronous rejection each rethrown by identity
2763
+ with every remaining handler still run, both together aggregated in run order, a handler added
2764
+ during a run kept for the next call, the count reset before the handlers run, and a `destroy()`
2765
+ that is called empty and called twice. `createHostileValues` proves a naive-reader failure for
2766
+ every member, frozen and fresh membership, and one total guard's benign and hostile answers with
2767
+ loop-index attribution.
2768
+ - [`tests/src/browser/helpers.test.ts`](../tests/src/browser/helpers.test.ts) — the journey-layer,
2769
+ role-map, and announced-half-and-clickable-half contracts across the layer, in real Chromium
2770
+ against constructed markup. The resolver takes a bare name, a
2771
+ role that disambiguates a tab from its own panel, a name no element carries, a name carried only by
2772
+ a role outside `ACCESSIBLE_ROLES`, and the disabled, hidden, and inert matches that are present but
2773
+ gated. It takes the glyph cases against a fixture stylesheet that really paints one: an exact name
2774
+ beside an `aria-hidden` icon, the same shape folded so the hidden pass names it unreachable rather
2775
+ than absent, a folded control carrying no glyph, the name the page carries nowhere, a short prefix
2776
+ that resolves nothing whether the longer name is on screen or folded, and a painted control an
2777
+ `aria-hidden` ancestor withholds, refused rather than returned. `computeNamePattern`
2778
+ takes a glyph at either edge, the prefix and the trailing word it refuses, regular-expression
2779
+ punctuation read as literal text, a requested name whose whitespace is collapsed, and both edges of
2780
+ its tolerance. `resolveAccessible` takes a target scrolled into view and one fixed outside the viewport
2781
+ that stays there, and `isOutsideViewport` takes a rectangle wholly beyond each edge and one
2782
+ straddling an edge. `isReachable` takes a plain control and each condition it drops, a control the
2783
+ document no longer holds, a focusable SVG against an element from a foreign namespace, and the
2784
+ refused summary that proves it is the one filter the acting verbs apply; `isRendered` takes each
2785
+ removal a browser honours and, as the split from `isReachable`, a zero-size announced control.
2786
+ Each acting verb takes its happy path and every voice it owns, including both
2787
+ region-scoped refusals and both native-disclosure ones; `clickAccessibleWithin` also takes a
2788
+ glyph-captioned control inside a region a glyph-carrying heading labels, which is the loose match
2789
+ proving unchanged. `traverseAccessible` takes a Tab-reachable
2790
+ target; as the cap control, a lone target whose own focus handler blurs it, so focus never lands
2791
+ and the cap fails with an empty trail; and, as the cycle control, the same self-blurring target
2792
+ behind a reachable decoy, so the traversal completes one cycle and reports the decoy in its trail.
2793
+ The page readers take their own inputs: `readPerception` takes one named region including its
2794
+ visually hidden text, a region a glyph-carrying heading labels through `aria-labelledby`, a painted
2795
+ region an `aria-hidden` attribute withholds, and its not-visible and ambiguous refusals, `readPage`
2796
+ the whole page as one
2797
+ normalized sentence, and
2798
+ `readFocus` a focused control's rendered text, a focused element that renders none, and nothing
2799
+ holding focus at all; `readValue` takes a rendered value and a control carrying none. The element
2800
+ readers follow: `readText` takes an `aria-hidden` glyph dropped with the runs around it collapsed
2801
+ and an element with no text at all; `readRole` takes exactly the tags `IMPLICIT_ROLES` carries and
2802
+ one it leaves out, a declared role taken over the implicit one, a section made a region only by
2803
+ something naming it, the axis a `th` declares against the column it defaults to, an anchor that is
2804
+ a link only while it holds an `href`, a select that becomes a listbox when it offers several rows
2805
+ at once, and exactly the input types `FIELD_ROLES` carries against one it leaves out; `readName`
2806
+ takes an `aria-labelledby` list joined in order past an id nothing answers for, an `aria-hidden`
2807
+ glyph dropped from a content role's text, `aria-label` over inner text, a form control's own
2808
+ labels, a button input named by its value, an image named by its alternative text over a `title`
2809
+ it also carries, an image carrying no alternative text named by that `title` instead, and the fall
2810
+ through to `title` and then to an empty string; `readStates` takes every declared state in one
2811
+ order, a native disclosure and a field read from the platform copies rather than from attributes,
2812
+ and a control that declares nothing. `describeTree` takes indentation that follows the roles rather
2813
+ than the markup, each line's name and states, an unpresented element dropped with its whole
2814
+ subtree, and a subtree carrying no role at all; `describeFocus` takes a positive `tabindex` first
2815
+ in ascending order before document order, a reachable control the role map does not answer for
2816
+ named by its tag, and a subtree with nothing reachable. `waitForFrame` takes the frame callbacks
2817
+ already queued, `render` takes parsed fixture markup attached to the document, and `clearStorage`
2818
+ takes local and session storage emptied together.
2819
+ `readContrast` takes a translucent surface composited onto the opaque layer beneath it, a fully
2820
+ opaque stack over different ancestors as the control from outside that population, a stack where
2821
+ nothing paints and one whose every painted layer is translucent, a stack 64 translucent layers
2822
+ deep whose composite has rounded to the canvas's own channels, a detached element whose computed
2823
+ foreground does not exist, and the same unpainted and translucent stacks measured against a
2824
+ supplied floor instead of refused. The color leaves beneath it take their own inputs: `parseColor`
2825
+ across the legacy and modern syntaxes, a refused keyword, hex triple, empty value, and unsupported
2826
+ color space, and — as the control the literals cannot supply — what this browser actually computes
2827
+ for a keyword and for a `color-mix()`. `blendColor`, `measureLuminance`, and `measureContrast`
2828
+ take their identities, their ordering, and the symmetry of the ratio. `readLayers` takes an
2829
+ unpainted stack, a transparent layer left out of a painted one, an opaque layer that ends the
2830
+ walk, and the deep stack whose last layer stays translucent while its composite no longer
2831
+ separates the floors; `readBackdrop` takes the floor returned by identity, a translucent stack
2832
+ composited onto it, and an opaque layer that ends the walk. `readRing` takes a painted outline and
2833
+ a painted box-shadow reached through `traverseAccessible`, a control that is not focused, a
2834
+ focused control left the browser's own ring, a focus style that only repaints the control's fill,
2835
+ and a `worn` element whose reading separates from the control's own. `measureContent` takes a
2836
+ 1600-row document read under an 844 pane and under a 2356 one, against the scroll height that
2837
+ agrees with it under the shorter pane and stretches to the pane under the taller, and a document
2838
+ whose last element carries a bottom margin under a body carrying bottom padding, where a reading
2839
+ taken from rectangles alone stops 100 rows short. `stagePane` takes the marked pane, the tester
2840
+ rendered at the viewport it was given, a release that runs twice without complaining, and a
2841
+ release after two stagings that hands back the viewport the tester held before the first;
2842
+ `captureFrame` takes a real file written, read back, and matched, with a planted file as the
2843
+ comparison's negative control, one element shot rather than the page, and a pane pinned to the
2844
+ wrong size by a rule of higher specificity, which is the refusal that also proves the release runs
2845
+ on the failing path. It also takes a document taller than the pane, whose frame equals the
2846
+ fixture's own declared 1600 rows and ends on the background the document declares rather than on
2847
+ the runner's canvas, and a document shorter than the pane, whose frame stays at the pane's height
2848
+ on that same floor. The equality is the control that an overshoot adds no rows: a frame taller
2849
+ than the document paints the same floor, because the root's background covers whatever canvas the
2850
+ pane stretched, so a `>=` assertion reads a 2356-row frame as coverage. The height the shot is
2851
+ staged at takes its own cases. A body whose box ends on a quarter of a pixel is measured under the
2852
+ staged pane first, so the case reddens on a browser that rounds the other way instead of passing
2853
+ quietly, and its frame ends on the fixture's background rather than on the runner's page in the
2854
+ row `scrollHeight` rounded away. A full-height panel capped by a media query reflows against the
2855
+ taller pane, and its frame covers what the reflow added. A panel holding half the pane over a
2856
+ fixed 900-row block converges on 1800 without ever reaching it by restaging at the height last
2857
+ read — 1322, then 1561 — and its frame lands on 1800, which is the fixed point written out rather
2858
+ than read back from the capture that staged it. The same panel uncapped grows with every pane and
2859
+ reaches the refusal, whose written-out restaging bound reddens when the source's bound moves and
2860
+ whose pane and viewport are handed back anyway. `readFrame` takes a written frame's size and
2861
+ floor, read a second way through the cascade's own answer for the same canvas, a bottom row split
2862
+ between two colors reported as no floor at all, a path holding no file, and a file holding no
2863
+ image. `readCascade` takes class tokens collected from plain and grouped rules and only real ones;
2864
+ `readRows` takes a row joined from its own text nodes rather than from run-together content, and
2865
+ an empty list; `extractOrphans` takes a child class rendered outside its container with a nested
2866
+ one left alone, nothing reported when every child sits inside one, and, as the control, an element
2867
+ answering the invariant by carrying both classes itself; `readStyle` takes the browser's resolved
2868
+ value for one property. `expandCaptures` takes the exact expected file list rather than a count,
2869
+ and both empty inputs. `readClasses` takes the root's own classes ahead of its descendants' in
2870
+ document order, an SVG class read through `classList` where `className` is no string, a fragment
2871
+ root contributing its descendants alone, markup carrying no class at all, and a class absent from
2872
+ the cascade left in the difference against `readCascade`. `extractStyles` takes an inline
2873
+ attribute and a `<style>` element in document order, a `<style>` root and a styled root each
2874
+ counted beside a `<div>` root carrying neither, an inline style on an SVG path, a `<style>`
2875
+ element carrying an inline attribute reported once, a whitespace-only attribute read past, a
2876
+ fragment root, and — as the control — markup whose classes and `data-*` attributes leave it with
2877
+ nothing to report.
2878
+ - [`tests/src/browser/factories.test.ts`](../tests/src/browser/factories.test.ts) — the journal
2879
+ contract, and the portfolio's refusals, its disabled gate, and its writes. Creation refuses an
2880
+ unregistered variant name; the registry expands across every variant whether or not the run
2881
+ writes; a run
2882
+ that is not enabled applies nothing, writes nothing, and records nothing; an enabled run applies
2883
+ the variant, resizes the viewport, writes a real file through the provider, records it, and hands
2884
+ out snapshots rather than its own lists; it refuses an unregistered state and a second placement of
2885
+ one state; and a placement handed an element writes a frame that is not the whole page's and leaves
2886
+ the staged pane released. `createJournal` takes a step recorded only while it is started, every
2887
+ console channel forwarded to the recorder that was there, a call's arguments joined into a line,
2888
+ an uncaught error and an unhandled rejection recorded and then ignored after the stop, the
2889
+ channels handed back by identity with a second stop proven a no-op against a replacement, a restart
2890
+ that clears `steps` and `output` without stacking wrappers, snapshots that stay what they were, and
2891
+ one journal's recording kept out of another's.
2892
+ - [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) — the `readInventory` and
2893
+ wait-family contracts, and each pure leaf against its own inputs. `resolveContained` takes
2894
+ contained relative and absolute targets and both spellings of an escape, and `requireContained`
2895
+ takes the same contained pair and each escape refused with the message naming the target it was
2896
+ given. `readIdentity` takes a real
2897
+ allocation's three fields, that allocation matching itself across a `stat` and an `lstat`, and a
2898
+ second allocation reading as a different identity. `readErrorCode` takes a real `ENOENT` off the
2899
+ host, a plain object, an `Error` carrying a non-string `code` and one carrying none, a
2900
+ null-prototype record, and the values that are not objects at all. `matchesIdentity` takes a triple
2901
+ matching in every field and one differing in each. `isExcluded` takes a key, an ancestor, the root, and a sibling that only
2902
+ looks like a match. `readInventory` takes key order, extension filtering, exclusion at the named
2903
+ door and at the walked one with its spellings normalized to one rule, each of its link refusals
2904
+ with a contained intermediate link as the control on the intermediate-link one, a root-level
2905
+ `__proto__` file, and the
2906
+ host's own case behavior probed rather than assumed. `createLink` takes a directory named by an
2907
+ absolute source, a relative source resolved against the link's own directory against a decoy one
2908
+ level up, a dangling link, the host's `EEXIST` on an occupied path, and — where the host makes no
2909
+ symbolic link — a file source refused with the host's own `EPERM` and nothing left behind.
2910
+ `removeTree` takes a live process
2911
+ holding the tree as its working directory, and the hosts split rather than branching at
2912
+ runtime: on Windows the un-retried `rmSync` baseline is proven to fail first, so the retry is what
2913
+ succeeds, and on POSIX the removal is permitted outright. `isRunning` takes the process making the
2914
+ call, a child that has exited, and a pid the host refuses without throwing. `waitForSocketClose`
2915
+ takes an already-closed socket, a peer that ends the connection, a reset waited past to the close
2916
+ that follows it, a socket error that is not a reset, a socket left open past the budget, both
2917
+ listeners removed after it resolves and after it rejects, an abort before and during the wait, and
2918
+ the refused bounds. `destroyScratch` takes a first-attempt destruction whose elapsed reading is
2919
+ below one retry interval, a signal already aborted before anything is attempted, the refused
2920
+ bounds, and — on each host by the mechanism that host actually refuses a removal for — an
2921
+ allocation held until the holder lets go, its budget exhausted with the host's own refusal as the
2922
+ `cause` and then destroyed after the hold ends.
2923
+ - [`tests/src/server/factories.test.ts`](../tests/src/server/factories.test.ts) — the
2924
+ `createScratch`, destroyed-allocation, and `createLoopback` contracts. `createLoopback` takes a
2925
+ real `fetch` answered from its own origin, a live keep-alive connection dropped by `destroy()`
2926
+ with a second server then binding the released port, a repeated `destroy()`
2927
+ handed the same promise before either call settles, parallel instances landing on distinct
2928
+ ports, a plain `node:net` server bound and closed, and a server already listening when it was
2929
+ handed over, refused. For `createScratch`, the ungrouped cases take the `0700` mode, nested
2930
+ seeding, the cleanup after a failed seed, the lexical refusals, the empty target's answers, and
2931
+ `has`, `write`, `read`, `names`, `ensure`, `link`, and `remove` each refused at a symbolic-link
2932
+ root and at a file root; `destroy()` is idempotent, leaves a replacement directory standing, and
2933
+ leaves a moved allocation alone. Then one group per subject.
2934
+ `destruction` takes `write`, `read`, `has`, `names`, `ensure`, `link`, and `remove` after
2935
+ `destroy()`, with `write`, `ensure`, and `link` also proven not to rebuild the allocation root, and
2936
+ `remove` proving its root and escape refusals answer before the destroyed-allocation one.
2937
+ `names` takes its sorted output, including the population that discriminates a dropped `.sort()`.
2938
+ `ensure` takes an empty directory, every missing parent, and a repeated call. `link` takes
2939
+ traversal through a planted link, the final segment `has` reports rather than follows, and the
2940
+ `EEXIST` an occupied final segment throws. `remove` takes a file beside a kept
2941
+ sibling, an empty directory, a populated subtree, a missing target, an ancestor link back to the
2942
+ allocation with every seeded file read back afterwards, a final link whose destination is read back
2943
+ afterwards, a sibling directory reached through that same ancestor link, an escaping target with
2944
+ the file outside left intact, the root refused as `''`, as `'.'`, and as its absolute path, a
2945
+ foreign directory swapped onto the allocated path that `remove('')` refuses, and that same swap
2946
+ under `destroy()`, which removes nothing either. `parent` and `prefix` take their own refusals.
2947
+ `createCookieJar` takes an empty jar rendering no header, every `Set-Cookie` field a real response
2948
+ carries applied and handed back unmodified, a cookie replaced whatever attributes the second field
2949
+ carries, a deletion on `Max-Age=0` in whatever case and spacing the origin sends, and a field
2950
+ carrying no `name=value` pair read past and still returned.
2951
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — the doc ↔ source bijection contract: the
2952
+ `## Surface` ↔ source bijection, the barrel ↔ source bijection, the behavioral-interface ↔
2953
+ `## Methods` bijection and each group's members, the fence imports, and link resolution for this
2954
+ guide. It also runs the equality gate: every `Summary` cell against the description paragraph of
2955
+ the declaration it documents, the titled `Own a temporary directory` fence against the `@example`
2956
+ block of that title (pinned so the titled pair cannot be retired silently), and the README pitch
2957
+ against this guide's tagline. Beside them it runs the fences themselves and asserts what their
2958
+ comments claim: the recorder's truncating `clear()`, the recorder map keyed by the events a real
2959
+ source emits, the signal tally through every exit it has, the resource numbering, the unchecked
2960
+ boundary's uncallable-method and non-object-target refusals, the header flattening, the wait
2961
+ family's opposite throw directions with the exhaustion message and its `cause`, the statechart
2962
+ table walked against a real disclosure with the failing row's name opening the message and the
2963
+ assertion kept as the `cause`, the cookie jar driven against a real origin, and the HTTP upgrade's
2964
+ refused arm, claimed arm, and budget.
2965
+
2966
+ ## See also
2967
+
2968
+ - [`README.md`](README.md) — the guides index.
2969
+ - `AGENTS.md` at the workspace root — the rules this package's own source and tests follow.