@evolu/nodejs 3.1.0 → 4.0.0

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/src/Fs.test.ts ADDED
@@ -0,0 +1,1019 @@
1
+ import {
2
+ assertEqual,
3
+ assertErr,
4
+ assertOk,
5
+ assertTrue,
6
+ assertType,
7
+ createRun,
8
+ testAbortError,
9
+ testAbortReason,
10
+ testCreateRun,
11
+ type FsError,
12
+ type Task,
13
+ } from "@evolu/common";
14
+ import { constants, promises as nodeFs } from "node:fs";
15
+ import {
16
+ mkdir,
17
+ mkdtemp,
18
+ readFile,
19
+ readlink,
20
+ realpath,
21
+ rm,
22
+ stat,
23
+ symlink,
24
+ utimes,
25
+ writeFile,
26
+ } from "node:fs/promises";
27
+ import { syncBuiltinESMExports } from "node:module";
28
+ import { tmpdir } from "node:os";
29
+ import { basename, dirname, join, resolve, sep } from "node:path";
30
+ import { pathToFileURL } from "node:url";
31
+ import { after, before, describe, it } from "node:test";
32
+ import { createNodeFs } from "./Fs.ts";
33
+
34
+ let directory = "";
35
+
36
+ before(async () => {
37
+ directory = await mkdtemp(join(tmpdir(), "evolu-fs-"));
38
+ });
39
+
40
+ after(async () => {
41
+ await rm(directory, { recursive: true, force: true });
42
+ });
43
+
44
+ const setupFile = async (name: string, content: string): Promise<string> => {
45
+ const path = join(directory, name);
46
+ await writeFile(path, content);
47
+ return path;
48
+ };
49
+
50
+ describe("readFile", () => {
51
+ it("reads bytes by default and a string with an encoding", async () => {
52
+ const fs = createNodeFs();
53
+ await using run = testCreateRun();
54
+ const path = await setupFile("read.txt", "héllo");
55
+
56
+ const bytes = await run(fs.readFile(path));
57
+ assertOk(bytes, new TextEncoder().encode("héllo"));
58
+ assertOk(await run(fs.readFile(path, "utf8")), "héllo");
59
+ assertOk(await run(fs.readFile(path, { encoding: "latin1" })), "héllo");
60
+ assertOk(await run(fs.readFile(pathToFileURL(path), "utf8")), "héllo");
61
+
62
+ assertOk(await run(fs.readFile(path, { encoding: "hex" })), "68c3a96c6c6f");
63
+ });
64
+
65
+ it("maps NotFound, IsDirectory, and unknown codes", async () => {
66
+ const fs = createNodeFs();
67
+ await using run = testCreateRun();
68
+ const missing = join(directory, "missing.txt");
69
+
70
+ const notFound = await run(fs.readFile(missing));
71
+ assertErr(notFound);
72
+ assertEqual(notFound.error.reason, "NotFound");
73
+ assertEqual(notFound.error.path, missing);
74
+ assertEqual(notFound.error.syscall, "open");
75
+ assertTrue(notFound.error.message.includes("ENOENT"));
76
+
77
+ const isDirectory = await run(fs.readFile(directory, "utf8"));
78
+ assertErr(isDirectory);
79
+ assertEqual(isDirectory.error.reason, "IsDirectory");
80
+
81
+ const unknown = await run(fs.readFile("bad\0path"));
82
+ assertErr(unknown);
83
+ assertEqual(unknown.error.reason, "Unknown");
84
+ assertEqual(unknown.error.syscall, "readFile");
85
+ assertEqual(unknown.error.path, "bad\0path");
86
+
87
+ const missingUrl = pathToFileURL(missing);
88
+ const url = await run(fs.readFile(missingUrl));
89
+ assertErr(url);
90
+ assertEqual(url.error.path, missingUrl.href);
91
+ });
92
+
93
+ for (const href of ["https://example.com/file", "file:///tmp/a%2Fb"]) {
94
+ it(`returns FsError for ${href}`, async () => {
95
+ const fs = createNodeFs();
96
+ await using run = testCreateRun();
97
+ const url = new URL(href);
98
+
99
+ const result = await run(fs.readFile(url));
100
+
101
+ assertErr(result);
102
+ assertEqual(result.error.type, "FsError");
103
+ assertEqual(result.error.reason, "Unknown");
104
+ assertEqual(result.error.path, url.href);
105
+ assertEqual(result.error.syscall, "readFile");
106
+ assertTrue(result.error.message.length > 0);
107
+ });
108
+ }
109
+
110
+ it("aborts with the Run", async () => {
111
+ const fs = createNodeFs();
112
+ await using run = testCreateRun();
113
+ const path = await setupFile("abort.txt", "content");
114
+ const fiber = run.abortable(fs.readFile(path));
115
+ fiber.abort(testAbortReason);
116
+
117
+ assertErr(await fiber, testAbortError);
118
+ });
119
+
120
+ it("returns a completed read after an abort request", async (t) => {
121
+ const nodeReadFile = t.mock.method(nodeFs, "readFile", () =>
122
+ Promise.resolve("content"),
123
+ );
124
+ syncBuiltinESMExports();
125
+ t.after(() => {
126
+ nodeReadFile.mock.restore();
127
+ syncBuiltinESMExports();
128
+ });
129
+ const fs = createNodeFs();
130
+ await using run = testCreateRun();
131
+
132
+ const fiber = run.abortable(fs.readFile("completed.txt", "utf8"));
133
+ fiber.abort(testAbortReason);
134
+
135
+ assertOk(await fiber, "content");
136
+ });
137
+ });
138
+
139
+ describe("writeFile", () => {
140
+ it("writes strings and bytes with Node options", async () => {
141
+ const fs = createNodeFs();
142
+ await using run = testCreateRun();
143
+ const path = join(directory, "write.txt");
144
+
145
+ assertOk(await run(fs.writeFile(path, "first")));
146
+ assertOk(await run(fs.writeFile(path, " second", { flag: "a" })));
147
+ assertEqual(await readFile(path, "utf8"), "first second");
148
+
149
+ assertOk(await run(fs.writeFile(path, new TextEncoder().encode("bytes"))));
150
+ assertEqual(await readFile(path, "utf8"), "bytes");
151
+ assertOk(await run(fs.writeFile(path, "aGV4", { encoding: "base64" })));
152
+ assertEqual(await readFile(path, "utf8"), "hex");
153
+
154
+ const exists = await run(fs.writeFile(path, "x", { flag: "wx" }));
155
+ assertErr(exists);
156
+ assertEqual(exists.error.reason, "AlreadyExists");
157
+ });
158
+
159
+ it("aborts with the Run", async () => {
160
+ const fs = createNodeFs();
161
+ await using run = testCreateRun();
162
+ const fiber = run.abortable(fs.writeFile(join(directory, "a.txt"), "x"));
163
+ fiber.abort(testAbortReason);
164
+
165
+ assertErr(await fiber, testAbortError);
166
+ });
167
+
168
+ it("returns a completed write after an abort request", async (t) => {
169
+ const nodeWriteFile = t.mock.method(nodeFs, "writeFile", () =>
170
+ Promise.resolve(),
171
+ );
172
+ syncBuiltinESMExports();
173
+ t.after(() => {
174
+ nodeWriteFile.mock.restore();
175
+ syncBuiltinESMExports();
176
+ });
177
+ const fs = createNodeFs();
178
+ await using run = testCreateRun();
179
+
180
+ const fiber = run.abortable(fs.writeFile("completed.txt", "content"));
181
+ fiber.abort(testAbortReason);
182
+
183
+ assertOk(await fiber);
184
+ });
185
+ });
186
+
187
+ describe("createDirectory and remove", () => {
188
+ it("creates and removes directories with Node semantics", async () => {
189
+ const fs = createNodeFs();
190
+ await using run = testCreateRun();
191
+ const nested = join(directory, "a", "b");
192
+
193
+ const missingParent = await run(fs.createDirectory(nested));
194
+ assertErr(missingParent);
195
+ assertEqual(missingParent.error.reason, "NotFound");
196
+ assertOk(await run(fs.createDirectory(nested, { recursive: true })));
197
+ assertOk(await run(fs.createDirectory(nested, { recursive: true })));
198
+
199
+ const already = await run(fs.createDirectory(nested));
200
+ assertErr(already);
201
+ assertEqual(already.error.reason, "AlreadyExists");
202
+
203
+ const notDirectory = await run(
204
+ fs.getMetadata(join(await setupFile("file.txt", ""), "child")),
205
+ );
206
+ assertErr(notDirectory);
207
+ assertEqual(notDirectory.error.reason, "NotDirectory");
208
+
209
+ await writeFile(join(nested, "file.txt"), "x");
210
+ const isDirectory = await run(
211
+ fs.remove(join(directory, "a"), { recursive: false }),
212
+ );
213
+ assertErr(isDirectory);
214
+ assertEqual(isDirectory.error.reason, "IsDirectory");
215
+
216
+ assertOk(await run(fs.remove(join(directory, "a"), { recursive: true })));
217
+ assertOk(await run(fs.exists(nested)), false);
218
+
219
+ const missing = await run(fs.remove(nested));
220
+ assertErr(missing);
221
+ assertEqual(missing.error.reason, "NotFound");
222
+ assertOk(await run(fs.remove(nested, { force: true })));
223
+ });
224
+
225
+ it("returns completed operations after an abort request", async () => {
226
+ const fs = createNodeFs();
227
+ await using run = testCreateRun();
228
+ const fiber = run.abortable(fs.createDirectory(join(directory, "aborted")));
229
+ fiber.abort(testAbortReason);
230
+
231
+ assertOk(await fiber);
232
+ assertOk(await run(fs.exists(join(directory, "aborted"))), true);
233
+ const removeFiber = run.abortable(
234
+ fs.remove(join(directory, "aborted"), { recursive: true }),
235
+ );
236
+ removeFiber.abort(testAbortReason);
237
+ assertOk(await removeFiber);
238
+ assertOk(await run(fs.exists(join(directory, "aborted"))), false);
239
+ });
240
+ });
241
+
242
+ describe("getMetadata and exists", () => {
243
+ it("returns file and directory metadata as data", async () => {
244
+ const fs = createNodeFs();
245
+ await using run = testCreateRun();
246
+ const path = await setupFile("metadata.txt", "12345");
247
+
248
+ const metadata = await run(fs.getMetadata(path));
249
+ assertOk(metadata);
250
+ assertEqual(metadata.value.size, 5);
251
+ assertEqual(metadata.value.type, "File");
252
+ assertEqual(structuredClone(metadata.value), metadata.value);
253
+ assertTrue(metadata.value.mtimeMs > 0);
254
+ assertTrue(metadata.value.mtime instanceof Date);
255
+
256
+ const directoryMetadata = await run(fs.getMetadata(directory));
257
+ assertOk(directoryMetadata);
258
+ assertEqual(directoryMetadata.value.type, "Directory");
259
+
260
+ const missing = await run(fs.getMetadata(join(directory, "nope")));
261
+ assertErr(missing);
262
+ assertEqual(missing.error.reason, "NotFound");
263
+ assertEqual(missing.error.syscall, "stat");
264
+
265
+ assertOk(await run(fs.exists(path)), true);
266
+ assertOk(await run(fs.exists(join(directory, "nope"))), false);
267
+ });
268
+
269
+ it("follows symbolic links when reading metadata", async () => {
270
+ const fs = createNodeFs();
271
+ await using run = testCreateRun();
272
+ const target = await setupFile("metadata-target.txt", "hello");
273
+ const link = join(directory, "metadata-link.txt");
274
+ await symlink(target, link);
275
+
276
+ const metadata = await run(fs.getMetadata(pathToFileURL(link)));
277
+ assertOk(metadata);
278
+ assertEqual(metadata.value.type, "File");
279
+ assertEqual(metadata.value.size, 5);
280
+ });
281
+
282
+ it("classifies every entry kind from native metadata", async (t) => {
283
+ const nativeMetadata = await stat(directory);
284
+ const nodeStat = t.mock.method(nodeFs, "stat", () =>
285
+ Promise.resolve(nativeMetadata),
286
+ );
287
+ syncBuiltinESMExports();
288
+ t.after(() => {
289
+ nodeStat.mock.restore();
290
+ syncBuiltinESMExports();
291
+ });
292
+ const fs = createNodeFs();
293
+ await using run = testCreateRun();
294
+
295
+ for (const [fileMode, type] of [
296
+ [constants.S_IFREG, "File"],
297
+ [constants.S_IFDIR, "Directory"],
298
+ [constants.S_IFLNK, "SymbolicLink"],
299
+ [constants.S_IFBLK, "BlockDevice"],
300
+ [constants.S_IFCHR, "CharacterDevice"],
301
+ [constants.S_IFIFO, "FIFO"],
302
+ [constants.S_IFSOCK, "Socket"],
303
+ [0, "Unknown"],
304
+ ] as const) {
305
+ nativeMetadata.mode = fileMode | 0o640;
306
+ const metadata = await run(fs.getMetadata("entry"));
307
+ assertOk(metadata);
308
+ assertEqual(metadata.value.type, type);
309
+ }
310
+ });
311
+
312
+ it("exists preserves errors other than NotFound", async () => {
313
+ const fs = createNodeFs();
314
+ await using run = testCreateRun();
315
+ const file = await setupFile("exists-parent.txt", "");
316
+ const path = join(file, "child");
317
+ const task = fs.exists(path);
318
+ assertType<typeof task, Task<boolean, FsError>>();
319
+
320
+ const result = await run(task);
321
+ assertErr(result);
322
+ assertEqual(result.error.reason, "NotDirectory");
323
+ assertEqual(result.error.path, path);
324
+
325
+ const invalid = await run(fs.exists("bad\0path"));
326
+ assertErr(invalid);
327
+ assertEqual(invalid.error.reason, "Unknown");
328
+ });
329
+
330
+ it("exists preserves permission errors", async (t) => {
331
+ const error = Object.assign(new Error("Permission denied"), {
332
+ code: "EACCES",
333
+ syscall: "access",
334
+ });
335
+ const nodeAccess = t.mock.method(nodeFs, "access", () =>
336
+ Promise.reject(error),
337
+ );
338
+ syncBuiltinESMExports();
339
+ t.after(() => {
340
+ nodeAccess.mock.restore();
341
+ syncBuiltinESMExports();
342
+ });
343
+ const fs = createNodeFs();
344
+ await using run = testCreateRun();
345
+
346
+ const result = await run(fs.exists("protected"));
347
+ assertErr(result);
348
+ assertEqual(result.error.reason, "PermissionDenied");
349
+ });
350
+
351
+ it("returns metadata and existence after an abort request", async () => {
352
+ const fs = createNodeFs();
353
+ await using run = testCreateRun();
354
+ const metadataFiber = run.abortable(fs.getMetadata(directory));
355
+ metadataFiber.abort(testAbortReason);
356
+ const metadata = await metadataFiber;
357
+ assertOk(metadata);
358
+ assertEqual(metadata.value.type, "Directory");
359
+
360
+ const existsFiber = run.abortable(fs.exists(directory));
361
+ existsFiber.abort(testAbortReason);
362
+ assertOk(await existsFiber, true);
363
+
364
+ const missingFiber = run.abortable(
365
+ fs.exists(join(directory, "missing-after-abort")),
366
+ );
367
+ missingFiber.abort(testAbortReason);
368
+ assertOk(await missingFiber, false);
369
+
370
+ const invalidFiber = run.abortable(fs.exists("bad\0path"));
371
+ invalidFiber.abort(testAbortReason);
372
+ const invalid = await invalidFiber;
373
+ assertErr(invalid);
374
+ assertEqual(invalid.error.type, "FsError");
375
+ assertEqual(invalid.error.reason, "Unknown");
376
+ });
377
+ });
378
+
379
+ describe("readDirectory", () => {
380
+ it("lists names relative to the directory, optionally recursively", async () => {
381
+ const fs = createNodeFs();
382
+ await using run = testCreateRun();
383
+ const root = join(directory, "listing");
384
+ await mkdir(join(root, "nested"), { recursive: true });
385
+ await writeFile(join(root, "first.txt"), "first");
386
+ await writeFile(join(root, "nested", "second.txt"), "second");
387
+
388
+ const names = await run(fs.readDirectory(root));
389
+ assertOk(names);
390
+ assertType<typeof names.value, ReadonlyArray<string>>();
391
+ assertEqual(names.value.toSorted(), ["first.txt", "nested"]);
392
+
393
+ const recursive = await run(
394
+ fs.readDirectory(pathToFileURL(root), { recursive: true }),
395
+ );
396
+ assertOk(recursive);
397
+ assertEqual(recursive.value.toSorted(), [
398
+ "first.txt",
399
+ "nested",
400
+ join("nested", "second.txt"),
401
+ ]);
402
+
403
+ const empty = join(root, "empty");
404
+ await mkdir(empty);
405
+ assertOk(await run(fs.readDirectory(empty)), []);
406
+ });
407
+
408
+ it("reports missing paths and paths that are not directories", async () => {
409
+ const fs = createNodeFs();
410
+ await using run = testCreateRun();
411
+ const missing = await run(
412
+ fs.readDirectory(join(directory, "missing-listing")),
413
+ );
414
+ assertErr(missing);
415
+ assertEqual(missing.error.reason, "NotFound");
416
+
417
+ const file = await setupFile("not-a-directory.txt", "");
418
+ const result = await run(fs.readDirectory(file));
419
+ assertErr(result);
420
+ assertEqual(result.error.reason, "NotDirectory");
421
+ });
422
+
423
+ it("returns directory entries after an abort request", async () => {
424
+ const fs = createNodeFs();
425
+ await using run = testCreateRun();
426
+ const root = join(directory, "aborted-listing");
427
+ await mkdir(root);
428
+ await writeFile(join(root, "entry.txt"), "entry");
429
+ const fiber = run.abortable(fs.readDirectory(root));
430
+ fiber.abort(testAbortReason);
431
+ assertOk(await fiber, ["entry.txt"]);
432
+ });
433
+ });
434
+
435
+ describe("copy and copyFile", () => {
436
+ it("copy uses Node's default replacement and explicit conflict options", async () => {
437
+ const fs = createNodeFs();
438
+ await using run = testCreateRun();
439
+ const source = await setupFile("native-copy-source", "new");
440
+ const destination = await setupFile("native-copy-destination", "old");
441
+ const sourceUrl = pathToFileURL(source);
442
+ const destinationUrl = pathToFileURL(destination);
443
+
444
+ assertOk(await run(fs.copy(sourceUrl, destinationUrl, { force: false })));
445
+ assertEqual(await readFile(destination, "utf8"), "old");
446
+ const conflict = await run(
447
+ fs.copy(sourceUrl, destinationUrl, {
448
+ force: false,
449
+ errorOnExist: true,
450
+ }),
451
+ );
452
+ assertErr(conflict);
453
+ assertEqual(conflict.error.reason, "AlreadyExists");
454
+ assertEqual(conflict.error.path, sourceUrl.href);
455
+ assertEqual(conflict.error.destination, destinationUrl.href);
456
+ assertEqual(await readFile(destination, "utf8"), "old");
457
+
458
+ assertOk(await run(fs.copy(sourceUrl, destinationUrl)));
459
+ assertEqual(await readFile(destination, "utf8"), "new");
460
+ });
461
+
462
+ it("copy merges directories by default and supports Node's directory conflict option", async () => {
463
+ const fs = createNodeFs();
464
+ await using run = testCreateRun();
465
+ const root = await mkdtemp(join(directory, "copy-merge-"));
466
+ const source = join(root, "source");
467
+ const destination = join(root, "destination");
468
+ await mkdir(join(source, "nested"), { recursive: true });
469
+ await mkdir(join(destination, "nested"), { recursive: true });
470
+ await writeFile(join(source, "nested", "new.txt"), "new");
471
+ await writeFile(join(destination, "nested", "old.txt"), "old");
472
+ await writeFile(join(destination, "nested", "new.txt"), "replaced");
473
+
474
+ const conflict = await run(
475
+ fs.copy(source, destination, {
476
+ force: false,
477
+ errorOnExist: true,
478
+ }),
479
+ );
480
+ assertErr(conflict);
481
+ assertEqual(conflict.error.reason, "AlreadyExists");
482
+ assertEqual(
483
+ await readFile(join(destination, "nested", "new.txt"), "utf8"),
484
+ "replaced",
485
+ );
486
+
487
+ assertOk(await run(fs.copy(source, destination)));
488
+ assertEqual(
489
+ await readFile(join(destination, "nested", "new.txt"), "utf8"),
490
+ "new",
491
+ );
492
+ assertEqual(
493
+ await readFile(join(destination, "nested", "old.txt"), "utf8"),
494
+ "old",
495
+ );
496
+ });
497
+
498
+ it("copy retains Node's symlink replacement despite conflict options", async () => {
499
+ const fs = createNodeFs();
500
+ await using run = testCreateRun();
501
+ const root = await mkdtemp(join(directory, "copy-native-links-"));
502
+ const target = join(root, "target");
503
+ const source = join(root, "source");
504
+ const destination = join(root, "destination");
505
+ await writeFile(target, "target");
506
+ await symlink("target", source);
507
+ await symlink("missing-old-target", destination);
508
+
509
+ assertOk(
510
+ await run(
511
+ fs.copy(source, destination, { force: false, errorOnExist: true }),
512
+ ),
513
+ );
514
+ assertEqual(await readlink(destination), target);
515
+ assertEqual(await readlink(source), "target");
516
+ assertEqual(await readFile(destination, "utf8"), "target");
517
+ });
518
+
519
+ it("copy preserves Node's errors for replacing entries with symlinks", async () => {
520
+ const fs = createNodeFs();
521
+ await using run = testCreateRun();
522
+ const root = await mkdtemp(join(directory, "copy-native-link-errors-"));
523
+ const source = join(root, "source");
524
+ const destination = join(root, "destination");
525
+ await symlink("missing-target", source);
526
+ await writeFile(destination, "keep");
527
+
528
+ const fileConflict = await run(fs.copy(source, destination));
529
+ assertErr(fileConflict);
530
+ assertEqual(fileConflict.error.reason, "AlreadyExists");
531
+ assertEqual(await readFile(destination, "utf8"), "keep");
532
+
533
+ await rm(destination);
534
+ await symlink("old-target", destination);
535
+ const danglingConflict = await run(fs.copy(source, destination));
536
+ assertErr(danglingConflict);
537
+ assertEqual(danglingConflict.error.reason, "NotFound");
538
+ assertEqual(await readlink(destination), "old-target");
539
+ });
540
+
541
+ it(`copyFile creates files exclusively when copies compete`, async () => {
542
+ const fs = createNodeFs();
543
+ await using run = testCreateRun();
544
+ const root = await mkdtemp(join(directory, "copy-race-"));
545
+ const source = join(root, "source");
546
+ await writeFile(source, "source");
547
+ for (let index = 0; index < 16; index++) {
548
+ const destination = join(root, `destination-${index}`);
549
+ const results = await Promise.all([
550
+ run(fs.copyFile(source, destination)),
551
+ run(fs.copyFile(source, destination)),
552
+ ]);
553
+ assertEqual(results.filter((result) => result.ok).length, 1);
554
+ const failure = results.find((result) => !result.ok);
555
+ assertTrue(failure !== undefined);
556
+ assertErr(failure);
557
+ assertEqual(failure.error.reason, "AlreadyExists");
558
+ assertEqual(await readFile(destination, "utf8"), "source");
559
+ }
560
+ });
561
+
562
+ it("preserves absolute symlink targets containing parent components", async () => {
563
+ const fs = createNodeFs();
564
+ await using run = testCreateRun();
565
+ const root = await mkdtemp(join(directory, "copy-link-absolute-"));
566
+ await mkdir(join(root, "physical", "deep"), { recursive: true });
567
+ await writeFile(join(root, "physical", "target"), "correct");
568
+ await writeFile(join(root, "target"), "wrong");
569
+ await symlink(join(root, "physical", "deep"), join(root, "alias"));
570
+ const target = join(root, "alias") + sep + ".." + sep + "target";
571
+ const source = join(root, "source");
572
+ const destination = join(root, "destination");
573
+ await symlink(target, source);
574
+
575
+ assertOk(await run(fs.copy(source, destination)));
576
+
577
+ assertEqual(await readlink(destination), target);
578
+ assertEqual(await readFile(destination, "utf8"), "correct");
579
+ assertEqual(await readFile(source, "utf8"), "correct");
580
+ });
581
+
582
+ it("preserves an ancestor symlink and access to the source", async () => {
583
+ const fs = createNodeFs();
584
+ await using run = testCreateRun();
585
+ const root = await mkdtemp(join(directory, "copy-link-ancestor-"));
586
+ const real = join(root, "real");
587
+ const target = join(real, "child");
588
+ const alias = join(root, "alias");
589
+ await mkdir(target, { recursive: true });
590
+ await symlink(target, join(real, "inner"));
591
+ await symlink(real, alias);
592
+ const source = join(alias, "inner");
593
+
594
+ const result = await run(fs.copy(source, alias, { force: true }));
595
+
596
+ assertErr(result);
597
+ assertEqual(result.error.reason, "Unknown");
598
+ assertEqual(await readlink(alias), real);
599
+ assertEqual(await readlink(source), target);
600
+ assertTrue((await stat(source)).isDirectory());
601
+ });
602
+
603
+ it("retains Node's guards against copying a directory into itself", async () => {
604
+ const fs = createNodeFs();
605
+ await using run = testCreateRun();
606
+ const root = await mkdtemp(join(directory, "copy-self-"));
607
+ const source = join(root, "source");
608
+ await mkdir(source);
609
+ await writeFile(join(source, "keep"), "keep");
610
+ const alias = join(root, "alias");
611
+ await symlink(source, alias);
612
+ for (const destination of [
613
+ source,
614
+ join(source, "nested"),
615
+ join(alias, "nested"),
616
+ ]) {
617
+ const result = await run(fs.copy(source, destination, { force: true }));
618
+ assertErr(result);
619
+ assertEqual(result.error.reason, "Unknown");
620
+ }
621
+ assertEqual(await readFile(join(source, "keep"), "utf8"), "keep");
622
+ assertOk(await run(fs.exists(join(source, "nested"))), false);
623
+ });
624
+
625
+ it("copies relative and dangling symbolic links into missing parents", async () => {
626
+ const fs = createNodeFs();
627
+ await using run = testCreateRun();
628
+ const root = await mkdtemp(join(directory, "copy-new-links-"));
629
+ const source = join(root, "source");
630
+ await symlink("missing-target", source);
631
+ const destination = join(root, "nested", "destination");
632
+
633
+ assertOk(await run(fs.copy(source, destination)));
634
+ assertEqual(await readlink(destination), join(root, "missing-target"));
635
+ });
636
+
637
+ it(`copyFile copies files and requires explicit overwrite`, async () => {
638
+ const fs = createNodeFs();
639
+ await using run = testCreateRun();
640
+ const source = await setupFile(`copyFile-source.txt`, "source");
641
+ const destination = join(directory, `copyFile-destination.txt`);
642
+ assertOk(await run(fs.copyFile(source, destination)));
643
+ assertEqual(await readFile(destination, "utf8"), "source");
644
+
645
+ await writeFile(source, "changed");
646
+ const sourceUrl = pathToFileURL(source);
647
+ const destinationUrl = pathToFileURL(destination);
648
+ const existing = await run(fs.copyFile(sourceUrl, destinationUrl));
649
+ assertErr(existing);
650
+ assertEqual(existing.error.reason, "AlreadyExists");
651
+ assertEqual(existing.error.path, sourceUrl.href);
652
+ assertEqual(existing.error.destination, destinationUrl.href);
653
+ assertEqual(await readFile(destination, "utf8"), "source");
654
+
655
+ assertOk(
656
+ await run(fs.copyFile(sourceUrl, destinationUrl, { overwrite: true })),
657
+ );
658
+ assertEqual(await readFile(destination, "utf8"), "changed");
659
+ });
660
+
661
+ (["copy", "copyFile"] as const).forEach((operation) => {
662
+ it(`${operation} reports missing sources`, async () => {
663
+ const fs = createNodeFs();
664
+ await using run = testCreateRun();
665
+ const source = join(directory, `missing-${operation}`);
666
+ const destination = join(directory, `missing-${operation}-destination`);
667
+ const result = await run(fs[operation](source, destination));
668
+ assertErr(result);
669
+ assertEqual(result.error.reason, "NotFound");
670
+ assertEqual(result.error.path, source);
671
+ assertEqual(result.error.destination, destination);
672
+ });
673
+
674
+ it(`${operation} returns a completed copy after an abort request`, async () => {
675
+ const fs = createNodeFs();
676
+ await using run = testCreateRun();
677
+ const source = await setupFile(`${operation}-abort-source.txt`, "copied");
678
+ const destination = join(directory, `${operation}-abort-destination.txt`);
679
+ const fiber = run.abortable(fs[operation](source, destination));
680
+ fiber.abort(testAbortReason);
681
+ assertOk(await fiber);
682
+ assertEqual(await readFile(destination, "utf8"), "copied");
683
+ });
684
+ });
685
+
686
+ it("copies a directory tree and preserves timestamps when requested", async () => {
687
+ const fs = createNodeFs();
688
+ await using run = testCreateRun();
689
+ const source = join(directory, "copy-tree-source");
690
+ const destination = join(directory, "copy-tree-destination");
691
+ await mkdir(join(source, "nested"), { recursive: true });
692
+ const file = join(source, "nested", "file.txt");
693
+ await writeFile(file, "nested file");
694
+ const modified = new Date("2020-01-01T00:00:00Z");
695
+ await utimes(file, modified, modified);
696
+
697
+ assertOk(
698
+ await run(fs.copy(source, destination, { preserveTimestamps: true })),
699
+ );
700
+ const copiedFile = join(destination, "nested", "file.txt");
701
+ assertEqual(await readFile(copiedFile, "utf8"), "nested file");
702
+ assertEqual((await stat(copiedFile)).mtimeMs, modified.getTime());
703
+ });
704
+
705
+ it("reports file and directory mismatches", async () => {
706
+ const fs = createNodeFs();
707
+ await using run = testCreateRun();
708
+ const file = await setupFile("copy-mismatch.txt", "file");
709
+ const target = join(directory, "copy-mismatch-directory");
710
+ await mkdir(target);
711
+
712
+ const toDirectory = await run(fs.copy(file, target));
713
+ assertErr(toDirectory);
714
+ assertEqual(toDirectory.error.reason, "IsDirectory");
715
+ const toFile = await run(fs.copy(target, file));
716
+ assertErr(toFile);
717
+ assertEqual(toFile.error.reason, "NotDirectory");
718
+
719
+ const directoryCopy = await run(
720
+ fs.copyFile(target, join(directory, "copy-single-directory")),
721
+ );
722
+ assertErr(directoryCopy);
723
+ assertEqual(directoryCopy.error.path, target);
724
+ });
725
+ });
726
+
727
+ describe("rename", () => {
728
+ it("renames files and directories", async () => {
729
+ const fs = createNodeFs();
730
+ await using run = testCreateRun();
731
+ const source = await setupFile("rename-source.txt", "renamed");
732
+ const destination = join(directory, "rename-destination.txt");
733
+ await writeFile(destination, "old destination");
734
+ assertOk(
735
+ await run(fs.rename(pathToFileURL(source), pathToFileURL(destination))),
736
+ );
737
+ assertOk(await run(fs.exists(source)), false);
738
+ assertEqual(await readFile(destination, "utf8"), "renamed");
739
+
740
+ const oldDirectory = join(directory, "rename-old-directory");
741
+ const newDirectory = join(directory, "rename-new-directory");
742
+ await mkdir(oldDirectory);
743
+ await writeFile(join(oldDirectory, "inside.txt"), "inside");
744
+ assertOk(await run(fs.rename(oldDirectory, newDirectory)));
745
+ assertOk(await run(fs.exists(oldDirectory)), false);
746
+ assertEqual(
747
+ await readFile(join(newDirectory, "inside.txt"), "utf8"),
748
+ "inside",
749
+ );
750
+ });
751
+
752
+ it("reports source and destination when rename fails", async () => {
753
+ const fs = createNodeFs();
754
+ await using run = testCreateRun();
755
+ const source = join(directory, "missing-rename");
756
+ const destination = join(directory, "missing-rename-destination");
757
+ const result = await run(fs.rename(source, destination));
758
+ assertErr(result);
759
+ assertEqual(result.error.reason, "NotFound");
760
+ assertEqual(result.error.path, source);
761
+ assertEqual(result.error.destination, destination);
762
+ });
763
+
764
+ it("returns a completed rename after an abort request", async () => {
765
+ const fs = createNodeFs();
766
+ await using run = testCreateRun();
767
+ const source = await setupFile("rename-abort-source.txt", "renamed");
768
+ const destination = join(directory, "rename-abort-destination.txt");
769
+ const fiber = run.abortable(fs.rename(source, destination));
770
+ fiber.abort(testAbortReason);
771
+ assertOk(await fiber);
772
+ assertOk(await run(fs.exists(source)), false);
773
+ assertEqual(await readFile(destination, "utf8"), "renamed");
774
+ });
775
+ });
776
+
777
+ describe("createTempDirectory", () => {
778
+ [false, true].forEach((absolute) => {
779
+ it(`follows symlinks before parent components in ${absolute ? "absolute" : "relative"} paths`, async () => {
780
+ const fs = createNodeFs();
781
+ await using run = testCreateRun();
782
+ const root = await mkdtemp(join(directory, "temp-parent-"));
783
+ const physical = join(root, "physical");
784
+ await mkdir(join(physical, "deep"), { recursive: true });
785
+ await symlink(join(physical, "deep"), join(root, "alias"));
786
+ const originalCwd = process.cwd();
787
+ try {
788
+ process.chdir(root);
789
+ const parent = (absolute ? root + sep : "") + "alias" + sep + "..";
790
+ const temp = await run(fs.createTempDirectory({ directory: parent }));
791
+ assertOk(temp);
792
+ {
793
+ await using created = temp.value;
794
+ assertEqual(
795
+ await realpath(dirname(created.path)),
796
+ await realpath(physical),
797
+ );
798
+ process.chdir(originalCwd);
799
+ assertTrue((await stat(created.path)).isDirectory());
800
+ }
801
+ assertOk(await run(fs.exists(temp.value.path)), false);
802
+ } finally {
803
+ process.chdir(originalCwd);
804
+ }
805
+ });
806
+ });
807
+
808
+ it("does not normalize away a missing parent component", async () => {
809
+ const fs = createNodeFs();
810
+ await using run = testCreateRun();
811
+ const parent = directory + sep + "missing-parent" + sep + "..";
812
+ const result = await run(fs.createTempDirectory({ directory: parent }));
813
+ if (result.ok) await result.value[Symbol.asyncDispose]();
814
+
815
+ assertErr(result);
816
+ assertEqual(result.error.reason, "NotFound");
817
+ assertEqual(result.error.path, parent);
818
+ });
819
+
820
+ it("disposes the original directory after the working directory changes", async () => {
821
+ const fs = createNodeFs();
822
+ await using run = testCreateRun();
823
+ const originalCwd = process.cwd();
824
+ const root = await mkdtemp(join(directory, "temp-cwd-"));
825
+ const other = join(root, "other");
826
+ await mkdir(other);
827
+
828
+ try {
829
+ process.chdir(root);
830
+ const temp = await run(fs.createTempDirectory({ directory: "" }));
831
+ assertOk(temp);
832
+ const originalPath = resolve(temp.value.path);
833
+ const otherPath = join(other, basename(temp.value.path));
834
+ await mkdir(otherPath);
835
+ const sentinel = join(otherPath, "keep.txt");
836
+ await writeFile(sentinel, "keep");
837
+
838
+ {
839
+ await using created = temp.value;
840
+ process.chdir(other);
841
+ assertEqual(basename(created.path), basename(originalPath));
842
+ }
843
+
844
+ assertOk(await run(fs.exists(originalPath)), false);
845
+ assertEqual(await readFile(sentinel, "utf8"), "keep");
846
+ } finally {
847
+ process.chdir(originalCwd);
848
+ }
849
+ });
850
+
851
+ it("defaults to the system temporary directory", async () => {
852
+ const fs = createNodeFs();
853
+ await using run = testCreateRun();
854
+ const temp = await run(fs.createTempDirectory());
855
+ assertOk(temp);
856
+ await using directory = temp.value;
857
+
858
+ assertEqual(dirname(directory.path), await realpath(tmpdir()));
859
+ assertTrue(basename(directory.path).length > 0);
860
+ assertOk(await run(fs.exists(directory.path)), true);
861
+ });
862
+
863
+ it("uses a name prefix in the system temporary directory", async () => {
864
+ const fs = createNodeFs();
865
+ await using run = testCreateRun();
866
+ const temp = await run(fs.createTempDirectory({ prefix: "evolu-fs-" }));
867
+ assertOk(temp);
868
+ await using directory = temp.value;
869
+
870
+ assertEqual(dirname(directory.path), await realpath(tmpdir()));
871
+ assertTrue(basename(directory.path).startsWith("evolu-fs-"));
872
+ });
873
+
874
+ it("keeps an empty prefix inside the supplied directory", async () => {
875
+ const fs = createNodeFs();
876
+ await using run = testCreateRun();
877
+ const temp = await run(
878
+ fs.createTempDirectory({ directory: directory + sep, prefix: "" }),
879
+ );
880
+ assertOk(temp);
881
+ await using created = temp.value;
882
+
883
+ assertEqual(dirname(created.path), await realpath(directory));
884
+ assertTrue(basename(created.path).length > 0);
885
+ });
886
+
887
+ it("resolves an empty parent to the current directory", async () => {
888
+ const fs = createNodeFs();
889
+ await using run = testCreateRun();
890
+ const temp = await run(
891
+ fs.createTempDirectory({ directory: "", prefix: "evolu-fs-" }),
892
+ );
893
+ assertOk(temp);
894
+ await using directory = temp.value;
895
+
896
+ assertEqual(resolve(dirname(directory.path)), process.cwd());
897
+ assertTrue(basename(directory.path).startsWith("evolu-fs-"));
898
+ });
899
+
900
+ it("creates a directory under the supplied parent and removes it on disposal", async () => {
901
+ const fs = createNodeFs();
902
+ await using run = testCreateRun();
903
+ const parent = await realpath(directory);
904
+ const prefix = join(parent, "temp-");
905
+
906
+ let path = "";
907
+ {
908
+ const temp = await run(
909
+ fs.createTempDirectory({ directory, prefix: "temp-" }),
910
+ );
911
+ assertOk(temp);
912
+ await using created = temp.value;
913
+ path = created.path;
914
+ assertEqual(dirname(path), parent);
915
+ assertTrue(path.startsWith(prefix));
916
+ await writeFile(join(path, "inside.txt"), "x");
917
+ }
918
+ assertOk(await run(fs.exists(path)), false);
919
+ });
920
+
921
+ it("reports a missing parent", async () => {
922
+ const fs = createNodeFs();
923
+ await using run = testCreateRun();
924
+ const parent = join(directory, "missing");
925
+
926
+ const missing = await run(
927
+ fs.createTempDirectory({ directory: parent, prefix: "temp-" }),
928
+ );
929
+ assertErr(missing);
930
+ assertEqual(missing.error.reason, "NotFound");
931
+ assertEqual(missing.error.path, parent);
932
+ assertEqual(missing.error.syscall, "realpath");
933
+ });
934
+
935
+ it("reports a non-directory parent when creation fails", async () => {
936
+ const fs = createNodeFs();
937
+ await using run = testCreateRun();
938
+ const parent = await setupFile("temp-file-parent", "keep");
939
+
940
+ const result = await run(fs.createTempDirectory({ directory: parent }));
941
+
942
+ assertErr(result);
943
+ assertEqual(result.error.reason, "NotDirectory");
944
+ assertEqual(result.error.path, (await realpath(parent)) + sep);
945
+ assertEqual(result.error.syscall, "mkdtemp");
946
+ assertEqual(await readFile(parent, "utf8"), "keep");
947
+ });
948
+
949
+ it("returns a disposable directory after an abort request", async () => {
950
+ const fs = createNodeFs();
951
+ await using run = testCreateRun();
952
+
953
+ let path = "";
954
+ {
955
+ const fiber = run.abortable(
956
+ fs.createTempDirectory({ directory, prefix: "aborted-temp-" }),
957
+ );
958
+ fiber.abort(testAbortReason);
959
+
960
+ const temp = await fiber;
961
+ assertOk(temp);
962
+ await using created = temp.value;
963
+ path = created.path;
964
+ assertOk(await run(fs.exists(path)), true);
965
+ await writeFile(join(path, "inside.txt"), "x");
966
+ }
967
+ assertOk(await run(fs.exists(path)), false);
968
+ });
969
+
970
+ it("returns the file system error after an abort request", async () => {
971
+ const fs = createNodeFs();
972
+ await using run = testCreateRun();
973
+ const fiber = run.abortable(
974
+ fs.createTempDirectory({
975
+ directory: join(directory, "missing"),
976
+ prefix: "aborted-temp-",
977
+ }),
978
+ );
979
+ fiber.abort(testAbortReason);
980
+
981
+ const result = await fiber;
982
+ assertErr(result);
983
+ assertEqual(result.error.type, "FsError");
984
+ assertEqual(result.error.reason, "NotFound");
985
+ });
986
+ });
987
+
988
+ describe("createNodeFs", () => {
989
+ it("preserves errors from non-cancellable operations after an abort request", async () => {
990
+ const fs = createNodeFs();
991
+ await using run = testCreateRun();
992
+ const missing = join(directory, "missing-after-abort");
993
+ const destination = join(directory, "unused-destination");
994
+ const tasks: ReadonlyArray<Task<unknown, FsError>> = [
995
+ fs.readDirectory(missing),
996
+ fs.createDirectory(join(missing, "nested")),
997
+ fs.copy(missing, destination),
998
+ fs.copyFile(missing, destination),
999
+ fs.rename(missing, destination),
1000
+ fs.remove(missing),
1001
+ fs.getMetadata(missing),
1002
+ ];
1003
+
1004
+ for (const task of tasks) {
1005
+ const fiber = run.abortable(task);
1006
+ fiber.abort(testAbortReason);
1007
+ const result = await fiber;
1008
+ assertErr(result);
1009
+ assertEqual(result.error.type, "FsError");
1010
+ assertEqual(result.error.reason, "NotFound");
1011
+ }
1012
+ });
1013
+
1014
+ it("can be injected into a Run", async () => {
1015
+ await using run = createRun({ fs: createNodeFs() });
1016
+ assertOk(await run(run.deps.fs.exists(directory)), true);
1017
+ assertTrue(typeof run.deps.time.now === "function");
1018
+ });
1019
+ });