@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.
- package/README.md +11 -13
- package/dist/src/browser/index.d.ts +41 -6
- package/dist/src/browser/index.js +5 -1
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +79 -23
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +79 -44
- package/dist/src/core/index.d.ts +79 -44
- package/dist/src/core/index.js +79 -23
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +188 -6
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +190 -7
- package/dist/src/server/index.d.ts +190 -7
- package/dist/src/server/index.js +188 -6
- package/dist/src/server/index.js.map +1 -1
- package/package.json +15 -13
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
package/dist/src/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
|
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 (
|
|
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 &&
|
|
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);
|