@zenfs/core 2.5.8 → 2.6.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.
Files changed (63) hide show
  1. package/dist/backends/cow.d.ts +13 -0
  2. package/dist/backends/cow.js +48 -11
  3. package/dist/backends/single_buffer.d.ts +2 -2
  4. package/dist/backends/store/fs.d.ts +6 -6
  5. package/dist/backends/store/fs.js +10 -2
  6. package/dist/internal/devices.d.ts +6 -6
  7. package/dist/internal/index_fs.d.ts +4 -4
  8. package/dist/node/async.js +1 -1
  9. package/dist/node/dir.d.ts +3 -1
  10. package/dist/node/dir.js +23 -5
  11. package/dist/node/promises.d.ts +3 -1
  12. package/dist/node/promises.js +128 -89
  13. package/dist/node/streams.d.ts +4 -0
  14. package/dist/node/streams.js +9 -10
  15. package/dist/node/sync.js +71 -40
  16. package/dist/utils.d.ts +8 -1
  17. package/dist/utils.js +26 -7
  18. package/dist/vfs/async.js +224 -85
  19. package/dist/vfs/file.d.ts +13 -7
  20. package/dist/vfs/file.js +661 -357
  21. package/dist/vfs/shared.d.ts +1 -0
  22. package/dist/vfs/shared.js +19 -3
  23. package/dist/vfs/sync.js +214 -85
  24. package/dist/vfs/vcache.d.ts +66 -0
  25. package/dist/vfs/vcache.js +249 -0
  26. package/dist/vfs/vnode.d.ts +66 -0
  27. package/dist/vfs/vnode.js +200 -0
  28. package/dist/vfs/watchers.js +4 -2
  29. package/dist/vfs/xattr.js +2 -2
  30. package/eslint.shared.js +1 -1
  31. package/package.json +3 -3
  32. package/scripts/make-index.js +7 -1
  33. package/scripts/test.js +28 -21
  34. package/tests/backend/port.test.ts +3 -3
  35. package/tests/backend/store.test.ts +49 -0
  36. package/tests/common.ts +62 -0
  37. package/tests/fetch/fetch.ts +6 -0
  38. package/tests/fetch/run.sh +2 -2
  39. package/tests/fetch/server.js +3 -3
  40. package/tests/fs/append.test.ts +5 -5
  41. package/tests/fs/concurrency.test.ts +52 -0
  42. package/tests/fs/dir.test.ts +13 -15
  43. package/tests/fs/directory.test.ts +32 -31
  44. package/tests/fs/errors.test.ts +151 -31
  45. package/tests/fs/exists.test.ts +4 -4
  46. package/tests/fs/links.test.ts +4 -11
  47. package/tests/fs/open.test.ts +4 -4
  48. package/tests/fs/permissions.test.ts +15 -15
  49. package/tests/fs/read.test.ts +9 -9
  50. package/tests/fs/readFile.test.ts +9 -9
  51. package/tests/fs/rename.test.ts +8 -8
  52. package/tests/fs/scaling.test.ts +4 -4
  53. package/tests/fs/stat.test.ts +20 -20
  54. package/tests/fs/streams.test.ts +9 -6
  55. package/tests/fs/tempdir.test.ts +33 -10
  56. package/tests/fs/times.test.ts +11 -11
  57. package/tests/fs/truncate.test.ts +6 -6
  58. package/tests/fs/watch.test.ts +107 -53
  59. package/tests/fs/write.test.ts +10 -10
  60. package/tests/fs/xattr.test.ts +3 -3
  61. package/tests/native/run.sh +3 -0
  62. package/tests/native/setup.ts +230 -0
  63. package/types/readable-stream.d.ts +2 -2
@@ -2,7 +2,7 @@
2
2
  import type { Stats } from '@zenfs/core';
3
3
  import assert from 'node:assert/strict';
4
4
  import { suite, test } from 'node:test';
5
- import { fs } from '../common.js';
5
+ import { config, fs } from '../common.js';
6
6
 
7
7
  const testDir = '/test-watch-dir';
8
8
  const testFile = testDir + '/test.txt';
@@ -10,10 +10,22 @@ const testFile = testDir + '/test.txt';
10
10
  await fs.promises.mkdir(testDir);
11
11
  await fs.promises.writeFile(testFile, 'Initial content');
12
12
 
