@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.
- package/dist/backends/cow.d.ts +13 -0
- package/dist/backends/cow.js +48 -11
- package/dist/backends/single_buffer.d.ts +2 -2
- package/dist/backends/store/fs.d.ts +6 -6
- package/dist/backends/store/fs.js +10 -2
- package/dist/internal/devices.d.ts +6 -6
- package/dist/internal/index_fs.d.ts +4 -4
- package/dist/node/async.js +1 -1
- package/dist/node/dir.d.ts +3 -1
- package/dist/node/dir.js +23 -5
- package/dist/node/promises.d.ts +3 -1
- package/dist/node/promises.js +128 -89
- package/dist/node/streams.d.ts +4 -0
- package/dist/node/streams.js +9 -10
- package/dist/node/sync.js +71 -40
- package/dist/utils.d.ts +8 -1
- package/dist/utils.js +26 -7
- package/dist/vfs/async.js +224 -85
- package/dist/vfs/file.d.ts +13 -7
- package/dist/vfs/file.js +661 -357
- package/dist/vfs/shared.d.ts +1 -0
- package/dist/vfs/shared.js +19 -3
- package/dist/vfs/sync.js +214 -85
- package/dist/vfs/vcache.d.ts +66 -0
- package/dist/vfs/vcache.js +249 -0
- package/dist/vfs/vnode.d.ts +66 -0
- package/dist/vfs/vnode.js +200 -0
- package/dist/vfs/watchers.js +4 -2
- package/dist/vfs/xattr.js +2 -2
- package/eslint.shared.js +1 -1
- package/package.json +3 -3
- package/scripts/make-index.js +7 -1
- package/scripts/test.js +28 -21
- package/tests/backend/port.test.ts +3 -3
- package/tests/backend/store.test.ts +49 -0
- package/tests/common.ts +62 -0
- package/tests/fetch/fetch.ts +6 -0
- package/tests/fetch/run.sh +2 -2
- package/tests/fetch/server.js +3 -3
- package/tests/fs/append.test.ts +5 -5
- package/tests/fs/concurrency.test.ts +52 -0
- package/tests/fs/dir.test.ts +13 -15
- package/tests/fs/directory.test.ts +32 -31
- package/tests/fs/errors.test.ts +151 -31
- package/tests/fs/exists.test.ts +4 -4
- package/tests/fs/links.test.ts +4 -11
- package/tests/fs/open.test.ts +4 -4
- package/tests/fs/permissions.test.ts +15 -15
- package/tests/fs/read.test.ts +9 -9
- package/tests/fs/readFile.test.ts +9 -9
- package/tests/fs/rename.test.ts +8 -8
- package/tests/fs/scaling.test.ts +4 -4
- package/tests/fs/stat.test.ts +20 -20
- package/tests/fs/streams.test.ts +9 -6
- package/tests/fs/tempdir.test.ts +33 -10
- package/tests/fs/times.test.ts +11 -11
- package/tests/fs/truncate.test.ts +6 -6
- package/tests/fs/watch.test.ts +107 -53
- package/tests/fs/write.test.ts +10 -10
- package/tests/fs/xattr.test.ts +3 -3
- package/tests/native/run.sh +3 -0
- package/tests/native/setup.ts +230 -0
- package/types/readable-stream.d.ts +2 -2
package/tests/fs/watch.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
130
|
+
// Rename the file to trigger the events
|
|
103
131
|
await fs.promises.rename(oldFile, newFile);
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
});
|
package/tests/fs/write.test.ts
CHANGED
|
@@ -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
|
|
package/tests/fs/xattr.test.ts
CHANGED
|
@@ -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,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
|
}
|