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.
- package/dist/cli/commands/harness.js +41 -3
- package/dist/cli/commands/harness.js.map +1 -1
- package/dist/index.js +1050 -420
- package/dist/index.js.map +4 -4
- package/dist/metafile.json +1 -1
- package/package.json +2 -1
- package/packages/safejs/dist/cli.js +24 -1
- package/packages/safejs/dist/index.d.ts +2 -0
- package/packages/safejs/dist/index.js +1 -0
- package/packages/safejs/dist/interp/cancel.js +4 -0
- package/packages/safejs/dist/interp/host-bridge.js +32 -1
- package/packages/safejs/dist/interp/promise.d.ts +1 -0
- package/packages/safejs/dist/interp/promise.js +10 -2
- package/packages/safejs/dist/modules/canonical-path.d.ts +15 -0
- package/packages/safejs/dist/modules/canonical-path.js +161 -0
- package/packages/safejs/dist/modules/fs.conformance-cases.d.ts +99 -0
- package/packages/safejs/dist/modules/fs.conformance-cases.js +1293 -0
- package/packages/safejs/dist/modules/fs.d.ts +72 -0
- package/packages/safejs/dist/modules/fs.js +558 -0
- package/packages/safejs/dist/modules/git.js +13 -33
- package/packages/safejs/dist/run.js +2 -4
|
@@ -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
|
+
];
|