13
+ /** Fail instead of hanging the suite if an expected event is never emitted */
14
+ async function withTimeout<T>(promise: Promise<T>, ms: number = 1000): Promise<T> {
15
+ const { promise: timedOut, reject } = Promise.withResolvers<never>();
16
+ const timer = setTimeout(() => reject(new Error(`Timed out after ${ms} ms waiting for an event`)), ms);
17
+ timer.unref?.();
18
+ try {
19
+ return await Promise.race([promise, timedOut]);
20
+ } finally {
21
+ clearTimeout(timer);
22
+ }
23
+ }
24
+
13
25
  /**
14
26
  * @todo convert `using watcher = ...` to void discards pending ES proposal
15
27
  */
16
- suite('Watch', () => {
28
+ suite('Watch', config('watch'), () => {
17
29
  test('Events emitted on file change', async () => {
18
30
  const { promise, resolve } = Promise.withResolvers<[string, string]>();
19
31
 
@@ -24,31 +36,47 @@ suite('Watch', () => {
24
36
  // Modify the file to trigger the event
25
37
  await fs.promises.writeFile(testFile, 'Updated content');
26
38
 
27
- const [eventType, filename] = await promise;
39
+ const [eventType, filename] = await withTimeout(promise);
28
40
  assert.equal(eventType, 'change');
29
41
  assert.equal(filename, 'test.txt');
30
42
  });
31
43
 
32
44
  test('Events are emitted on delete', async () => {
45
+ const { promise, resolve } = Promise.withResolvers<string>();
46
+
47
+ /* Only wait for the 'rename'. Deleting a watched file changes its link count,
48
+ which some implementations report as a 'change' event first. */
33
49
  using watcher = fs.watch(testFile, (eventType, filename) => {
34
- assert.equal(eventType, 'rename');
35
- assert.equal(filename, 'test.txt');
50
+ if (eventType == 'rename') resolve(filename);
36
51
  });
37
52
 
38
53
  // Delete the file to trigger the event
39
54
  await fs.promises.unlink(testFile);
55
+
56
+ assert.equal(await withTimeout(promise), 'test.txt');
40
57
  });
41
58
 
42
59
  test('Changes are detected with watchFile()', async () => {
43
- const listener = (curr: Stats, prev: Stats) => {
44
- assert(curr.mtimeMs != prev.mtimeMs);
45
- fs.unwatchFile(testFile, listener);
46
- };
60
+ // The previous test deleted the file, and watching a non-existent file is its own can of worms
61
+ await fs.promises.writeFile(testFile, 'Restored');
47
62
 
48
- fs.watchFile(testFile, listener);
63
+ // Timestamps have millisecond resolution, so make sure the two writes can not share one
64
+ await new Promise(resolve => setImmediate(resolve));
49
65
 
50
- // Modify the file to trigger the event
51
- await fs.promises.writeFile(testFile, 'Changed content');
66
+ const { promise, resolve } = Promise.withResolvers<[Stats, Stats]>();
67
+ const listener = (curr: Stats, prev: Stats) => resolve([curr, prev]);
68
+
69
+ fs.watchFile(testFile, { interval: 50 }, listener);
70
+
71
+ try {
72
+ // Modify the file to trigger the event
73
+ await fs.promises.writeFile(testFile, 'Changed content');
74
+
75
+ const [curr, prev] = await withTimeout(promise, 2000);
76
+ assert(curr.mtimeMs != prev.mtimeMs);
77
+ } finally {
78
+ fs.unwatchFile(testFile, listener);
79
+ }
52
80
  });
53
81
 
54
82
  test('unwatchFile() works', async () => {
@@ -69,12 +97,18 @@ suite('Watch', () => {
69
97
  });
70
98
 
71
99
  test('Directories can be watched', async () => {
100
+ const { promise, resolve } = Promise.withResolvers<[string, string]>();
101
+
72
102
  using watcher = fs.watch(testDir, (eventType, filename) => {
73
- assert.equal(eventType, 'change');
74
- assert.equal(filename, 'newFile.txt');
103
+ resolve([eventType, filename]);
75
104
  });
76
105
 
77
106
  await fs.promises.writeFile(testDir + '/newFile.txt', 'Content');
107
+
108
+ const [eventType, filename] = await withTimeout(promise);
109
+ // Creating the file adds an entry to the directory, which is a 'rename' event
110
+ assert.equal(eventType, 'rename');
111
+ assert.equal(filename, 'newFile.txt');
78
112
  });
79
113
 
80
114
  test('File renames are detected', async () => {
@@ -84,24 +118,22 @@ suite('Watch', () => {
84
118
  const newFile = testDir + '/' + newFileName;
85
119
 
86
120
  await fs.promises.writeFile(oldFile, 'Some content');
87
- const oldFileResolver = Promise.withResolvers<void>();
88
- const newFileResolver = Promise.withResolvers<void>();
89
121
 
90
- const fileResolvers: Record<string, { resolver: PromiseWithResolvers<void>; eventType: string }> = {
91
- [oldFileName]: { resolver: oldFileResolver, eventType: 'rename' },
92
- [newFileName]: { resolver: newFileResolver, eventType: 'change' },
93
- };
122
+ const events: Record<string, string> = {};
123
+ const { promise, resolve } = Promise.withResolvers<void>();
94
124
 
95
125
  using watcher = fs.watch(testDir, (eventType, filename) => {
96
- const resolver = fileResolvers[filename];
97
- assert.notEqual(resolver, undefined); // should have a resolver so file is expected
98
- assert.equal(eventType, resolver.eventType);
99
- resolver.resolver.resolve();
126
+ events[filename] ??= eventType;
127
+ if (oldFileName in events && newFileName in events) resolve();
100
128
  });
101
129
 
102
- // Rename the file to trigger the event
130
+ // Rename the file to trigger the events
103
131
  await fs.promises.rename(oldFile, newFile);
104
- await Promise.all([newFileResolver.promise, oldFileResolver.promise]);
132
+
133
+ await withTimeout(promise);
134
+ // Both names are 'rename': one entry disappears and another appears
135
+ assert.equal(events[oldFileName], 'rename');
136
+ assert.equal(events[newFileName], 'rename');
105
137
  });
106
138
 
107
139
  test('File deletions are detected', async () => {
@@ -109,12 +141,16 @@ suite('Watch', () => {
109
141
 
110
142
  await fs.promises.writeFile(tempFile, 'Temporary content');
111
143
 
144
+ const { promise, resolve } = Promise.withResolvers<string>();
145
+
146
+ // Only wait for the 'rename', see 'Events are emitted on delete'
112
147
  using watcher = fs.watch(tempFile, (eventType, filename) => {
113
- assert.equal(eventType, 'rename');
114
- assert.equal(filename, 'tempFile.txt');
148
+ if (eventType == 'rename') resolve(filename);
115
149
  });
116
150
 
117
151
  await fs.promises.unlink(tempFile);
152
+
153
+ assert.equal(await withTimeout(promise), 'tempFile.txt');
118
154
  });
119
155
 
120
156
  test('File deletions are detected by promises API', async () => {
@@ -122,39 +158,59 @@ suite('Watch', () => {
122
158
 
123
159
  await fs.promises.writeFile(tempFile, 'Temporary content');
124
160
 
125
- const watcher = fs.promises.watch(tempFile);
161
+ const ac = new AbortController();
162
+ const watcher = fs.promises.watch(tempFile, { signal: ac.signal });
126
163
 
164
+ // Only wait for the 'rename', see 'Events are emitted on delete'
127
165
  const promise = (async () => {
128
- for await (const event of watcher) {
129
- assert.equal(event.eventType, 'rename');
130
- assert.equal(event.filename, 'tempFile.txt');
131
- return;
132
- }
133
- })();
166
+ for await (const event of watcher) if (event.eventType == 'rename') return event;
167
+ })().catch((e: Error) => {
168
+ if (e.name != 'AbortError') throw e;
169
+ });
134
170
 
135
- await fs.promises.unlink(tempFile);
136
- await watcher.return!();
137
- await promise;
171
+ try {
172
+ await fs.promises.unlink(tempFile);
173
+
174
+ const event = await withTimeout(promise);
175
+ assert.equal(event?.eventType, 'rename');
176
+ assert.equal(event?.filename, 'tempFile.txt');
177
+ } finally {
178
+ /* Abort before return(): a native iterator suspended awaiting an event
179
+ queues return() behind that await, which never resolves */
180
+ ac.abort();
181
+ await watcher.return?.();
182
+ }
138
183
  });
139
184
 
140
185
  test('File creations are detected recursively', async () => {
141
186
  const subDir = `${testDir}/sub-dir`;
142
187
  const tempFile = `${subDir}/tempFile.txt`;
143
188
  await fs.promises.mkdir(subDir);
144
- const watcher = fs.promises.watch('/');
145
189
 
190
+ const ac = new AbortController();
191
+ const watcher = fs.promises.watch('/', { recursive: true, signal: ac.signal });
192
+
193
+ // Create the file before consuming the iterator, so the first observed event is the unlink
146
194
  await fs.promises.writeFile(tempFile, 'Temporary content');
195
+
147
196
  const promise = (async () => {
148
- for await (const event of watcher) {
149
- assert.equal(event.eventType, 'rename');
150
- assert.equal(event.filename, tempFile.slice(1));
151
- return;
152
- }
153
- })();
197
+ for await (const event of watcher) return event;
198
+ })().catch((e: Error) => {
199
+ if (e.name != 'AbortError') throw e;
200
+ });
154
201
 
155
- await fs.promises.unlink(tempFile);
156
- await watcher.return!();
157
- await promise;
202
+ try {
203
+ await fs.promises.unlink(tempFile);
204
+
205
+ const event = await withTimeout(promise);
206
+ assert.equal(event?.eventType, 'rename');
207
+ assert.equal(event?.filename, tempFile.slice(1));
208
+ } finally {
209
+ /* Abort before return(): a native iterator suspended awaiting an event
210
+ queues return() behind that await, which never resolves */
211
+ ac.abort();
212
+ await watcher.return?.();
213
+ }
158
214
  });
159
215
 
160
216
  test('watch("/") receives events for files under root #293', async () => {
@@ -168,11 +224,9 @@ suite('Watch', () => {
168
224
 
169
225
  await fs.promises.writeFile('/watch-root-file.txt', 'x');
170
226
 
171
- const [eventType, filename] = await Promise.race([
172
- promise,
173
- new Promise<[string, string]>((_, reject) => setTimeout(() => reject(new Error('watch("/") timed out')), 1000)),
174
- ]);
175
- assert.equal(eventType, 'change');
227
+ const [eventType, filename] = await withTimeout(promise);
228
+ // Creating the file adds an entry to the root directory, which is a 'rename' event
229
+ assert.equal(eventType, 'rename');
176
230
  assert.equal(filename, 'watch-root-file.txt');
177
231
  });
178
232
  });
@@ -3,14 +3,14 @@ import assert from 'node:assert/strict';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path/posix';
5
5
  import { suite, test } from 'node:test';
6
- import { fs } from '../common.js';
6
+ import { config, fs } from '../common.js';
7
7
  import { data as dataPath } from '../setup.js';
8
8
 
9
9
  const fileName = 'write.txt';
10
10
  const utf8example = readFileSync(join(dataPath, 'utf8.txt'), 'utf8');
11
11
 
12
- suite('Writes', () => {
13
- test('Using FileHandle and UTF-8', async () => {
12
+ suite('Writes', config('write'), () => {
13
+ test('Using FileHandle and UTF-8', config('async'), async () => {
14
14
  const expected = 'ümlaut.';
15
15
 
16
16
  const handle = await fs.promises.open(fileName, 'w', 0o644);
@@ -25,7 +25,7 @@ suite('Writes', () => {
25
25
  await fs.promises.unlink(fileName);
26
26
  });
27
27
 
28
- test('Using FileHandle with buffer', async () => {
28
+ test('Using FileHandle with buffer', config('async'), async () => {
29
29
  const expected = Buffer.from('hello');
30
30
 
31
31
  const handle = await fs.promises.open(fileName, 'w', 0o644);
@@ -41,7 +41,7 @@ suite('Writes', () => {
41
41
  await fs.promises.unlink(fileName);
42
42
  });
43
43
 
44
- test('Using sync path functions', () => {
44
+ test('Using sync path functions', config('sync'), () => {
45
45
  const fd = fs.openSync(fileName, 'w');
46
46
 
47
47
  let written = fs.writeSync(fd, '');
@@ -58,7 +58,7 @@ suite('Writes', () => {
58
58
  assert.equal(fs.readFileSync(fileName, 'utf8'), 'foobár');
59
59
  });
60
60
 
61
- test('Using promises API', async () => {
61
+ test('Using promises API', config('async'), async () => {
62
62
  const filename = 'test.txt';
63
63
  await fs.promises.writeFile(filename, utf8example);
64
64
  const data = await fs.promises.readFile(filename);
@@ -66,7 +66,7 @@ suite('Writes', () => {
66
66
  await fs.promises.unlink(filename);
67
67
  });
68
68
 
69
- test('Using promises API with buffer', async () => {
69
+ test('Using promises API with buffer', config('async'), async () => {
70
70
  const filename = 'test2.txt';
71
71
  const expected = Buffer.from(utf8example, 'utf8');
72
72
 
@@ -77,7 +77,7 @@ suite('Writes', () => {
77
77
  await fs.promises.unlink(filename);
78
78
  });
79
79
 
80
- test('Promises API with base64 data', async () => {
80
+ test('Promises API with base64 data', config('async'), async () => {
81
81
  const data = readFileSync(join(dataPath, 'image.jpg'), 'base64');
82
82
 
83
83
  const buffer = Buffer.from(data, 'base64');
@@ -89,7 +89,7 @@ suite('Writes', () => {
89
89
  assert.equal(read, data);
90
90
  });
91
91
 
92
- test('Using sync path functions with custom mode', () => {
92
+ test('Using sync path functions with custom mode', config('sync', 'permissions'), () => {
93
93
  const file = 'testWriteFileSync.txt';
94
94
  const mode = 0o755;
95
95
 
@@ -102,7 +102,7 @@ suite('Writes', () => {
102
102
  fs.unlinkSync(file);
103
103
  });
104
104
 
105
- test('Appending to a file synchronously with custom mode', () => {
105
+ test('Appending to a file synchronously with custom mode', config('sync', 'appends', 'permissions'), () => {
106
106
  const file = 'testAppendFileSync.txt';
107
107
  const mode = 0o755;
108
108
 
@@ -2,9 +2,9 @@
2
2
  import { Buffer } from 'buffer';
3
3
  import assert from 'node:assert/strict';
4
4
  import { suite, test } from 'node:test';
5
- import { fs } from '../common.js';
5
+ import { config, fs } from '../common.js';
6
6
 
7
- suite('Extended Attributes', () => {
7
+ suite('Extended Attributes', config('xattr'), () => {
8
8
  const testFile = 'xattr-test.txt';
9
9
  const testValue = 'test value';
10
10
  const testName = 'user.test';
@@ -63,7 +63,7 @@ suite('Extended Attributes', () => {
63
63
  await assert.rejects(fs.xattr.set('nonexistent-file.txt', testName, 'value'), { code: 'ENOENT' });
64
64
  });
65
65
 
66
- test('synchronous operations', () => {
66
+ test('synchronous operations', config('sync'), () => {
67
67
  const syncAttrName = 'user.sync-test';
68
68
 
69
69
  fs.xattr.setSync(testFile, syncAttrName, testValue);
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+
3
+ sudo npx zenfs-test -v tests/native/setup.ts
@@ -0,0 +1,230 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ /* Run the FS test suite against native node:fs.
3
+ Since the tests use absolute paths, everything is contained to a scratch directory using chroot-style path mapping. */
4
+ import * as native from 'node:fs';
5
+ import type { TestFlag, TestFlagState } from '../common.js';
6
+ import { isAbsolute, join, relative, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { copySync, data, tmp } from '../setup.js';
9
+ import { styleText } from 'node:util';
10
+
11
+ // The tests assume modes are used as-is, but native fs applies the process umask
12
+ process.umask(0);
13
+
14
+ // Test files run in parallel processes, so each process gets its own scratch directory
15
+ const root = native.mkdtempSync(join(tmp, 'native-'));
16
+
17
+ /** Restore directory modes so the scratch directory can be removed (e.g. after permissions tests chmod 0o000) */
18
+ function makeRemovable(path: string): void {
19
+ try {
20
+ if (!native.lstatSync(path).isDirectory()) return;
21
+ native.chmodSync(path, 0o755);
22
+ for (const entry of native.readdirSync(path)) makeRemovable(join(path, entry));
23
+ } catch {
24
+ // The cleanup is best-effort; rmSync will report anything unremovable
25
+ }
26
+ }
27
+
28
+ process.on('exit', () => {
29
+ makeRemovable(root);
30
+ native.rmSync(root, { recursive: true, force: true });
31
+ });
32
+
33
+ type PathLike = string | Buffer | URL;
34
+
35
+ function isPath(value: unknown): value is PathLike {
36
+ return typeof value == 'string' || value instanceof Buffer || value instanceof URL;
37
+ }
38
+
39
+ /** Translate a test path into the scratch directory */
40
+ function toNative(path: PathLike): string {
41
+ if (path instanceof URL) path = fileURLToPath(path);
42
+ path = path.toString();
43
+ // Do not map the empty path, so it still results in ENOENT
44
+ if (!path) return path;
45
+ return join(root, resolve('/', path));
46
+ }
47
+
48
+ /** Translate a native path back into a test path */
49
+ function fromNative(path: string): string {
50
+ return resolve('/', relative(root, path));
51
+ }
52
+
53
+ /** How many leading arguments are paths */
54
+ const pathArgs: Record<string, number> = {
55
+ access: 1,
56
+ appendFile: 1,
57
+ chmod: 1,
58
+ chown: 1,
59
+ copyFile: 2,
60
+ cp: 2,
61
+ createReadStream: 1,
62
+ createWriteStream: 1,
63
+ exists: 1,
64
+ glob: 1,
65
+ lchmod: 1,
66
+ lchown: 1,
67
+ link: 2,
68
+ lstat: 1,
69
+ lutimes: 1,
70
+ mkdir: 1,
71
+ mkdtemp: 1,
72
+ mkdtempDisposable: 1,
73
+ open: 1,
74
+ opendir: 1,
75
+ readFile: 1,
76
+ readdir: 1,
77
+ readlink: 1,
78
+ realpath: 1,
79
+ rename: 2,
80
+ rm: 1,
81
+ rmdir: 1,
82
+ stat: 1,
83
+ statfs: 1,
84
+ truncate: 1,
85
+ unlink: 1,
86
+ unwatchFile: 1,
87
+ utimes: 1,
88
+ watch: 1,
89
+ watchFile: 1,
90
+ writeFile: 1,
91
+ };
92
+
93
+ /** Functions whose result is a path that needs to be translated back. Note `mkdir` returns the first created path when recursive */
94
+ const returnsPath = new Set(['realpath', 'mkdtemp', 'readlink', 'mkdir']);
95
+
96
+ function mapArgs(base: string, args: unknown[]): unknown[] {
97
+ const out = [...args];
98
+
99
+ if (base == 'symlink') {
100
+ /* Absolute targets must be mapped so resolution can't escape the scratch directory.
101
+ Relative targets resolve against the link's parent, which is already inside. */
102
+ if (isPath(out[0]) && isAbsolute(out[0].toString())) out[0] = toNative(out[0]);
103
+ if (isPath(out[1])) out[1] = toNative(out[1]);
104
+ return out;
105
+ }
106
+
107
+ for (let i = 0; i < (pathArgs[base] ?? 0); i++) {
108
+ const arg = out[i];
109
+ if (isPath(arg)) out[i] = toNative(arg);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function mapResult(base: string, result: unknown): unknown {
115
+ if (returnsPath.has(base) && typeof result == 'string') return fromNative(result);
116
+
117
+ // `Dirent.parentPath` is a real path, so it needs to be mapped back too
118
+ if (base == 'readdir' && Array.isArray(result)) {
119
+ for (const entry of result) {
120
+ if (typeof entry?.parentPath == 'string') entry.parentPath = fromNative(entry.parentPath);
121
+ }
122
+ }
123
+
124
+ // `mkdtempDisposable` returns the path on an object. Disposal uses the path it captured, so this is safe to change.
125
+ const disposable = result as { path?: unknown };
126
+ if (base == 'mkdtempDisposable' && typeof disposable?.path == 'string') disposable.path = fromNative(disposable.path);
127
+
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * Functions whose last argument is an event listener, not a `(err, result)` callback.
133
+ * Wrapping those would change the listener's identity, so `unwatchFile` could not remove it.
134
+ */
135
+ const listenerAPIs = new Set(['watch', 'watchFile', 'unwatchFile']);
136
+
137
+ /** Translate native paths in errors back into test paths */
138
+ function mapError(error: unknown): unknown {
139
+ if (!error || typeof error != 'object') return error;
140
+ const e = error as Record<string, unknown>;
141
+
142
+ for (const key of ['path', 'dest'] as const) {
143
+ if (typeof e[key] == 'string' && e[key].startsWith(root)) e[key] = fromNative(e[key]);
144
+ }
145
+ if (typeof e.message == 'string') e.message = e.message.replaceAll(root, '');
146
+
147
+ return error;
148
+ }
149
+
150
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
151
+ function wrap(name: string, fn: Function, promise: boolean): Function {
152
+ const base = name.endsWith('Sync') ? name.slice(0, -4) : name;
153
+
154
+ if (promise) {
155
+ return async function (...args: unknown[]) {
156
+ try {
157
+ return mapResult(base, await fn(...mapArgs(base, args)));
158
+ } catch (e) {
159
+ throw mapError(e);
160
+ }
161
+ };
162
+ }
163
+
164
+ return function (...args: unknown[]) {
165
+ const mapped = mapArgs(base, args);
166
+
167
+ // Map paths in callback errors and results (e.g. realpath(path, cb))
168
+ const cb = mapped.at(-1);
169
+ if (typeof cb == 'function' && !listenerAPIs.has(base)) {
170
+ mapped[mapped.length - 1] = (err: unknown, result: unknown, ...rest: unknown[]) => cb(mapError(err), mapResult(base, result), ...rest);
171
+ }
172
+
173
+ try {
174
+ return mapResult(base, fn(...mapped));
175
+ } catch (e) {
176
+ throw mapError(e);
177
+ }
178
+ };
179
+ }
180
+
181
+ const wrapped: Record<string, unknown> = {};
182
+
183
+ for (const [key, value] of Object.entries(native)) {
184
+ if (key == 'promises') continue;
185
+ // Classes (Stats, Dirent, ReadStream, ...) must not be wrapped, or `instanceof` breaks
186
+ wrapped[key] = typeof value == 'function' && !/^[A-Z]/.test(key) ? wrap(key, value, false) : value;
187
+ }
188
+
189
+ // `using` support, which node:fs does not have (yet?)
190
+ wrapped.watch = function (...args: Parameters<typeof native.watch>) {
191
+ const watcher = native.watch(...(mapArgs('watch', args) as Parameters<typeof native.watch>));
192
+ (watcher as any)[Symbol.dispose] ??= () => watcher.close();
193
+ (watcher as any)[Symbol.asyncDispose] ??= () => watcher.close();
194
+ return watcher;
195
+ };
196
+
197
+ const promises: Record<string, unknown> = {};
198
+
199
+ for (const [key, value] of Object.entries(native.promises)) {
200
+ promises[key] = typeof value == 'function' && !/^[A-Z]/.test(key) ? wrap(key, value, true) : value;
201
+ }
202
+
203
+ // promises.watch returns an async iterable, not a promise, so it must not be awaited
204
+ promises.watch = (...args: Parameters<typeof native.promises.watch>) =>
205
+ native.promises.watch(...(mapArgs('watch', args) as Parameters<typeof native.promises.watch>));
206
+
207
+ // ZenFS API extension
208
+ promises.exists = (path: PathLike) => Promise.resolve(native.existsSync(toNative(path)));
209
+
210
+ wrapped.promises = promises;
211
+
212
+ export const fs = wrapped as unknown as typeof native;
213
+
214
+ /** So tests can check `stats instanceof Stats` against the correct class */
215
+ export const Stats = native.Stats;
216
+
217
+ export const flags: Partial<Record<TestFlag, TestFlagState>> = {
218
+ // Node only implements `lchmod` on macOS
219
+ lchmod: process.platform == 'darwin',
220
+ // The suite uses ZenFS' own extended attribute API, which `node:fs` does not have
221
+ xattr: false,
222
+ };
223
+
224
+ copySync(data, fs);
225
+
226
+ // Kill genuinely hung processes without keeping finished ones alive
227
+ setTimeout(() => {
228
+ console.error(styleText(['yellow', 'bold'], 'Process took longer than 5s and was killed.'));
229
+ process.exit(255);
230
+ }, 5_000).unref();
@@ -13,6 +13,6 @@ Warning: Do not install @types/readable-stream alongside this package!
13
13
 
14
14
  /// <reference types="node" preserve="true" />
15
15
  declare module 'readable-stream' {
16
- import { Readable, Writable } from 'node:stream';
17
- export { Readable, Writable };
16
+ import { finished, Readable, Writable } from 'node:stream';
17
+ export { finished, Readable, Writable };
18
18
  }