@orkestrel/scaffold 0.0.66 → 0.0.68
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.
- package/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +8 -8
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +44 -22
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +43 -23
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +9 -9
|
@@ -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.
|