@orkestrel/test 0.0.13 → 0.0.15

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.
@@ -1,7 +1,7 @@
1
- import { Server } from 'node:net';
2
- import { Socket } from 'node:net';
3
- import { Stats } from 'node:fs';
4
- import { WaitOptions } from '@orkestrel/test';
1
+ import type { Server } from 'node:net';
2
+ import type { Socket } from 'node:net';
3
+ import type { Stats } from 'node:fs';
4
+ import type { WaitOptions } from '@orkestrel/test';
5
5
 
6
6
  /** Holds a name-keyed cookie store a test drives one origin with, filled from real responses. */
7
7
  export declare interface CookieJarInterface {
@@ -24,7 +24,8 @@ export declare interface CookieJarInterface {
24
24
  * @returns Those fields unmodified, in the order the response carried them.
25
25
  * @remarks Selection is by name alone. A field spelling `Max-Age=0` deletes its cookie and every
26
26
  * other field stores or replaces one, so `Domain`, `Path`, `Expires`, and `Secure` are read past
27
- * rather than honoured. Nothing outlives the jar.
27
+ * rather than honoured. A field carrying no `name=value` pair is read past too and still
28
+ * returned. Nothing outlives the jar.
28
29
  */
29
30
  capture(response: Response): readonly string[];
30
31
  }
@@ -51,6 +52,17 @@ export declare function createCookieJar(): CookieJarInterface;
51
52
  * `EPERM`, while a directory or missing source is passed to a junction attempt. A missing source is
52
53
  * accepted to create a dangling junction. Where the host creates a junction, its stored value is the
53
54
  * resolved absolute path.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { readFileSync } from 'node:fs'
59
+ * import { createLink } from '@orkestrel/test/server'
60
+ *
61
+ * // `/scratch/source` is a directory holding `file.txt`.
62
+ * createLink('/scratch/linked', '/scratch/source')
63
+ *
64
+ * readFileSync('/scratch/linked/file.txt', 'utf8') // 'linked'
65
+ * ```
54
66
  */
55
67
  export declare function createLink(path: string, source: string): void;
56
68
 
@@ -72,6 +84,67 @@ export declare function createLoopback(server: Server): Promise<LoopbackInterfac
72
84
  * `/` or `\`; or when allocation or seeding fails.
73
85
  * @remarks Default parent: the host temporary directory. Default prefix: `orkestrel-test-`. Seed
74
86
  * keys use root-relative paths.
87
+ *
88
+ * @example Own a temporary directory
89
+ * ```ts
90
+ * import { createScratch } from '@orkestrel/test/server'
91
+ *
92
+ * const scratch = createScratch({ prefix: 'guide-', files: { 'src/index.ts': 'export {}\n' } })
93
+ *
94
+ * scratch.read('src/index.ts') // 'export {}\n'
95
+ * scratch.has('src') // true
96
+ * scratch.read('src') // throws Error: Scratch path is a directory: src
97
+ * scratch.read('missing.ts') // undefined
98
+ * scratch.write('../escape.ts', '') // throws Error: Path outside scratch directory: ../escape.ts
99
+ *
100
+ * // `write` answers the contained path it wrote, the way `ensure` and `link` answer theirs, so the
101
+ * // path goes straight to the code under test without joining it again.
102
+ * scratch.write('src/notes.ts', 'export {}\n') // `${scratch.path}/src/notes.ts`
103
+ *
104
+ * // `ensure` is how you get an empty directory, because every `write` creates a file.
105
+ * scratch.ensure('empty')
106
+ * scratch.names() // ['empty', 'src']
107
+ * scratch.names('empty') // []
108
+ *
109
+ * // `parent` puts the allocation somewhere other than the host temporary directory.
110
+ * const child = createScratch({ parent: scratch.path, prefix: 'child-' })
111
+ * scratch.names().length // 3 — 'empty', 'src', and the child allocation
112
+ * child.destroy()
113
+ * scratch.names().length // 2 — the child removed itself and nothing else
114
+ *
115
+ * // `link` creates the symbolic link the threat model names, and `read` follows it. A directory
116
+ * // source runs on a host that creates no symbolic link too; see "Hosts that create no symbolic
117
+ * // link" for what such a host does with a file source.
118
+ * const outside = createScratch({ prefix: 'outside-', files: { 'read.ts': 'export {}\n' } })
119
+ * scratch.link('gate', outside.path) // `${scratch.path}/gate` — the link's own path, not its destination
120
+ * scratch.read('gate/read.ts') // 'export {}\n' — read through the link, at its destination
121
+ *
122
+ * // A link pointing out of the allocation is resolved through, so a contained path acts outside it.
123
+ * scratch.ensure('gate/made') // `${scratch.path}/gate/made` — the lexical path, not the destination
124
+ * outside.names() // ['made', 'read.ts'] — the directory was made under `outside.path`
125
+ * scratch.names('gate') // ['made', 'read.ts'] — the same entries, listed through the link
126
+ *
127
+ * // `link` acts at the final segment rather than through it, so `gate` is occupied.
128
+ * scratch.link('gate', outside.path) // throws Error: EEXIST: file already exists
129
+ *
130
+ * // `has` reads the final segment without following it, and `read` follows it.
131
+ * scratch.link('dangling', 'missing.ts')
132
+ * scratch.has('dangling') // true — the link is there
133
+ * scratch.read('dangling') // undefined — what it points at is not
134
+ *
135
+ * // `remove` takes one contained entry and acts at the final segment, so a link goes and whatever it
136
+ * // pointed at stays. A missing target is a no-op.
137
+ * scratch.remove('dangling')
138
+ * scratch.has('dangling') // false
139
+ * scratch.remove('missing.ts') // no throw — there was nothing there
140
+ * scratch.remove('src') // the directory and everything under it
141
+ * scratch.names() // ['empty', 'gate']
142
+ *
143
+ * scratch.destroy()
144
+ * scratch.destroy() // no-op — destroy is idempotent
145
+ * outside.has('made') // true — destroy unlinks `gate` and leaves what it pointed at
146
+ * outside.destroy()
147
+ * ```
75
148
  */
76
149
  export declare function createScratch(options?: ScratchOptions): ScratchInterface;
77
150
 
@@ -84,7 +157,7 @@ export declare function createScratch(options?: ScratchOptions): ScratchInterfac
84
157
  * @throws The abort reason, or an `Error` when a bound is invalid or the budget elapses. The
85
158
  * exhaustion error carries the last host refusal as its `cause`.
86
159
  * @remarks Default budget: `10000` milliseconds. Default interval: `25` milliseconds. A host holds a
87
- * directory for a short interval after the process that held it exits, and a just-stopped child's
160
+ * directory for a short interval after the process that held it exits, and a recently stopped child's
88
161
  * working directory is the case this exists for, so removal is attempted until the host lets go
89
162
  * rather than exactly once. {@link ScratchInterface.destroy} stays synchronous and is unchanged; this
90
163
  * is the bounded retry around it. A directory nothing releases still fails, with the host's own
@@ -120,6 +193,14 @@ export declare interface InventoryOptions {
120
193
  * @param key - The root-relative key to test.
121
194
  * @param exclusions - The normalized root-relative exclusion keys.
122
195
  * @returns True if an exclusion names the key or one of its ancestors; false otherwise.
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * import { isExcluded } from '@orkestrel/test/server'
200
+ *
201
+ * isExcluded('src/index.ts', ['src']) // true
202
+ * isExcluded('src-other/index.ts', ['src']) // false
203
+ * ```
123
204
  */
124
205
  export declare function isExcluded(key: string, exclusions: readonly string[]): boolean;
125
206
 
@@ -139,6 +220,14 @@ export declare function isExcluded(key: string, exclusions: readonly string[]):
139
220
  *
140
221
  * A Linux zombie — a process that has exited and whose parent has not reaped it — still accepts
141
222
  * signal `0`, so its `/proc` status is read and a `Z` state reads as false.
223
+ *
224
+ * @example
225
+ * ```ts
226
+ * import { isRunning } from '@orkestrel/test/server'
227
+ *
228
+ * isRunning(process.pid) // true
229
+ * isRunning(2 ** 31) // false
230
+ * ```
142
231
  */
143
232
  export declare function isRunning(pid: number): boolean;
144
233
 
@@ -169,6 +258,17 @@ export declare interface LoopbackInterface {
169
258
  * @remarks All three fields are compared because none of them alone identifies an allocation. A
170
259
  * device is shared by every directory on one filesystem, an index node is reused once its directory
171
260
  * is removed, and a creation time repeats within the host's timestamp resolution.
261
+ *
262
+ * @example
263
+ * ```ts
264
+ * import { statSync } from 'node:fs'
265
+ * import { matchesIdentity, readIdentity } from '@orkestrel/test/server'
266
+ *
267
+ * const allocation = readIdentity(statSync('/scratch'))
268
+ *
269
+ * matchesIdentity(readIdentity(statSync('/scratch')), allocation) // true
270
+ * matchesIdentity({ birth: 3, device: 1, inode: 9 }, { birth: 3, device: 1, inode: 2 }) // false
271
+ * ```
172
272
  */
173
273
  export declare function matchesIdentity(current: ScratchIdentity, allocation: ScratchIdentity): boolean;
174
274
 
@@ -181,6 +281,16 @@ export declare function matchesIdentity(current: ScratchIdentity, allocation: Sc
181
281
  * `code`, and one carrying a `code` that is not a string all answer `undefined`. A null-prototype
182
282
  * object is read the same way, because the key is tested with `in` rather than through
183
283
  * `hasOwnProperty`.
284
+ *
285
+ * @example
286
+ * ```ts
287
+ * import { readFileSync } from 'node:fs'
288
+ * import { captureError } from '@orkestrel/test'
289
+ * import { readErrorCode } from '@orkestrel/test/server'
290
+ *
291
+ * readErrorCode(captureError(() => readFileSync('/scratch/absent.txt', 'utf8'))) // 'ENOENT'
292
+ * readErrorCode(new Error('refused')) // undefined
293
+ * ```
184
294
  */
185
295
  export declare function readErrorCode(error: unknown): string | undefined;
186
296
 
@@ -189,6 +299,16 @@ export declare function readErrorCode(error: unknown): string | undefined;
189
299
  *
190
300
  * @param status - The status read from the directory's path.
191
301
  * @returns The device, index node, and creation time that together name the allocation.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * import { statSync } from 'node:fs'
306
+ * import { readIdentity } from '@orkestrel/test/server'
307
+ *
308
+ * const status = statSync('/scratch')
309
+ *
310
+ * readIdentity(status) // { birth: status.birthtimeMs, device: status.dev, inode: status.ino }
311
+ * ```
192
312
  */
193
313
  export declare function readIdentity(status: Stats): ScratchIdentity;
194
314
 
@@ -228,13 +348,23 @@ export declare const REMOVE_TREE_RETRYABLE_CODES: readonly string[];
228
348
  * @param path - The absolute directory to remove.
229
349
  * @throws The last removal error once {@link REMOVE_TREE_MAX_ATTEMPTS} attempts are exhausted,
230
350
  * or immediately for any error whose code is not in {@link REMOVE_TREE_RETRYABLE_CODES}.
231
- * @remarks On Windows, a directory that a just-exited process still holds as its current
351
+ * @remarks On Windows, a directory that a recently exited process still holds as its current
232
352
  * working directory throws `EPERM` for a short interval after that process exits. Node's own
233
353
  * `rmSync` `maxRetries`/`retryDelay` options do not cover this error class on that host: probed
234
354
  * against a real held directory, they neither delay nor retry before rethrowing, so the retry
235
355
  * is implemented here with a synchronous sleep instead. Ten attempts 100ms apart bound the wait
236
356
  * at roughly one second. A hold that outlasts that second is {@link destroyScratch}'s case, which
237
357
  * retries every refusal inside a caller's budget rather than the codes named here.
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * import { existsSync } from 'node:fs'
362
+ * import { removeTree } from '@orkestrel/test/server'
363
+ *
364
+ * removeTree('/scratch/tree')
365
+ *
366
+ * existsSync('/scratch/tree') // false
367
+ * ```
238
368
  */
239
369
  export declare function removeTree(path: string): void;
240
370
 
@@ -282,6 +412,16 @@ export declare function requestUpgrade(port: number, options?: UpgradeOptions):
282
412
  * @remarks This is {@link resolveContained} with the refusal every contained scratch operation makes
283
413
  * of an escape, so the check and its one message are stated once. Read `resolveContained` where an
284
414
  * escape is an answer rather than a refusal.
415
+ *
416
+ * @example
417
+ * ```ts
418
+ * import { requireContained } from '@orkestrel/test/server'
419
+ *
420
+ * requireContained('/scratch', 'nested/file.txt') // '/scratch/nested/file.txt'
421
+ *
422
+ * // Throws Error: Path outside scratch directory: ../escape.ts
423
+ * requireContained('/scratch', '../escape.ts')
424
+ * ```
285
425
  */
286
426
  export declare function requireContained(root: string, target: string): string;
287
427
 
@@ -387,6 +527,7 @@ export declare interface ScratchInterface {
387
527
  * Removes the allocated directory and everything in it when its identity still matches.
388
528
  *
389
529
  * @throws When the host refuses to inspect or remove the matching allocation.
530
+ * @remarks Idempotent. An identity that no longer matches removes nothing.
390
531
  */
391
532
  destroy(): void;
392
533
  }
@@ -422,6 +563,13 @@ export declare interface ScratchOptions {
422
563
  * @remarks Byte `0x80` is an invalid UTF-8 lead byte. POSIX stores the name verbatim and Windows
423
564
  * rejects it with `ENOENT`, so the answer is true on POSIX and false on Windows. The path is passed
424
565
  * as a `Buffer` because the byte survives no string round trip.
566
+ *
567
+ * @example
568
+ * ```ts
569
+ * import { supportsBytes } from '@orkestrel/test/server'
570
+ *
571
+ * supportsBytes() // true on a POSIX host, false on Windows
572
+ * ```
425
573
  */
426
574
  export declare function supportsBytes(): boolean;
427
575
 
@@ -437,6 +585,13 @@ export declare function supportsBytes(): boolean;
437
585
  * routes the second write onto the first entry, so reading the first back returns the second's
438
586
  * contents and the answer is false. The answer is true on a typical POSIX host and false on a
439
587
  * case-folding Windows or macOS volume.
588
+ *
589
+ * @example
590
+ * ```ts
591
+ * import { supportsCase } from '@orkestrel/test/server'
592
+ *
593
+ * supportsCase() // true on a case-sensitive volume, false on a case-folding one
594
+ * ```
440
595
  */
441
596
  export declare function supportsCase(): boolean;
442
597
 
@@ -452,6 +607,13 @@ export declare function supportsCase(): boolean;
452
607
  * The answer is false on a filesystem carrying neither reparse points nor symbolic links. Every call
453
608
  * probes and cleans up after itself, so a host whose answer changes is read again rather than
454
609
  * remembered.
610
+ *
611
+ * @example
612
+ * ```ts
613
+ * import { supportsDirectoryLinks } from '@orkestrel/test/server'
614
+ *
615
+ * supportsDirectoryLinks() // true where the host creates a symbolic link or a junction
616
+ * ```
455
617
  */
456
618
  export declare function supportsDirectoryLinks(): boolean;
457
619
 
@@ -483,6 +645,13 @@ export declare function supportsFileLinks(): boolean;
483
645
  * it: a POSIX host running as uid `0` stores every bit faithfully and bypasses the access check the
484
646
  * bits describe, so a caller that needs a permission to be enforced probes the refusal it needs
485
647
  * rather than reading this.
648
+ *
649
+ * @example
650
+ * ```ts
651
+ * import { supportsMode } from '@orkestrel/test/server'
652
+ *
653
+ * supportsMode() // true on a POSIX host, false on Windows
654
+ * ```
486
655
  */
487
656
  export declare function supportsMode(): boolean;
488
657
 
@@ -531,6 +700,20 @@ export declare type UpgradeResult = {
531
700
  * every other error ends the wait. The interval is validated for consistency with the wait family but
532
701
  * is not used, because this helper parks on the socket's events. Both listeners are removed on every
533
702
  * settlement, so a caller may wait on one socket repeatedly.
703
+ *
704
+ * @example
705
+ * ```ts
706
+ * import { connect, createServer } from 'node:net'
707
+ * import { createLoopback, waitForSocketClose } from '@orkestrel/test/server'
708
+ *
709
+ * const loopback = await createLoopback(createServer((socket) => socket.end()))
710
+ * const client = connect(loopback.port, '127.0.0.1')
711
+ *
712
+ * await waitForSocketClose(client, { budget: 1000 }) // undefined
713
+ * client.destroyed // true
714
+ *
715
+ * await loopback.destroy()
716
+ * ```
534
717
  */
535
718
  export declare function waitForSocketClose(socket: Socket, options?: WaitOptions): Promise<void>;
536
719
 
@@ -4,6 +4,7 @@ import { request } from "node:http";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { isFunction, isNumber, isObject, isString } from "@orkestrel/contract";
7
8
  import { checkBounds, waitForDelay } from "../core/index.js";
8
9
  import { once } from "node:events";
9
10
  //#region src/server/constants.ts
@@ -49,6 +50,16 @@ function resolveContained(root, target) {
49
50
  * @remarks This is {@link resolveContained} with the refusal every contained scratch operation makes
50
51
  * of an escape, so the check and its one message are stated once. Read `resolveContained` where an
51
52
  * escape is an answer rather than a refusal.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { requireContained } from '@orkestrel/test/server'
57
+ *
58
+ * requireContained('/scratch', 'nested/file.txt') // '/scratch/nested/file.txt'
59
+ *
60
+ * // Throws Error: Path outside scratch directory: ../escape.ts
61
+ * requireContained('/scratch', '../escape.ts')
62
+ * ```
52
63
  */
53
64
  function requireContained(root, target) {
54
65
  const candidate = resolveContained(root, target);
@@ -60,6 +71,16 @@ function requireContained(root, target) {
60
71
  *
61
72
  * @param status - The status read from the directory's path.
62
73
  * @returns The device, index node, and creation time that together name the allocation.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * import { statSync } from 'node:fs'
78
+ * import { readIdentity } from '@orkestrel/test/server'
79
+ *
80
+ * const status = statSync('/scratch')
81
+ *
82
+ * readIdentity(status) // { birth: status.birthtimeMs, device: status.dev, inode: status.ino }
83
+ * ```
63
84
  */
64
85
  function readIdentity(status) {
65
86
  return {
@@ -77,9 +98,19 @@ function readIdentity(status) {
77
98
  * `code`, and one carrying a `code` that is not a string all answer `undefined`. A null-prototype
78
99
  * object is read the same way, because the key is tested with `in` rather than through
79
100
  * `hasOwnProperty`.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * import { readFileSync } from 'node:fs'
105
+ * import { captureError } from '@orkestrel/test'
106
+ * import { readErrorCode } from '@orkestrel/test/server'
107
+ *
108
+ * readErrorCode(captureError(() => readFileSync('/scratch/absent.txt', 'utf8'))) // 'ENOENT'
109
+ * readErrorCode(new Error('refused')) // undefined
110
+ * ```
80
111
  */
81
112
  function readErrorCode(error) {
82
- return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : void 0;
113
+ return isObject(error) && "code" in error && isString(error.code) ? error.code : void 0;
83
114
  }
84
115
  /**
85
116
  * Reports whether two directory identities name the same allocation.
@@ -90,6 +121,17 @@ function readErrorCode(error) {
90
121
  * @remarks All three fields are compared because none of them alone identifies an allocation. A
91
122
  * device is shared by every directory on one filesystem, an index node is reused once its directory
92
123
  * is removed, and a creation time repeats within the host's timestamp resolution.
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * import { statSync } from 'node:fs'
128
+ * import { matchesIdentity, readIdentity } from '@orkestrel/test/server'
129
+ *
130
+ * const allocation = readIdentity(statSync('/scratch'))
131
+ *
132
+ * matchesIdentity(readIdentity(statSync('/scratch')), allocation) // true
133
+ * matchesIdentity({ birth: 3, device: 1, inode: 9 }, { birth: 3, device: 1, inode: 2 }) // false
134
+ * ```
93
135
  */
94
136
  function matchesIdentity(current, allocation) {
95
137
  return current.device === allocation.device && current.inode === allocation.inode && current.birth === allocation.birth;
@@ -100,6 +142,14 @@ function matchesIdentity(current, allocation) {
100
142
  * @param key - The root-relative key to test.
101
143
  * @param exclusions - The normalized root-relative exclusion keys.
102
144
  * @returns True if an exclusion names the key or one of its ancestors; false otherwise.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * import { isExcluded } from '@orkestrel/test/server'
149
+ *
150
+ * isExcluded('src/index.ts', ['src']) // true
151
+ * isExcluded('src-other/index.ts', ['src']) // false
152
+ * ```
103
153
  */
104
154
  function isExcluded(key, exclusions) {
105
155
  return exclusions.some((rule) => rule === "" || key === rule || key.startsWith(`${rule}/`));
@@ -116,6 +166,17 @@ function isExcluded(key, exclusions) {
116
166
  * `EPERM`, while a directory or missing source is passed to a junction attempt. A missing source is
117
167
  * accepted to create a dangling junction. Where the host creates a junction, its stored value is the
118
168
  * resolved absolute path.
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * import { readFileSync } from 'node:fs'
173
+ * import { createLink } from '@orkestrel/test/server'
174
+ *
175
+ * // `/scratch/source` is a directory holding `file.txt`.
176
+ * createLink('/scratch/linked', '/scratch/source')
177
+ *
178
+ * readFileSync('/scratch/linked/file.txt', 'utf8') // 'linked'
179
+ * ```
119
180
  */
120
181
  function createLink(path, source) {
121
182
  try {
@@ -134,13 +195,23 @@ function createLink(path, source) {
134
195
  * @param path - The absolute directory to remove.
135
196
  * @throws The last removal error once {@link REMOVE_TREE_MAX_ATTEMPTS} attempts are exhausted,
136
197
  * or immediately for any error whose code is not in {@link REMOVE_TREE_RETRYABLE_CODES}.
137
- * @remarks On Windows, a directory that a just-exited process still holds as its current
198
+ * @remarks On Windows, a directory that a recently exited process still holds as its current
138
199
  * working directory throws `EPERM` for a short interval after that process exits. Node's own
139
200
  * `rmSync` `maxRetries`/`retryDelay` options do not cover this error class on that host: probed
140
201
  * against a real held directory, they neither delay nor retry before rethrowing, so the retry
141
202
  * is implemented here with a synchronous sleep instead. Ten attempts 100ms apart bound the wait
142
203
  * at roughly one second. A hold that outlasts that second is {@link destroyScratch}'s case, which
143
204
  * retries every refusal inside a caller's budget rather than the codes named here.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * import { existsSync } from 'node:fs'
209
+ * import { removeTree } from '@orkestrel/test/server'
210
+ *
211
+ * removeTree('/scratch/tree')
212
+ *
213
+ * existsSync('/scratch/tree') // false
214
+ * ```
144
215
  */
145
216
  function removeTree(path) {
146
217
  for (let attempt = 1;; attempt++) try {
@@ -169,7 +240,7 @@ function removeTree(path) {
169
240
  * key below it, and it applies to a named target and a walked entry alike.
170
241
  */
171
242
  function readInventory(root, targets, options) {
172
- const supplied = resolve(typeof root === "string" ? root : fileURLToPath(root));
243
+ const supplied = resolve(isString(root) ? root : fileURLToPath(root));
173
244
  const rootStatus = lstatSync(supplied);
174
245
  if (rootStatus.isSymbolicLink()) throw new Error("Root is a symbolic link");
175
246
  if (!rootStatus.isDirectory()) throw new Error("Root is not a directory");
@@ -240,6 +311,14 @@ function readInventory(root, targets, options) {
240
311
  *
241
312
  * A Linux zombie — a process that has exited and whose parent has not reaped it — still accepts
242
313
  * signal `0`, so its `/proc` status is read and a `Z` state reads as false.
314
+ *
315
+ * @example
316
+ * ```ts
317
+ * import { isRunning } from '@orkestrel/test/server'
318
+ *
319
+ * isRunning(process.pid) // true
320
+ * isRunning(2 ** 31) // false
321
+ * ```
243
322
  */
244
323
  function isRunning(pid) {
245
324
  try {
@@ -269,6 +348,20 @@ function isRunning(pid) {
269
348
  * every other error ends the wait. The interval is validated for consistency with the wait family but
270
349
  * is not used, because this helper parks on the socket's events. Both listeners are removed on every
271
350
  * settlement, so a caller may wait on one socket repeatedly.
351
+ *
352
+ * @example
353
+ * ```ts
354
+ * import { connect, createServer } from 'node:net'
355
+ * import { createLoopback, waitForSocketClose } from '@orkestrel/test/server'
356
+ *
357
+ * const loopback = await createLoopback(createServer((socket) => socket.end()))
358
+ * const client = connect(loopback.port, '127.0.0.1')
359
+ *
360
+ * await waitForSocketClose(client, { budget: 1000 }) // undefined
361
+ * client.destroyed // true
362
+ *
363
+ * await loopback.destroy()
364
+ * ```
272
365
  */
273
366
  async function waitForSocketClose(socket, options) {
274
367
  const budget = options?.budget ?? 1e3;
@@ -321,7 +414,7 @@ async function waitForSocketClose(socket, options) {
321
414
  * @throws The abort reason, or an `Error` when a bound is invalid or the budget elapses. The
322
415
  * exhaustion error carries the last host refusal as its `cause`.
323
416
  * @remarks Default budget: `10000` milliseconds. Default interval: `25` milliseconds. A host holds a
324
- * directory for a short interval after the process that held it exits, and a just-stopped child's
417
+ * directory for a short interval after the process that held it exits, and a recently stopped child's
325
418
  * working directory is the case this exists for, so removal is attempted until the host lets go
326
419
  * rather than exactly once. {@link ScratchInterface.destroy} stays synchronous and is unchanged; this
327
420
  * is the bounded retry around it. A directory nothing releases still fails, with the host's own
@@ -468,6 +561,13 @@ async function requestUpgrade(port, options) {
468
561
  * The answer is false on a filesystem carrying neither reparse points nor symbolic links. Every call
469
562
  * probes and cleans up after itself, so a host whose answer changes is read again rather than
470
563
  * remembered.
564
+ *
565
+ * @example
566
+ * ```ts
567
+ * import { supportsDirectoryLinks } from '@orkestrel/test/server'
568
+ *
569
+ * supportsDirectoryLinks() // true where the host creates a symbolic link or a junction
570
+ * ```
471
571
  */
472
572
  function supportsDirectoryLinks() {
473
573
  const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-directory-links-"));
@@ -524,6 +624,13 @@ function supportsFileLinks() {
524
624
  * it: a POSIX host running as uid `0` stores every bit faithfully and bypasses the access check the
525
625
  * bits describe, so a caller that needs a permission to be enforced probes the refusal it needs
526
626
  * rather than reading this.
627
+ *
628
+ * @example
629
+ * ```ts
630
+ * import { supportsMode } from '@orkestrel/test/server'
631
+ *
632
+ * supportsMode() // true on a POSIX host, false on Windows
633
+ * ```
527
634
  */
528
635
  function supportsMode() {
529
636
  const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-mode-"));
@@ -549,6 +656,13 @@ function supportsMode() {
549
656
  * routes the second write onto the first entry, so reading the first back returns the second's
550
657
  * contents and the answer is false. The answer is true on a typical POSIX host and false on a
551
658
  * case-folding Windows or macOS volume.
659
+ *
660
+ * @example
661
+ * ```ts
662
+ * import { supportsCase } from '@orkestrel/test/server'
663
+ *
664
+ * supportsCase() // true on a case-sensitive volume, false on a case-folding one
665
+ * ```
552
666
  */
553
667
  function supportsCase() {
554
668
  const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-case-"));
@@ -574,6 +688,13 @@ function supportsCase() {
574
688
  * @remarks Byte `0x80` is an invalid UTF-8 lead byte. POSIX stores the name verbatim and Windows
575
689
  * rejects it with `ENOENT`, so the answer is true on POSIX and false on Windows. The path is passed
576
690
  * as a `Buffer` because the byte survives no string round trip.
691
+ *
692
+ * @example
693
+ * ```ts
694
+ * import { supportsBytes } from '@orkestrel/test/server'
695
+ *
696
+ * supportsBytes() // true on a POSIX host, false on Windows
697
+ * ```
577
698
  */
578
699
  function supportsBytes() {
579
700
  const directory = mkdtempSync(join(tmpdir(), "orkestrel-test-bytes-"));
@@ -598,6 +719,67 @@ function supportsBytes() {
598
719
  * `/` or `\`; or when allocation or seeding fails.
599
720
  * @remarks Default parent: the host temporary directory. Default prefix: `orkestrel-test-`. Seed
600
721
  * keys use root-relative paths.
722
+ *
723
+ * @example Own a temporary directory
724
+ * ```ts
725
+ * import { createScratch } from '@orkestrel/test/server'
726
+ *
727
+ * const scratch = createScratch({ prefix: 'guide-', files: { 'src/index.ts': 'export {}\n' } })
728
+ *
729
+ * scratch.read('src/index.ts') // 'export {}\n'
730
+ * scratch.has('src') // true
731
+ * scratch.read('src') // throws Error: Scratch path is a directory: src
732
+ * scratch.read('missing.ts') // undefined
733
+ * scratch.write('../escape.ts', '') // throws Error: Path outside scratch directory: ../escape.ts
734
+ *
735
+ * // `write` answers the contained path it wrote, the way `ensure` and `link` answer theirs, so the
736
+ * // path goes straight to the code under test without joining it again.
737
+ * scratch.write('src/notes.ts', 'export {}\n') // `${scratch.path}/src/notes.ts`
738
+ *
739
+ * // `ensure` is how you get an empty directory, because every `write` creates a file.
740
+ * scratch.ensure('empty')
741
+ * scratch.names() // ['empty', 'src']
742
+ * scratch.names('empty') // []
743
+ *
744
+ * // `parent` puts the allocation somewhere other than the host temporary directory.
745
+ * const child = createScratch({ parent: scratch.path, prefix: 'child-' })
746
+ * scratch.names().length // 3 — 'empty', 'src', and the child allocation
747
+ * child.destroy()
748
+ * scratch.names().length // 2 — the child removed itself and nothing else
749
+ *
750
+ * // `link` creates the symbolic link the threat model names, and `read` follows it. A directory
751
+ * // source runs on a host that creates no symbolic link too; see "Hosts that create no symbolic
752
+ * // link" for what such a host does with a file source.
753
+ * const outside = createScratch({ prefix: 'outside-', files: { 'read.ts': 'export {}\n' } })
754
+ * scratch.link('gate', outside.path) // `${scratch.path}/gate` — the link's own path, not its destination
755
+ * scratch.read('gate/read.ts') // 'export {}\n' — read through the link, at its destination
756
+ *
757
+ * // A link pointing out of the allocation is resolved through, so a contained path acts outside it.
758
+ * scratch.ensure('gate/made') // `${scratch.path}/gate/made` — the lexical path, not the destination
759
+ * outside.names() // ['made', 'read.ts'] — the directory was made under `outside.path`
760
+ * scratch.names('gate') // ['made', 'read.ts'] — the same entries, listed through the link
761
+ *
762
+ * // `link` acts at the final segment rather than through it, so `gate` is occupied.
763
+ * scratch.link('gate', outside.path) // throws Error: EEXIST: file already exists
764
+ *
765
+ * // `has` reads the final segment without following it, and `read` follows it.
766
+ * scratch.link('dangling', 'missing.ts')
767
+ * scratch.has('dangling') // true — the link is there
768
+ * scratch.read('dangling') // undefined — what it points at is not
769
+ *
770
+ * // `remove` takes one contained entry and acts at the final segment, so a link goes and whatever it
771
+ * // pointed at stays. A missing target is a no-op.
772
+ * scratch.remove('dangling')
773
+ * scratch.has('dangling') // false
774
+ * scratch.remove('missing.ts') // no throw — there was nothing there
775
+ * scratch.remove('src') // the directory and everything under it
776
+ * scratch.names() // ['empty', 'gate']
777
+ *
778
+ * scratch.destroy()
779
+ * scratch.destroy() // no-op — destroy is idempotent
780
+ * outside.has('made') // true — destroy unlinks `gate` and leaves what it pointed at
781
+ * outside.destroy()
782
+ * ```
601
783
  */
602
784
  function createScratch(options) {
603
785
  const parent = resolve(options?.parent ?? tmpdir());
@@ -696,7 +878,7 @@ async function createLoopback(server) {
696
878
  server.listen(0, "127.0.0.1");
697
879
  await once(server, "listening");
698
880
  const address = server.address();
699
- if (typeof address !== "object" || address === null || !("port" in address) || typeof address.port !== "number") throw new Error(`Loopback address must have a numeric port; found ${String(address)}`);
881
+ if (!isObject(address) || !("port" in address) || !isNumber(address.port)) throw new Error(`Loopback address must have a numeric port; found ${String(address)}`);
700
882
  const port = address.port;
701
883
  let destruction;
702
884
  return {
@@ -704,7 +886,7 @@ async function createLoopback(server) {
704
886
  port,
705
887
  destroy() {
706
888
  if (destruction === void 0) destruction = new Promise((resolveClose, rejectClose) => {
707
- if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") server.closeAllConnections();
889
+ if ("closeAllConnections" in server && isFunction(server.closeAllConnections)) server.closeAllConnections();
708
890
  server.close((error) => {
709
891
  if (error === void 0 || "code" in error && error.code === "ERR_SERVER_NOT_RUNNING") resolveClose();
710
892
  else rejectClose(error);