poe-code 4.0.11 → 4.0.13

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.
@@ -0,0 +1,1293 @@
1
+ import { constants as nodeFsConstants } from "node:fs";
2
+ import { getSystemErrorMap } from "node:util";
3
+ import { Volume, createFsFromVolume } from "memfs";
4
+ import { makeFsModule } from "./fs.js";
5
+ // The one case table the fs module's conformance is measured against. It is data rather than
6
+ // assertions because three suites read it and each asks it a different question:
7
+ //
8
+ // - fs.conformance.test.ts drives every case twice over identical volumes — once through the
9
+ // module and once against memfs directly — and proves the two answers are the same; it then
10
+ // drives each case once more and holds the answer against real node's recorded truth;
11
+ // - fs.test.ts drives each case against a stub replaying `node`, so node's full truth
12
+ // (errno, syscall, and dest included) is asserted with no real filesystem, and drives the
13
+ // cases memfs models node's way over a volume so the operation's effect is exercised;
14
+ // - scripts/record-fs-conformance.ts records what real node:fs/promises answers for every case
15
+ // into fs.node-truth.json, in a temporary directory it removes again. Refresh it with
16
+ // `npm run record:fs-conformance`, on each platform the suite runs on; the suite fails when a
17
+ // case it defines has no recording, so adding a case here forces a re-record.
18
+ //
19
+ // Every case's semantics below were recorded from real node v22.22.2 on darwin with a umask of
20
+ // 0o022, and the fixture is what proves they still are: the recorder answers for the same table.
21
+ //
22
+ // What a case may state and what it must derive is the whole of how this table stays honest
23
+ // across platforms. A case states its code, the syscall node blamed, and the paths it was
24
+ // handed — that is the semantic claim, and it holds wherever node runs. It never states an
25
+ // errno: an errno is the number the running platform gives the code (ENOTEMPTY is -66 on darwin
26
+ // and -39 on linux), so systemErrorTruth reads it back from node's own table instead, and
27
+ // fs.conformance.test.ts fails any case whose errno disagrees with the running platform's. The
28
+ // message text is composed the same way for consistency rather than necessity — libuv carries
29
+ // its own description strings, so only the number moves between platforms.
30
+ //
31
+ // What no derivation can settle is a case where the platform picks a different code altogether:
32
+ // darwin's copyfile refuses a directory source with ENOTSUP where linux answers EISDIR. The one
33
+ // such case is marked below, and the per-platform fixture is what answers for it.
34
+ //
35
+ // A case is a title, the volume it needs, and one call. It carries no assertion: what the two
36
+ // drives prove is fixed, so a case that wants its own expectation wants a test rather than a
37
+ // row here.
38
+ const SAMPLE_TEXT = "héllo ✓";
39
+ // Every string encoding node accepts, including its aliases.
40
+ export const STRING_ENCODINGS = [
41
+ "utf8",
42
+ "utf-8",
43
+ "utf16le",
44
+ "utf-16le",
45
+ "ucs2",
46
+ "ucs-2",
47
+ "latin1",
48
+ "binary",
49
+ "ascii",
50
+ "base64",
51
+ "base64url",
52
+ "hex"
53
+ ];
54
+ export function encode(text, encoding) {
55
+ return Buffer.from(text, "utf8").toString(encoding);
56
+ }
57
+ // A case memfs cannot answer at all rather than answering differently: a real symlink cycle
58
+ // recurses inside memfs with the event loop blocked, so driving it would hang the suite rather
59
+ // than fail it. Those cases are proven against the recorded replay alone.
60
+ export const HANGS = "hangs";
61
+ // node pairs an errno name with the errno number and the description libuv gave it,
62
+ // and every system error it raises is composed from that one table. Reading the pair
63
+ // back from the table is what keeps an expectation the platform's answer rather than
64
+ // the darwin numbers and wording these cases happened to be recorded on.
65
+ export function readSystemError(code) {
66
+ const entry = [...getSystemErrorMap()].find(([, [name]]) => name === code);
67
+ /* c8 ignore next 3 -- every code this table names is one node defines. */
68
+ if (entry === undefined) {
69
+ throw new Error(`node does not define the ${code} system error.`);
70
+ }
71
+ const [errno, [, description]] = entry;
72
+ return { errno, description };
73
+ }
74
+ // The exact text node gives a system error, composed the way node's own uvException
75
+ // does: the errno name, the description, the syscall, and the paths it was handed.
76
+ // A path is quoted only when the error carries one — an error the read syscall
77
+ // raised is blamed on a descriptor and names no path — and a second path is appended
78
+ // for the operations node reports a dest for.
79
+ export function systemErrorMessage(code, syscall, path, dest) {
80
+ const { description } = readSystemError(code);
81
+ const target = path === undefined ? "" : ` '${path}'`;
82
+ const destination = dest === undefined ? "" : ` -> '${dest}'`;
83
+ return `${code}: ${description}, ${syscall}${target}${destination}`;
84
+ }
85
+ // Answers exactly what real node answered for the case. Every operation name is stubbed with
86
+ // the same answer rather than a per-case list: only the operation the case invokes is reached,
87
+ // because with no root the module calls nothing else, so answering every name cannot let a
88
+ // case pass through an operation it did not name.
89
+ export function createNodeTruthFs(truth) {
90
+ const answer = async () => {
91
+ if ("result" in truth) {
92
+ return truth.result;
93
+ }
94
+ const error = new Error(truth.message);
95
+ error.name = truth.name;
96
+ error.code = truth.code;
97
+ error.errno = truth.errno;
98
+ error.syscall = truth.syscall;
99
+ error.path = truth.path;
100
+ if (truth.dest !== undefined) {
101
+ error.dest = truth.dest;
102
+ }
103
+ throw error;
104
+ };
105
+ return new Proxy({}, { get: () => answer });
106
+ }
107
+ // Reads every field node carries, so a replay proves errno, syscall, and dest cross
108
+ // unchanged rather than only the message.
109
+ export async function readRecordedOutcome(operation) {
110
+ try {
111
+ return { result: await operation };
112
+ }
113
+ catch (error) {
114
+ const rejection = error;
115
+ return {
116
+ name: rejection.name,
117
+ message: rejection.message,
118
+ code: rejection.code,
119
+ errno: rejection.errno,
120
+ syscall: rejection.syscall,
121
+ path: rejection.path,
122
+ dest: rejection.dest
123
+ };
124
+ }
125
+ }
126
+ // Reads back only what memfs models, so a comparison never pretends memfs answered an errno,
127
+ // a syscall, or a dest.
128
+ export async function readObserved(operation) {
129
+ try {
130
+ return { result: await operation };
131
+ }
132
+ catch (error) {
133
+ const rejection = error;
134
+ return {
135
+ name: rejection.name,
136
+ message: rejection.message,
137
+ code: rejection.code,
138
+ path: rejection.path
139
+ };
140
+ }
141
+ }
142
+ // Projects a recorded truth down to the fields memfs models, which is what makes the two
143
+ // comparable.
144
+ export function toObserved(truth) {
145
+ if ("result" in truth) {
146
+ return { result: truth.result };
147
+ }
148
+ const { name, message, code, path } = truth;
149
+ return { name, message, code, path };
150
+ }
151
+ // The same projection over a recording read back from the fixture, which has been through JSON
152
+ // and so cannot be asked whether it has a `result` key: `resolved` is the discriminator that
153
+ // survived, and for a resolved case the result reads back as undefined exactly when node
154
+ // answered nothing.
155
+ export function toRecordedObserved(entry) {
156
+ return entry.resolved
157
+ ? { result: entry.node.result }
158
+ : toObserved(entry.node);
159
+ }
160
+ // The volume every case starts from, and the two drivers a differential needs: the module over
161
+ // one copy of it and memfs's own promises API over an identical copy.
162
+ export function driveCase(testCase) {
163
+ const volume = createCaseVolume(testCase);
164
+ const referenceVolume = createCaseVolume(testCase);
165
+ return {
166
+ fs: makeFsModule({
167
+ fs: createFsFromVolume(volume).promises
168
+ }),
169
+ reference: createFsFromVolume(referenceVolume).promises,
170
+ volume
171
+ };
172
+ }
173
+ // The directory every case's paths are spelled under. The recorder rebuilds the volume below
174
+ // inside a temporary directory and rewrites this prefix onto it, so a case that named a path
175
+ // outside it would be recorded against the real filesystem rather than the copy.
176
+ export const CASE_ROOT = "/repo";
177
+ // The volume every case starts from, as a path-to-contents map: memfs builds it with
178
+ // Volume.fromJSON and the recorder stages the same entries with real mkdir and writeFile, so
179
+ // both sides of a comparison start from one description rather than two that can drift.
180
+ export const CASE_VOLUME = { [`${CASE_ROOT}/keep.txt`]: "" };
181
+ function createCaseVolume(testCase) {
182
+ const volume = Volume.fromJSON(CASE_VOLUME, "/");
183
+ testCase.setup?.(volume);
184
+ return volume;
185
+ }
186
+ // What node answered for a system error, composed from node's own table rather than typed out.
187
+ // A case names only the code, the syscall node blamed, and the paths it was handed: those are
188
+ // the semantic claim, and the errno and the message text are the platform's answer to it.
189
+ //
190
+ // This is what keeps an expectation from being darwin's. An errno is the number the running
191
+ // platform gives the code — ENOTEMPTY is -66 on darwin and -39 on linux — so a typed-out number
192
+ // is a claim only the platform it was typed on can keep. Reading it back through
193
+ // getSystemErrorMap() asks the platform the suite is running on instead. The message text needs
194
+ // no such care but is composed the same way for one reason: libuv carries its own description
195
+ // strings, so "directory not empty" is the same sentence everywhere and only the number moves.
196
+ //
197
+ // The code itself can still be the platform's choice — darwin's copyfile refuses a directory
198
+ // source with ENOTSUP where linux answers EISDIR — which no table lookup can settle. Such a case
199
+ // declares its code per platform below.
200
+ export function systemErrorTruth(entry) {
201
+ return {
202
+ name: "Error",
203
+ message: systemErrorMessage(entry.code, entry.syscall, entry.path, entry.dest),
204
+ code: entry.code,
205
+ errno: readSystemError(entry.code).errno,
206
+ syscall: entry.syscall,
207
+ path: entry.path,
208
+ ...(entry.dest === undefined ? {} : { dest: entry.dest })
209
+ };
210
+ }
211
+ // name is "Error" for every case: node raises a system error as a plain Error, never as
212
+ // a TypeError. That is the line an argument error sits on the other side of, and the
213
+ // argument validation block in fs.test.ts holds it there by asserting TypeError/RangeError.
214
+ // memfs names each of these Error on its own, so the claim is measured over a real
215
+ // filesystem for every case memfs models rather than only replayed.
216
+ export function toConformanceCase(entry) {
217
+ return {
218
+ title: entry.title,
219
+ setup: entry.setup,
220
+ invoke: entry.invoke,
221
+ node: systemErrorTruth(entry),
222
+ gap: entry.gap
223
+ };
224
+ }
225
+ // fs-error-message-parity's cases: one representative per syscall the module's surface can
226
+ // reach, which is what the message format is measured over.
227
+ export const SYSTEM_ERROR_CASES = [
228
+ {
229
+ title: "readFile on a missing path is blamed on open",
230
+ syscall: "open",
231
+ code: "ENOENT",
232
+ path: "/repo/missing.txt",
233
+ invoke: (fs) => fs.readFile("/repo/missing.txt", "utf8")
234
+ },
235
+ {
236
+ // The one syscall in the table node names no path for: by the time the read fails
237
+ // node holds a descriptor rather than a path, so the message stops at the syscall
238
+ // and the error carries no path field either. A format that always quoted a path
239
+ // would spell this ", read ''" or ", read 'undefined'".
240
+ title: "readFile on a directory is blamed on read, which names no path",
241
+ syscall: "read",
242
+ code: "EISDIR",
243
+ setup: (volume) => volume.mkdirSync("/repo/d"),
244
+ invoke: (fs) => fs.readFile("/repo/d", "utf8"),
245
+ gap: {
246
+ reason: "memfs blames the open rather than the read and names the path node omits",
247
+ memfs: {
248
+ name: "Error",
249
+ message: systemErrorMessage("EISDIR", "open", "/repo/d"),
250
+ code: "EISDIR",
251
+ path: "/repo/d"
252
+ }
253
+ }
254
+ },
255
+ {
256
+ title: "readdir on a missing path is blamed on scandir",
257
+ syscall: "scandir",
258
+ code: "ENOENT",
259
+ path: "/repo/missing",
260
+ invoke: (fs) => fs.readdir("/repo/missing")
261
+ },
262
+ {
263
+ title: "mkdir on an existing directory is blamed on mkdir",
264
+ syscall: "mkdir",
265
+ code: "EEXIST",
266
+ path: "/repo/d",
267
+ setup: (volume) => volume.mkdirSync("/repo/d"),
268
+ invoke: (fs) => fs.mkdir("/repo/d")
269
+ },
270
+ {
271
+ title: "rmdir on a non-empty directory is blamed on rmdir",
272
+ syscall: "rmdir",
273
+ code: "ENOTEMPTY",
274
+ path: "/repo/d",
275
+ setup: (volume) => {
276
+ volume.mkdirSync("/repo/d");
277
+ volume.writeFileSync("/repo/d/child.txt", "child");
278
+ },
279
+ invoke: (fs) => fs.rmdir("/repo/d")
280
+ },
281
+ {
282
+ // The module exports no unlink, so the only way to reach the syscall is rm, which
283
+ // node implements as an lstat and then an unlink: the lstat of a file inside a
284
+ // directory that denies writes succeeds and the unlink is what the mode refuses.
285
+ title: "rm of a file in a write-denied directory is blamed on unlink",
286
+ syscall: "unlink",
287
+ code: "EACCES",
288
+ path: "/repo/ro/child.txt",
289
+ setup: (volume) => {
290
+ volume.mkdirSync("/repo/ro");
291
+ volume.writeFileSync("/repo/ro/child.txt", "child");
292
+ volume.chmodSync("/repo/ro", 0o500);
293
+ },
294
+ invoke: (fs) => fs.rm("/repo/ro/child.txt"),
295
+ gap: {
296
+ reason: "memfs blames rm, the fs function, where node blames the unlink it refused",
297
+ memfs: {
298
+ name: "Error",
299
+ message: systemErrorMessage("EACCES", "rm", "/repo/ro/child.txt"),
300
+ code: "EACCES",
301
+ path: "/repo/ro/child.txt"
302
+ }
303
+ }
304
+ },
305
+ {
306
+ title: "rename of a missing source is blamed on rename and names both paths",
307
+ syscall: "rename",
308
+ code: "ENOENT",
309
+ path: "/repo/missing.txt",
310
+ dest: "/repo/renamed.txt",
311
+ invoke: (fs) => fs.rename("/repo/missing.txt", "/repo/renamed.txt")
312
+ },
313
+ {
314
+ // node lower-cases the syscall it blames, which is not how the fs function is
315
+ // spelled: a message derived from the function name would say 'copyFile'.
316
+ title: "copyFile with COPYFILE_EXCL onto an existing path is blamed on copyfile",
317
+ syscall: "copyfile",
318
+ code: "EEXIST",
319
+ path: "/repo/keep.txt",
320
+ dest: "/repo/taken.txt",
321
+ setup: (volume) => volume.writeFileSync("/repo/taken.txt", "taken"),
322
+ invoke: (fs) => fs.copyFile("/repo/keep.txt", "/repo/taken.txt", nodeFsConstants.COPYFILE_EXCL),
323
+ gap: {
324
+ reason: "memfs blames copyFile, the fs function, where node blames the lower-cased syscall",
325
+ memfs: {
326
+ name: "Error",
327
+ message: systemErrorMessage("EEXIST", "copyFile", "/repo/keep.txt", "/repo/taken.txt"),
328
+ code: "EEXIST",
329
+ path: "/repo/keep.txt"
330
+ }
331
+ }
332
+ },
333
+ {
334
+ title: "link onto an existing path is blamed on link and names both paths",
335
+ syscall: "link",
336
+ code: "EEXIST",
337
+ path: "/repo/keep.txt",
338
+ dest: "/repo/taken.txt",
339
+ setup: (volume) => volume.writeFileSync("/repo/taken.txt", "taken"),
340
+ invoke: (fs) => fs.link("/repo/keep.txt", "/repo/taken.txt")
341
+ },
342
+ {
343
+ title: "symlink onto an existing path is blamed on symlink and names both paths",
344
+ syscall: "symlink",
345
+ code: "EEXIST",
346
+ path: "/repo/keep.txt",
347
+ dest: "/repo/taken.txt",
348
+ setup: (volume) => volume.writeFileSync("/repo/taken.txt", "taken"),
349
+ invoke: (fs) => fs.symlink("/repo/keep.txt", "/repo/taken.txt")
350
+ },
351
+ {
352
+ title: "readlink on a regular file is blamed on readlink",
353
+ syscall: "readlink",
354
+ code: "EINVAL",
355
+ path: "/repo/keep.txt",
356
+ invoke: (fs) => fs.readlink("/repo/keep.txt")
357
+ },
358
+ {
359
+ title: "realpath on a missing path is blamed on realpath",
360
+ syscall: "realpath",
361
+ code: "ENOENT",
362
+ path: "/repo/missing.txt",
363
+ invoke: (fs) => fs.realpath("/repo/missing.txt")
364
+ },
365
+ {
366
+ title: "stat on a missing path is blamed on stat",
367
+ syscall: "stat",
368
+ code: "ENOENT",
369
+ path: "/repo/missing.txt",
370
+ invoke: (fs) => fs.stat("/repo/missing.txt")
371
+ },
372
+ {
373
+ title: "lstat on a missing path is blamed on lstat",
374
+ syscall: "lstat",
375
+ code: "ENOENT",
376
+ path: "/repo/missing.txt",
377
+ invoke: (fs) => fs.lstat("/repo/missing.txt")
378
+ },
379
+ {
380
+ title: "access on a missing path is blamed on access",
381
+ syscall: "access",
382
+ code: "ENOENT",
383
+ path: "/repo/missing.txt",
384
+ invoke: (fs) => fs.access("/repo/missing.txt")
385
+ },
386
+ {
387
+ title: "chmod on a missing path is blamed on chmod",
388
+ syscall: "chmod",
389
+ code: "ENOENT",
390
+ path: "/repo/missing.txt",
391
+ invoke: (fs) => fs.chmod("/repo/missing.txt", 0o600)
392
+ },
393
+ {
394
+ // node blames the utime syscall, which is the fs function's name without its s.
395
+ title: "utimes on a missing path is blamed on utime",
396
+ syscall: "utime",
397
+ code: "ENOENT",
398
+ path: "/repo/missing.txt",
399
+ invoke: (fs) => fs.utimes("/repo/missing.txt", 0, 0),
400
+ gap: {
401
+ reason: "memfs blames utimes, the fs function, where node blames the utime syscall",
402
+ memfs: {
403
+ name: "Error",
404
+ message: systemErrorMessage("ENOENT", "utimes", "/repo/missing.txt"),
405
+ code: "ENOENT",
406
+ path: "/repo/missing.txt"
407
+ }
408
+ }
409
+ },
410
+ {
411
+ // fs/promises has no truncate syscall to blame: it opens the path and ftruncates
412
+ // the descriptor, so a path it cannot open is blamed on the open. The syscall
413
+ // truncate is unreachable through the module, and ftruncate with it — the
414
+ // descriptor the module would need to reach one is FileHandle, which SafeJS refuses.
415
+ title: "truncate on a missing path is blamed on the open it opens the path with",
416
+ syscall: "open",
417
+ code: "ENOENT",
418
+ path: "/repo/missing.txt",
419
+ invoke: (fs) => fs.truncate("/repo/missing.txt", 0)
420
+ }
421
+ ];
422
+ // fs-mkdir-rm-edges' cases: the operations whose node semantics are most often approximated
423
+ // wrong, which is mostly a matter of what they answer rather than what they refuse.
424
+ export const MKDIR_RM_RMDIR_CASES = [
425
+ {
426
+ title: "mkdir recursive returns the first directory it created",
427
+ invoke: (fs) => fs.mkdir("/repo/a/b/c", { recursive: true }),
428
+ node: { result: "/repo/a" },
429
+ gap: {
430
+ reason: "memfs returns the requested path rather than the first directory created",
431
+ memfs: { result: "/repo/a/b/c" }
432
+ }
433
+ },
434
+ {
435
+ title: "mkdir recursive returns the first directory created below an existing parent",
436
+ setup: (volume) => volume.mkdirSync("/repo/a"),
437
+ invoke: (fs) => fs.mkdir("/repo/a/b/c", { recursive: true }),
438
+ node: { result: "/repo/a/b" },
439
+ gap: {
440
+ reason: "memfs returns the requested path rather than the first directory created",
441
+ memfs: { result: "/repo/a/b/c" }
442
+ }
443
+ },
444
+ {
445
+ // node builds the answer from the path it was handed rather than a normalised
446
+ // one, so './' survives in the result. Recorded from node, which answers
447
+ // '/repo/./d1' here and 'repo/x' for a relative 'repo/x/y'.
448
+ title: "mkdir recursive returns the first directory it created as the path was spelled",
449
+ invoke: (fs) => fs.mkdir("/repo/./d1/../d1/d2", { recursive: true }),
450
+ node: { result: "/repo/./d1" },
451
+ gap: {
452
+ reason: "memfs returns the requested path rather than the first directory created",
453
+ memfs: { result: "/repo/./d1/../d1/d2" }
454
+ }
455
+ },
456
+ {
457
+ title: "mkdir recursive on an existing directory creates nothing and returns undefined",
458
+ setup: (volume) => volume.mkdirSync("/repo/a"),
459
+ invoke: (fs) => fs.mkdir("/repo/a", { recursive: true }),
460
+ node: { result: undefined }
461
+ },
462
+ {
463
+ title: "mkdir non-recursive with a missing parent rejects with ENOENT",
464
+ invoke: (fs) => fs.mkdir("/repo/missing/leaf"),
465
+ node: systemErrorTruth({ code: "ENOENT", syscall: "mkdir", path: "/repo/missing/leaf" }),
466
+ gap: {
467
+ reason: "memfs blames the missing parent rather than the path mkdir was given",
468
+ memfs: {
469
+ name: "Error",
470
+ message: "ENOENT: no such file or directory, mkdir '/repo/missing'",
471
+ code: "ENOENT",
472
+ path: "/repo/missing"
473
+ }
474
+ }
475
+ },
476
+ {
477
+ title: "mkdir non-recursive on an existing directory rejects with EEXIST",
478
+ setup: (volume) => volume.mkdirSync("/repo/a"),
479
+ invoke: (fs) => fs.mkdir("/repo/a"),
480
+ node: systemErrorTruth({ code: "EEXIST", syscall: "mkdir", path: "/repo/a" })
481
+ },
482
+ {
483
+ title: "mkdir non-recursive through a file segment rejects with ENOTDIR",
484
+ setup: (volume) => volume.writeFileSync("/repo/f", "x"),
485
+ invoke: (fs) => fs.mkdir("/repo/f/leaf"),
486
+ node: systemErrorTruth({ code: "ENOTDIR", syscall: "mkdir", path: "/repo/f/leaf" }),
487
+ gap: {
488
+ reason: "memfs blames the file segment rather than the path mkdir was given",
489
+ memfs: {
490
+ name: "Error",
491
+ message: "ENOTDIR: not a directory, mkdir '/repo/f'",
492
+ code: "ENOTDIR",
493
+ path: "/repo/f"
494
+ }
495
+ }
496
+ },
497
+ {
498
+ title: "mkdir recursive through a file segment rejects with ENOTDIR",
499
+ setup: (volume) => volume.writeFileSync("/repo/f", "x"),
500
+ invoke: (fs) => fs.mkdir("/repo/f/leaf", { recursive: true }),
501
+ node: systemErrorTruth({ code: "ENOTDIR", syscall: "mkdir", path: "/repo/f/leaf" })
502
+ },
503
+ {
504
+ title: "mkdir non-recursive on an existing file rejects with EEXIST",
505
+ setup: (volume) => volume.writeFileSync("/repo/f", "x"),
506
+ invoke: (fs) => fs.mkdir("/repo/f"),
507
+ node: systemErrorTruth({ code: "EEXIST", syscall: "mkdir", path: "/repo/f" })
508
+ },
509
+ {
510
+ title: "mkdir recursive on an existing file rejects with EEXIST",
511
+ setup: (volume) => volume.writeFileSync("/repo/f", "x"),
512
+ invoke: (fs) => fs.mkdir("/repo/f", { recursive: true }),
513
+ node: systemErrorTruth({ code: "EEXIST", syscall: "mkdir", path: "/repo/f" }),
514
+ gap: {
515
+ reason: "memfs forgives an existing file when mkdir is recursive and resolves",
516
+ memfs: { result: undefined }
517
+ }
518
+ },
519
+ {
520
+ title: "rm on a missing path without force rejects with ENOENT",
521
+ invoke: (fs) => fs.rm("/repo/nope"),
522
+ node: systemErrorTruth({ code: "ENOENT", syscall: "lstat", path: "/repo/nope" }),
523
+ gap: {
524
+ reason: "memfs blames stat where node's rm lstats the path first",
525
+ memfs: {
526
+ name: "Error",
527
+ message: "ENOENT: no such file or directory, stat '/repo/nope'",
528
+ code: "ENOENT",
529
+ path: "/repo/nope"
530
+ }
531
+ }
532
+ },
533
+ {
534
+ title: "rm on a missing path with force resolves",
535
+ invoke: (fs) => fs.rm("/repo/nope", { force: true }),
536
+ node: { result: undefined }
537
+ },
538
+ {
539
+ // force forgives a missing path and nothing else: the lstat that fails here
540
+ // fails with ENOTDIR, so force does not swallow it.
541
+ title: "rm force through a file segment still rejects with ENOTDIR",
542
+ setup: (volume) => volume.writeFileSync("/repo/f", "x"),
543
+ invoke: (fs) => fs.rm("/repo/f/leaf", { force: true }),
544
+ node: systemErrorTruth({ code: "ENOTDIR", syscall: "lstat", path: "/repo/f/leaf" }),
545
+ gap: {
546
+ reason: "memfs blames stat where node's rm lstats the path first",
547
+ memfs: {
548
+ name: "Error",
549
+ message: "ENOTDIR: not a directory, stat '/repo/f/leaf'",
550
+ code: "ENOTDIR",
551
+ path: "/repo/f/leaf"
552
+ }
553
+ }
554
+ },
555
+ {
556
+ title: "rm on a directory without recursive rejects with ERR_FS_EISDIR",
557
+ setup: (volume) => volume.mkdirSync("/repo/d"),
558
+ invoke: (fs) => fs.rm("/repo/d"),
559
+ node: {
560
+ name: "SystemError",
561
+ message: "Path is a directory: rm returned EISDIR (is a directory) /repo/d",
562
+ code: "ERR_FS_EISDIR",
563
+ errno: 21,
564
+ syscall: "rm",
565
+ path: "/repo/d"
566
+ },
567
+ gap: {
568
+ reason: "memfs raises a plain Error and prefixes the code to node's message",
569
+ memfs: {
570
+ name: "Error",
571
+ message: "[ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) /repo/d",
572
+ code: "ERR_FS_EISDIR",
573
+ path: "/repo/d"
574
+ }
575
+ }
576
+ },
577
+ {
578
+ title: "rm force on a directory without recursive still rejects with ERR_FS_EISDIR",
579
+ setup: (volume) => volume.mkdirSync("/repo/d"),
580
+ invoke: (fs) => fs.rm("/repo/d", { force: true }),
581
+ node: {
582
+ name: "SystemError",
583
+ message: "Path is a directory: rm returned EISDIR (is a directory) /repo/d",
584
+ code: "ERR_FS_EISDIR",
585
+ errno: 21,
586
+ syscall: "rm",
587
+ path: "/repo/d"
588
+ },
589
+ gap: {
590
+ reason: "memfs raises a plain Error and prefixes the code to node's message",
591
+ memfs: {
592
+ name: "Error",
593
+ message: "[ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) /repo/d",
594
+ code: "ERR_FS_EISDIR",
595
+ path: "/repo/d"
596
+ }
597
+ }
598
+ },
599
+ {
600
+ title: "rm recursive on a non-empty directory resolves",
601
+ setup: (volume) => {
602
+ volume.mkdirSync("/repo/d/e", { recursive: true });
603
+ volume.writeFileSync("/repo/d/e/f", "x");
604
+ },
605
+ invoke: (fs) => fs.rm("/repo/d", { recursive: true }),
606
+ node: { result: undefined }
607
+ },
608
+ {
609
+ title: "rm on a symlink to a directory unlinks the link",
610
+ setup: (volume) => {
611
+ volume.mkdirSync("/repo/d");
612
+ volume.symlinkSync("/repo/d", "/repo/link");
613
+ },
614
+ invoke: (fs) => fs.rm("/repo/link"),
615
+ node: { result: undefined },
616
+ gap: {
617
+ reason: "memfs follows the link and refuses it as a directory instead of unlinking it",
618
+ memfs: {
619
+ name: "Error",
620
+ message: "[ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) /repo/link",
621
+ code: "ERR_FS_EISDIR",
622
+ path: "/repo/link"
623
+ }
624
+ }
625
+ },
626
+ {
627
+ // node's rm lstats the path, so a link whose target is gone is still a link to
628
+ // unlink. force is not needed for that.
629
+ title: "rm on a dangling symlink without force unlinks the link",
630
+ setup: (volume) => volume.symlinkSync("/repo/ghost", "/repo/dangle"),
631
+ invoke: (fs) => fs.rm("/repo/dangle"),
632
+ node: { result: undefined },
633
+ gap: {
634
+ reason: "memfs stats through the link and reports the missing target as ENOENT",
635
+ memfs: {
636
+ name: "Error",
637
+ message: "ENOENT: no such file or directory, stat '/repo/dangle'",
638
+ code: "ENOENT",
639
+ path: "/repo/dangle"
640
+ }
641
+ }
642
+ },
643
+ {
644
+ title: "rmdir on a non-empty directory rejects with ENOTEMPTY",
645
+ setup: (volume) => {
646
+ volume.mkdirSync("/repo/d");
647
+ volume.writeFileSync("/repo/d/f", "x");
648
+ },
649
+ invoke: (fs) => fs.rmdir("/repo/d"),
650
+ node: systemErrorTruth({ code: "ENOTEMPTY", syscall: "rmdir", path: "/repo/d" })
651
+ },
652
+ {
653
+ title: "rmdir on a file rejects with ENOTDIR",
654
+ setup: (volume) => volume.writeFileSync("/repo/f", "x"),
655
+ invoke: (fs) => fs.rmdir("/repo/f"),
656
+ node: systemErrorTruth({ code: "ENOTDIR", syscall: "rmdir", path: "/repo/f" })
657
+ },
658
+ {
659
+ title: "rmdir on a missing path rejects with ENOENT",
660
+ invoke: (fs) => fs.rmdir("/repo/nope"),
661
+ node: systemErrorTruth({ code: "ENOENT", syscall: "rmdir", path: "/repo/nope" })
662
+ },
663
+ {
664
+ title: "rmdir on a symlink to a directory rejects with ENOTDIR rather than following it",
665
+ setup: (volume) => {
666
+ volume.mkdirSync("/repo/d");
667
+ volume.symlinkSync("/repo/d", "/repo/link");
668
+ },
669
+ invoke: (fs) => fs.rmdir("/repo/link"),
670
+ node: systemErrorTruth({ code: "ENOTDIR", syscall: "rmdir", path: "/repo/link" })
671
+ },
672
+ {
673
+ // rmdir does not lstat the way rm does; it hands the link to the rmdir syscall,
674
+ // which refuses a non-directory.
675
+ title: "rmdir on a dangling symlink rejects with ENOTDIR",
676
+ setup: (volume) => volume.symlinkSync("/repo/ghost", "/repo/dangle"),
677
+ invoke: (fs) => fs.rmdir("/repo/dangle"),
678
+ node: systemErrorTruth({ code: "ENOTDIR", syscall: "rmdir", path: "/repo/dangle" })
679
+ },
680
+ {
681
+ // node still honours rmdir's deprecated recursive option on v22 (it warns via
682
+ // DEP0147), so the module forwards it rather than refusing it.
683
+ title: "rmdir recursive on a non-empty directory resolves",
684
+ setup: (volume) => volume.mkdirSync("/repo/d/e", { recursive: true }),
685
+ invoke: (fs) => fs.rmdir("/repo/d", { recursive: true }),
686
+ node: { result: undefined }
687
+ }
688
+ ];
689
+ // fs-write-flag-edges' cases. The write side is where a flag decides whether a call creates,
690
+ // truncates, appends, or refuses, and node settles all four in the open it does first — so it
691
+ // blames 'open' rather than writeFile, appendFile, or truncate for every failure below but one,
692
+ // the exception being a write that the open allowed and the descriptor did not.
693
+ export const WRITE_CASES = [
694
+ {
695
+ title: "writeFile with flag wx onto an existing path rejects with EEXIST",
696
+ setup: (volume) => volume.writeFileSync("/repo/f.txt", "original"),
697
+ invoke: (fs) => fs.writeFile("/repo/f.txt", "next", { flag: "wx" }),
698
+ node: systemErrorTruth({ code: "EEXIST", syscall: "open", path: "/repo/f.txt" })
699
+ },
700
+ {
701
+ // r+ opens for writing without creating, so the flag that makes writeFile
702
+ // non-creating is also the one that makes a missing path an error.
703
+ title: "writeFile with flag r+ onto a missing path rejects with ENOENT",
704
+ invoke: (fs) => fs.writeFile("/repo/missing.txt", "next", { flag: "r+" }),
705
+ node: systemErrorTruth({ code: "ENOENT", syscall: "open", path: "/repo/missing.txt" })
706
+ },
707
+ {
708
+ title: "writeFile onto a directory rejects with EISDIR",
709
+ setup: (volume) => volume.mkdirSync("/repo/d"),
710
+ invoke: (fs) => fs.writeFile("/repo/d", "x"),
711
+ node: systemErrorTruth({ code: "EISDIR", syscall: "open", path: "/repo/d" })
712
+ },
713
+ {
714
+ // The one write-side failure node does not blame on the open: r opens
715
+ // read-only and succeeds, so it is the write against that descriptor that
716
+ // fails, and node reports it with no path at all.
717
+ title: "appendFile with flag r rejects with EBADF",
718
+ setup: (volume) => volume.writeFileSync("/repo/log.txt", "first"),
719
+ invoke: (fs) => fs.appendFile("/repo/log.txt", "-second", { flag: "r" }),
720
+ node: systemErrorTruth({ code: "EBADF", syscall: "write" }),
721
+ gap: {
722
+ reason: "memfs ignores the read-only flag and appends where node refuses the write",
723
+ memfs: { result: undefined }
724
+ }
725
+ },
726
+ {
727
+ title: "truncate on a missing path rejects with ENOENT",
728
+ invoke: (fs) => fs.truncate("/repo/nope.txt", 0),
729
+ node: systemErrorTruth({ code: "ENOENT", syscall: "open", path: "/repo/nope.txt" })
730
+ },
731
+ {
732
+ title: "truncate on a directory rejects with EISDIR",
733
+ setup: (volume) => volume.mkdirSync("/repo/d"),
734
+ invoke: (fs) => fs.truncate("/repo/d", 0),
735
+ node: systemErrorTruth({ code: "EISDIR", syscall: "open", path: "/repo/d" })
736
+ }
737
+ ];
738
+ // Staged as a→b→a. node walks the cycle until it gives up and blames the
739
+ // operation's own syscall; the errno is darwin's, where ELOOP is -62 and Linux
740
+ // uses -40 — it is asserted only through the recorded replay, which is
741
+ // platform-independent because the stub raises the recorded number itself.
742
+ export const stageLoop = (volume) => {
743
+ volume.symlinkSync("b", "/repo/a");
744
+ volume.symlinkSync("a", "/repo/b");
745
+ };
746
+ export const LOOP_HANGS = "memfs recurses through the cycle instead of answering ELOOP";
747
+ // fs-symlink-edges' cases: where a filesystem facade usually stops matching node.
748
+ export const SYMLINK_CASES = [
749
+ {
750
+ // The link resolves to nothing, so the path stat was given is the one it
751
+ // blames rather than the missing target the link named.
752
+ title: "stat on a dangling symlink rejects with ENOENT",
753
+ setup: (volume) => volume.symlinkSync("missing.txt", "/repo/dangling"),
754
+ invoke: (fs) => fs.stat("/repo/dangling"),
755
+ node: systemErrorTruth({ code: "ENOENT", syscall: "stat", path: "/repo/dangling" })
756
+ },
757
+ {
758
+ title: "readlink on a regular file rejects with EINVAL",
759
+ setup: (volume) => volume.writeFileSync("/repo/file.txt", "contents"),
760
+ invoke: (fs) => fs.readlink("/repo/file.txt"),
761
+ node: systemErrorTruth({ code: "EINVAL", syscall: "readlink", path: "/repo/file.txt" })
762
+ },
763
+ {
764
+ // A directory is not a link either, and node separates the two: a missing path
765
+ // is ENOENT while a path that exists and is not a link is EINVAL.
766
+ title: "readlink on a directory rejects with EINVAL",
767
+ setup: (volume) => volume.mkdirSync("/repo/dir"),
768
+ invoke: (fs) => fs.readlink("/repo/dir"),
769
+ node: systemErrorTruth({ code: "EINVAL", syscall: "readlink", path: "/repo/dir" })
770
+ },
771
+ {
772
+ title: "readlink on a missing path rejects with ENOENT",
773
+ invoke: (fs) => fs.readlink("/repo/missing"),
774
+ node: systemErrorTruth({ code: "ENOENT", syscall: "readlink", path: "/repo/missing" })
775
+ },
776
+ {
777
+ // realpath has to resolve the link to answer, so a dangling one is a missing
778
+ // file rather than the link's own path echoed back.
779
+ title: "realpath on a dangling symlink rejects with ENOENT",
780
+ setup: (volume) => volume.symlinkSync("missing.txt", "/repo/dangling"),
781
+ invoke: (fs) => fs.realpath("/repo/dangling"),
782
+ node: systemErrorTruth({ code: "ENOENT", syscall: "realpath", path: "/repo/dangling" })
783
+ },
784
+ {
785
+ // node blames the target as 'path' and the link as 'dest' here, which is the
786
+ // reverse of how the arguments read: symlink(target, path). Recorded rather
787
+ // than reasoned, because either way round looks plausible. memfs reports the
788
+ // same message, code, and path and only omits the dest, so the divergence is
789
+ // in a field no comparison over memfs can see rather than in the answer.
790
+ title: "symlink onto an existing path rejects with EEXIST",
791
+ setup: (volume) => volume.writeFileSync("/repo/taken.txt", "contents"),
792
+ invoke: (fs) => fs.symlink("file.txt", "/repo/taken.txt"),
793
+ node: systemErrorTruth({
794
+ code: "EEXIST",
795
+ syscall: "symlink",
796
+ path: "file.txt",
797
+ dest: "/repo/taken.txt"
798
+ })
799
+ },
800
+ {
801
+ // node opens the directory the link resolves to and fails on the read, so the
802
+ // error names no path at all: it is the descriptor that could not be read.
803
+ title: "readFile through a symlink to a directory rejects with EISDIR",
804
+ setup: (volume) => {
805
+ volume.mkdirSync("/repo/dir");
806
+ volume.symlinkSync("dir", "/repo/dirlink");
807
+ },
808
+ invoke: (fs) => fs.readFile("/repo/dirlink", "utf8"),
809
+ node: systemErrorTruth({ code: "EISDIR", syscall: "read" }),
810
+ gap: {
811
+ reason: "memfs blames open with the directory the link resolved to where node blames a pathless read",
812
+ memfs: {
813
+ name: "Error",
814
+ message: "EISDIR: illegal operation on a directory, open '/repo/dir'",
815
+ code: "EISDIR",
816
+ path: "/repo/dir"
817
+ }
818
+ }
819
+ },
820
+ {
821
+ title: "readFile through a symlink loop rejects with ELOOP",
822
+ setup: stageLoop,
823
+ invoke: (fs) => fs.readFile("/repo/a", "utf8"),
824
+ node: systemErrorTruth({ code: "ELOOP", syscall: "open", path: "/repo/a" }),
825
+ gap: { reason: LOOP_HANGS, memfs: HANGS }
826
+ },
827
+ {
828
+ title: "stat through a symlink loop rejects with ELOOP",
829
+ setup: stageLoop,
830
+ invoke: (fs) => fs.stat("/repo/a"),
831
+ node: systemErrorTruth({ code: "ELOOP", syscall: "stat", path: "/repo/a" }),
832
+ gap: { reason: LOOP_HANGS, memfs: HANGS }
833
+ },
834
+ {
835
+ title: "realpath through a symlink loop rejects with ELOOP",
836
+ setup: stageLoop,
837
+ invoke: (fs) => fs.realpath("/repo/a"),
838
+ node: systemErrorTruth({ code: "ELOOP", syscall: "realpath", path: "/repo/a" }),
839
+ gap: { reason: LOOP_HANGS, memfs: HANGS }
840
+ }
841
+ ];
842
+ // node spells the syscall rather than the fs function in a copyfile message, which is
843
+ // the whole of what memfs gets wrong on the two EXCL cases.
844
+ const NAMES_THE_FUNCTION = "memfs names the copyFile function where node names the copyfile syscall";
845
+ const stageFile = (volume) => {
846
+ volume.writeFileSync("/repo/src", "source");
847
+ volume.writeFileSync("/repo/dest", "dest");
848
+ };
849
+ // fs-copy-rename-edges' cases.
850
+ export const COPY_RENAME_CASES = [
851
+ {
852
+ title: "copyFile with COPYFILE_EXCL onto an existing destination rejects with EEXIST",
853
+ setup: stageFile,
854
+ invoke: (fs) => fs.copyFile("/repo/src", "/repo/dest", nodeFsConstants.COPYFILE_EXCL),
855
+ node: systemErrorTruth({
856
+ code: "EEXIST",
857
+ syscall: "copyfile",
858
+ path: "/repo/src",
859
+ dest: "/repo/dest"
860
+ }),
861
+ gap: {
862
+ reason: NAMES_THE_FUNCTION,
863
+ memfs: {
864
+ name: "Error",
865
+ message: "EEXIST: file already exists, copyFile '/repo/src' -> '/repo/dest'",
866
+ code: "EEXIST",
867
+ path: "/repo/src"
868
+ }
869
+ }
870
+ },
871
+ {
872
+ // copyFile is not asked whether the two paths name the same file, so a copy onto
873
+ // itself is a copy: it opens the destination, truncates nothing it has not already
874
+ // read, and resolves.
875
+ title: "copyFile onto itself resolves",
876
+ setup: (volume) => volume.writeFileSync("/repo/src", "source"),
877
+ invoke: (fs) => fs.copyFile("/repo/src", "/repo/src"),
878
+ node: { result: undefined }
879
+ },
880
+ {
881
+ // EXCL is checked against the destination existing rather than against it being a
882
+ // different file, so a path is refused as its own destination.
883
+ title: "copyFile onto itself with COPYFILE_EXCL rejects with EEXIST",
884
+ setup: (volume) => volume.writeFileSync("/repo/src", "source"),
885
+ invoke: (fs) => fs.copyFile("/repo/src", "/repo/src", nodeFsConstants.COPYFILE_EXCL),
886
+ node: systemErrorTruth({
887
+ code: "EEXIST",
888
+ syscall: "copyfile",
889
+ path: "/repo/src",
890
+ dest: "/repo/src"
891
+ }),
892
+ gap: {
893
+ reason: NAMES_THE_FUNCTION,
894
+ memfs: {
895
+ name: "Error",
896
+ message: "EEXIST: file already exists, copyFile '/repo/src' -> '/repo/src'",
897
+ code: "EEXIST",
898
+ path: "/repo/src"
899
+ }
900
+ }
901
+ },
902
+ {
903
+ // The one case in the table whose code is the platform's choice rather than node's, so it is
904
+ // the one a derivation cannot reach: darwin's copyfile refuses a directory source with
905
+ // ENOTSUP, whose libuv message names a socket whatever the path really is, where linux
906
+ // answers EISDIR. Declared as darwin's because darwin is what the committed fixture was
907
+ // recorded on, and the title says whose answer it is rather than naming a code every
908
+ // platform would then be read as giving. A linux recording is what settles linux's: this
909
+ // case's node truth wants a per-platform code the moment there is a second platform to
910
+ // record, which the fixture drive would report by failing this case rather than skipping it.
911
+ title: "copyFile where the source is a directory rejects with darwin's own code",
912
+ setup: (volume) => volume.mkdirSync("/repo/d"),
913
+ invoke: (fs) => fs.copyFile("/repo/d", "/repo/dest"),
914
+ node: systemErrorTruth({
915
+ code: "ENOTSUP",
916
+ syscall: "copyfile",
917
+ path: "/repo/d",
918
+ dest: "/repo/dest"
919
+ }),
920
+ gap: {
921
+ reason: "memfs opens the source and refuses it as EISDIR where darwin's copyfile answers ENOTSUP",
922
+ memfs: {
923
+ name: "Error",
924
+ message: "EISDIR: illegal operation on a directory, open '/repo/d'",
925
+ code: "EISDIR",
926
+ path: "/repo/d"
927
+ }
928
+ }
929
+ },
930
+ {
931
+ title: "copyFile where the destination is a directory rejects with EISDIR",
932
+ setup: (volume) => {
933
+ volume.writeFileSync("/repo/src", "source");
934
+ volume.mkdirSync("/repo/d");
935
+ },
936
+ invoke: (fs) => fs.copyFile("/repo/src", "/repo/d"),
937
+ node: systemErrorTruth({
938
+ code: "EISDIR",
939
+ syscall: "copyfile",
940
+ path: "/repo/src",
941
+ dest: "/repo/d"
942
+ }),
943
+ gap: {
944
+ reason: "memfs blames the destination it opened where node blames the source and reports the destination as dest",
945
+ memfs: {
946
+ name: "Error",
947
+ message: "EISDIR: illegal operation on a directory, open '/repo/d'",
948
+ code: "EISDIR",
949
+ path: "/repo/d"
950
+ }
951
+ }
952
+ },
953
+ {
954
+ title: "copyFile onto an existing destination resolves",
955
+ setup: stageFile,
956
+ invoke: (fs) => fs.copyFile("/repo/src", "/repo/dest"),
957
+ node: { result: undefined }
958
+ },
959
+ {
960
+ title: "cp non-recursive on a directory rejects with ERR_FS_EISDIR",
961
+ setup: (volume) => volume.mkdirSync("/repo/d"),
962
+ invoke: (fs) => fs.cp("/repo/d", "/repo/copy"),
963
+ node: {
964
+ name: "SystemError",
965
+ message: "Path is a directory: cp returned EISDIR (/repo/d is a directory (not copied)) /repo/d",
966
+ code: "ERR_FS_EISDIR",
967
+ errno: 21,
968
+ syscall: "cp",
969
+ path: "/repo/d"
970
+ },
971
+ gap: {
972
+ reason: "memfs raises a plain EISDIR where node raises its own ERR_FS_EISDIR",
973
+ memfs: {
974
+ name: "Error",
975
+ message: "EISDIR: illegal operation on a directory, cp '/repo/d'",
976
+ code: "EISDIR",
977
+ path: "/repo/d"
978
+ }
979
+ }
980
+ },
981
+ {
982
+ title: "cp recursive on a directory resolves",
983
+ setup: (volume) => volume.mkdirSync("/repo/d/e", { recursive: true }),
984
+ invoke: (fs) => fs.cp("/repo/d", "/repo/copy", { recursive: true }),
985
+ node: { result: undefined }
986
+ },
987
+ {
988
+ // errorOnExist is only read when force is off: force overwrites, so the two
989
+ // together would be a contradiction node resolves in force's favour.
990
+ title: "cp with errorOnExist and force off onto an existing file rejects with ERR_FS_CP_EEXIST",
991
+ setup: stageFile,
992
+ invoke: (fs) => fs.cp("/repo/src", "/repo/dest", { errorOnExist: true, force: false }),
993
+ node: {
994
+ name: "SystemError",
995
+ message: "Target already exists: cp returned EEXIST (/repo/dest already exists) /repo/dest",
996
+ code: "ERR_FS_CP_EEXIST",
997
+ errno: 17,
998
+ syscall: "cp",
999
+ path: "/repo/dest"
1000
+ },
1001
+ gap: {
1002
+ reason: "memfs raises a plain EEXIST where node raises its own ERR_FS_CP_EEXIST",
1003
+ memfs: {
1004
+ name: "Error",
1005
+ message: "EEXIST: file already exists, cp '/repo/dest'",
1006
+ code: "EEXIST",
1007
+ path: "/repo/dest"
1008
+ }
1009
+ }
1010
+ },
1011
+ {
1012
+ // node blames the destination rather than the source: the fault is where the copy
1013
+ // was asked to land, not what it was asked to copy.
1014
+ title: "cp of a directory into itself rejects with ERR_FS_CP_EINVAL",
1015
+ setup: (volume) => volume.mkdirSync("/repo/d"),
1016
+ invoke: (fs) => fs.cp("/repo/d", "/repo/d/sub", { recursive: true }),
1017
+ node: {
1018
+ name: "SystemError",
1019
+ message: "Invalid src or dest: cp returned EINVAL (cannot copy /repo/d to a subdirectory of self /repo/d/sub) /repo/d/sub",
1020
+ code: "ERR_FS_CP_EINVAL",
1021
+ errno: 22,
1022
+ syscall: "cp",
1023
+ path: "/repo/d/sub"
1024
+ },
1025
+ gap: {
1026
+ reason: "memfs raises a plain EINVAL blaming the source where node raises ERR_FS_CP_EINVAL blaming the destination",
1027
+ memfs: {
1028
+ name: "Error",
1029
+ message: "EINVAL: invalid argument, cp '/repo/d' -> '/repo/d/sub'",
1030
+ code: "EINVAL",
1031
+ path: "/repo/d"
1032
+ }
1033
+ }
1034
+ },
1035
+ {
1036
+ title: "rename onto itself resolves as a no-op",
1037
+ setup: (volume) => volume.writeFileSync("/repo/src", "source"),
1038
+ invoke: (fs) => fs.rename("/repo/src", "/repo/src"),
1039
+ node: { result: undefined }
1040
+ },
1041
+ {
1042
+ title: "rename with a missing source rejects with ENOENT",
1043
+ invoke: (fs) => fs.rename("/repo/nope", "/repo/dest"),
1044
+ node: systemErrorTruth({
1045
+ code: "ENOENT",
1046
+ syscall: "rename",
1047
+ path: "/repo/nope",
1048
+ dest: "/repo/dest"
1049
+ })
1050
+ },
1051
+ {
1052
+ title: "rename of a file onto an existing directory rejects with EISDIR",
1053
+ setup: (volume) => {
1054
+ volume.writeFileSync("/repo/src", "source");
1055
+ volume.mkdirSync("/repo/d");
1056
+ },
1057
+ invoke: (fs) => fs.rename("/repo/src", "/repo/d"),
1058
+ node: systemErrorTruth({
1059
+ code: "EISDIR",
1060
+ syscall: "rename",
1061
+ path: "/repo/src",
1062
+ dest: "/repo/d"
1063
+ }),
1064
+ gap: {
1065
+ reason: "memfs replaces the directory with the file instead of refusing it as EISDIR",
1066
+ memfs: { result: undefined }
1067
+ }
1068
+ },
1069
+ {
1070
+ title: "rename of a directory onto an existing file rejects with ENOTDIR",
1071
+ setup: (volume) => {
1072
+ volume.mkdirSync("/repo/d");
1073
+ volume.writeFileSync("/repo/f", "file");
1074
+ },
1075
+ invoke: (fs) => fs.rename("/repo/d", "/repo/f"),
1076
+ node: systemErrorTruth({
1077
+ code: "ENOTDIR",
1078
+ syscall: "rename",
1079
+ path: "/repo/d",
1080
+ dest: "/repo/f"
1081
+ }),
1082
+ gap: {
1083
+ reason: "memfs replaces the file with the directory instead of refusing it as ENOTDIR",
1084
+ memfs: { result: undefined }
1085
+ }
1086
+ },
1087
+ {
1088
+ // node replaces an empty destination directory and refuses a non-empty one, so
1089
+ // ENOTEMPTY is the whole of what stops rename destroying a tree.
1090
+ title: "rename of a directory onto a non-empty directory rejects with ENOTEMPTY",
1091
+ setup: (volume) => {
1092
+ volume.mkdirSync("/repo/d");
1093
+ volume.mkdirSync("/repo/t");
1094
+ volume.writeFileSync("/repo/t/x", "x");
1095
+ },
1096
+ invoke: (fs) => fs.rename("/repo/d", "/repo/t"),
1097
+ node: systemErrorTruth({
1098
+ code: "ENOTEMPTY",
1099
+ syscall: "rename",
1100
+ path: "/repo/d",
1101
+ dest: "/repo/t"
1102
+ }),
1103
+ gap: {
1104
+ reason: "memfs overwrites the non-empty directory instead of refusing it as ENOTEMPTY",
1105
+ memfs: { result: undefined }
1106
+ }
1107
+ },
1108
+ {
1109
+ title: "rename onto an existing file resolves",
1110
+ setup: stageFile,
1111
+ invoke: (fs) => fs.rename("/repo/src", "/repo/dest"),
1112
+ node: { result: undefined }
1113
+ },
1114
+ {
1115
+ title: "link where the destination exists rejects with EEXIST",
1116
+ setup: stageFile,
1117
+ invoke: (fs) => fs.link("/repo/src", "/repo/dest"),
1118
+ node: systemErrorTruth({
1119
+ code: "EEXIST",
1120
+ syscall: "link",
1121
+ path: "/repo/src",
1122
+ dest: "/repo/dest"
1123
+ })
1124
+ },
1125
+ {
1126
+ // A hard link to a directory would let a script build a cycle the kernel cannot
1127
+ // unwind, so darwin refuses it outright rather than reporting it as a type error.
1128
+ title: "link where the source is a directory rejects with EPERM",
1129
+ setup: (volume) => volume.mkdirSync("/repo/d"),
1130
+ invoke: (fs) => fs.link("/repo/d", "/repo/l"),
1131
+ node: systemErrorTruth({ code: "EPERM", syscall: "link", path: "/repo/d", dest: "/repo/l" }),
1132
+ gap: {
1133
+ reason: "memfs hard-links the directory instead of refusing it as EPERM",
1134
+ memfs: { result: undefined }
1135
+ }
1136
+ },
1137
+ {
1138
+ title: "link where the destination is free resolves",
1139
+ setup: (volume) => volume.writeFileSync("/repo/src", "one"),
1140
+ invoke: (fs) => fs.link("/repo/src", "/repo/hard"),
1141
+ node: { result: undefined }
1142
+ }
1143
+ ];
1144
+ // A Stats object and a Dirent are the two results the module deliberately reshapes: the *Ms
1145
+ // numbers stand in for node's Date fields, and both cross the bridge as plain objects rather
1146
+ // than as class instances. A case that wants one therefore reads the answers off it rather than
1147
+ // returning it, which is also what makes it comparable against a reference that hands back
1148
+ // node's own class.
1149
+ const readTypes = (source) => ({
1150
+ isFile: source.isFile(),
1151
+ isDirectory: source.isDirectory(),
1152
+ isSymbolicLink: source.isSymbolicLink()
1153
+ });
1154
+ // readdir order is the filesystem's to choose — node does not sort, and neither does the
1155
+ // module — so every readdir case sorts what it was given. An unsorted case would be asserting
1156
+ // the order memfs happens to answer in, which is the one thing about readdir node does not
1157
+ // promise.
1158
+ const byName = (entries) => [...entries].sort();
1159
+ // fs-node-conformance-suite's own cases: the reads, encodings, and shapes the edge-case tasks
1160
+ // had no reason to cover, since each answers rather than refuses and their subject was what an
1161
+ // operation refuses.
1162
+ export const READ_AND_SHAPE_CASES = [
1163
+ {
1164
+ title: "writeFile into a missing directory rejects with ENOENT",
1165
+ invoke: (fs) => fs.writeFile("/repo/missing/f.txt", "x"),
1166
+ node: systemErrorTruth({ code: "ENOENT", syscall: "open", path: "/repo/missing/f.txt" }),
1167
+ gap: {
1168
+ reason: "memfs blames the missing parent rather than the path it was given, and names no syscall in the message at all",
1169
+ memfs: {
1170
+ name: "Error",
1171
+ message: "ENOENT: no such file or directory, '/repo/missing'",
1172
+ code: "ENOENT",
1173
+ path: "/repo/missing"
1174
+ }
1175
+ }
1176
+ },
1177
+ {
1178
+ title: "readdir on a file rejects with ENOTDIR",
1179
+ setup: (volume) => volume.writeFileSync("/repo/f.txt", "x"),
1180
+ invoke: (fs) => fs.readdir("/repo/f.txt"),
1181
+ node: systemErrorTruth({ code: "ENOTDIR", syscall: "scandir", path: "/repo/f.txt" })
1182
+ },
1183
+ {
1184
+ title: "readdir names every entry of a directory",
1185
+ setup: stageTree,
1186
+ invoke: async (fs) => byName(await fs.readdir("/repo/tree")),
1187
+ node: { result: ["b.txt", "sub"] }
1188
+ },
1189
+ {
1190
+ title: "readdir withFileTypes reports each entry's name, parent, and type",
1191
+ setup: stageTree,
1192
+ readsAnswer: true,
1193
+ invoke: async (fs) => {
1194
+ const entries = await fs.readdir("/repo/tree", { withFileTypes: true });
1195
+ return entries
1196
+ .map((entry) => ({ name: entry.name, parentPath: entry.parentPath, ...readTypes(entry) }))
1197
+ .sort((first, second) => first.name.localeCompare(second.name));
1198
+ },
1199
+ node: {
1200
+ result: [
1201
+ {
1202
+ name: "b.txt",
1203
+ parentPath: "/repo/tree",
1204
+ isFile: true,
1205
+ isDirectory: false,
1206
+ isSymbolicLink: false
1207
+ },
1208
+ {
1209
+ name: "sub",
1210
+ parentPath: "/repo/tree",
1211
+ isFile: false,
1212
+ isDirectory: true,
1213
+ isSymbolicLink: false
1214
+ }
1215
+ ]
1216
+ }
1217
+ },
1218
+ {
1219
+ // node answers a recursive readdir with paths relative to the directory it was
1220
+ // given, the directories included alongside the files they hold.
1221
+ title: "readdir recursive names every nested entry relative to the directory",
1222
+ setup: stageTree,
1223
+ invoke: async (fs) => byName(await fs.readdir("/repo/tree", { recursive: true })),
1224
+ node: { result: ["b.txt", "sub", "sub/c.txt"] }
1225
+ },
1226
+ {
1227
+ title: "stat follows a symlink to a file and reports the file",
1228
+ setup: stageLink,
1229
+ readsAnswer: true,
1230
+ invoke: async (fs) => readTypes(await fs.stat("/repo/link")),
1231
+ node: { result: { isFile: true, isDirectory: false, isSymbolicLink: false } }
1232
+ },
1233
+ {
1234
+ title: "lstat does not follow a symlink to a file and reports the link",
1235
+ setup: stageLink,
1236
+ readsAnswer: true,
1237
+ invoke: async (fs) => readTypes(await fs.lstat("/repo/link")),
1238
+ node: { result: { isFile: false, isDirectory: false, isSymbolicLink: true } }
1239
+ },
1240
+ {
1241
+ // Every mode is refused for the same reason and node reports none of them: an
1242
+ // access error names the path and the syscall alone, so R_OK and W_OK on a missing
1243
+ // path are one error twice rather than two.
1244
+ title: "access R_OK on a missing path rejects with ENOENT",
1245
+ invoke: (fs) => fs.access("/repo/missing.txt", nodeFsConstants.R_OK),
1246
+ node: systemErrorTruth({ code: "ENOENT", syscall: "access", path: "/repo/missing.txt" })
1247
+ },
1248
+ {
1249
+ title: "access W_OK on a missing path rejects with ENOENT",
1250
+ invoke: (fs) => fs.access("/repo/missing.txt", nodeFsConstants.W_OK),
1251
+ node: systemErrorTruth({ code: "ENOENT", syscall: "access", path: "/repo/missing.txt" })
1252
+ },
1253
+ {
1254
+ // Growing a file is the one truncate whose answer says nothing: it resolves like
1255
+ // any other, and only the file it left behind reports that the region it added
1256
+ // reads back as NUL bytes rather than as anything the file held before.
1257
+ title: "truncate beyond the length pads the file with NUL bytes",
1258
+ setup: (volume) => volume.writeFileSync("/repo/f.txt", "abc"),
1259
+ invoke: async (fs) => {
1260
+ await fs.truncate("/repo/f.txt", 5);
1261
+ return fs.readFile("/repo/f.txt", "utf8");
1262
+ },
1263
+ node: { result: "abc\u0000\u0000" }
1264
+ },
1265
+ ...STRING_ENCODINGS.map((encoding) => ({
1266
+ // The data is the string node itself spells SAMPLE_TEXT with in this encoding, so
1267
+ // the round trip is the identity every encoding owes a script: what a write of a
1268
+ // string in an encoding leaves behind is what a read in that encoding answers.
1269
+ title: `writeFile and readFile round-trip a ${encoding} string`,
1270
+ invoke: async (fs) => {
1271
+ await fs.writeFile("/repo/round.txt", encode(SAMPLE_TEXT, encoding), encoding);
1272
+ return fs.readFile("/repo/round.txt", encoding);
1273
+ },
1274
+ node: { result: encode(SAMPLE_TEXT, encoding) }
1275
+ }))
1276
+ ];
1277
+ function stageTree(volume) {
1278
+ volume.mkdirSync("/repo/tree/sub", { recursive: true });
1279
+ volume.writeFileSync("/repo/tree/b.txt", "b");
1280
+ volume.writeFileSync("/repo/tree/sub/c.txt", "c");
1281
+ }
1282
+ function stageLink(volume) {
1283
+ volume.writeFileSync("/repo/target.txt", "contents");
1284
+ volume.symlinkSync("target.txt", "/repo/link");
1285
+ }
1286
+ export const FS_CONFORMANCE_CASES = [
1287
+ ...SYSTEM_ERROR_CASES.map(toConformanceCase),
1288
+ ...MKDIR_RM_RMDIR_CASES,
1289
+ ...WRITE_CASES,
1290
+ ...SYMLINK_CASES,
1291
+ ...COPY_RENAME_CASES,
1292
+ ...READ_AND_SHAPE_CASES
1293
+ ];