opfs-mock 2.6.0 → 2.7.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/README.md +1 -1
- package/dist/index.d.mts +21 -2
- package/dist/index.mjs +113 -14
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# opfs-mock
|
|
2
2
|
|
|
3
3
|
In-memory implementation of the [origin private file system](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). Its main utility is for testing OPFS-dependent code in Node.js. It's tested
|
|
4
|
-
on Node.js versions
|
|
4
|
+
on all supported Node.js versions (`22.x`, `24.x`, `25.x`, `26.x`).
|
|
5
5
|
|
|
6
6
|
## Installation
|
|
7
7
|
|
package/dist/index.d.mts
CHANGED
|
@@ -3,20 +3,39 @@
|
|
|
3
3
|
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/queryPermission
|
|
4
4
|
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/requestPermission
|
|
5
5
|
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/remove
|
|
6
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemObserver
|
|
7
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemChangeRecord
|
|
6
8
|
type PermissionHandler = (descriptor?: FileSystemHandlePermissionDescriptor) => Promise<PermissionState>;
|
|
7
9
|
declare global {
|
|
10
|
+
type FileSystemChangeType = 'appeared' | 'disappeared' | 'errored' | 'modified' | 'moved' | 'unknown';
|
|
11
|
+
interface FileSystemChangeRecord {
|
|
12
|
+
changedHandle: FileSystemHandle | FileSystemSyncAccessHandle | null;
|
|
13
|
+
relativePathComponents: string[];
|
|
14
|
+
relativePathMovedFrom: string[] | null;
|
|
15
|
+
root: FileSystemHandle | FileSystemSyncAccessHandle;
|
|
16
|
+
type: FileSystemChangeType;
|
|
17
|
+
}
|
|
18
|
+
type FileSystemObserverCallback = (records: FileSystemChangeRecord[], observer: FileSystemObserver) => void;
|
|
19
|
+
interface FileSystemObserver {
|
|
20
|
+
disconnect(): void;
|
|
21
|
+
observe(handle: FileSystemHandle | FileSystemSyncAccessHandle): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
const FileSystemObserver: {
|
|
24
|
+
prototype: FileSystemObserver;
|
|
25
|
+
new (callback: FileSystemObserverCallback): FileSystemObserver;
|
|
26
|
+
};
|
|
8
27
|
interface FileSystemHandlePermissionDescriptor {
|
|
9
28
|
mode?: 'read' | 'readwrite';
|
|
10
29
|
}
|
|
11
30
|
interface FileSystemFileHandle extends FileSystemHandle {
|
|
12
31
|
queryPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
|
|
13
32
|
requestPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
|
|
14
|
-
remove(): Promise<void>;
|
|
33
|
+
remove(options?: FileSystemRemoveOptions): Promise<void>;
|
|
15
34
|
}
|
|
16
35
|
interface FileSystemDirectoryHandle extends FileSystemHandle {
|
|
17
36
|
queryPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
|
|
18
37
|
requestPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
|
|
19
|
-
remove(): Promise<void>;
|
|
38
|
+
remove(options?: FileSystemRemoveOptions): Promise<void>;
|
|
20
39
|
}
|
|
21
40
|
}
|
|
22
41
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -14,22 +14,94 @@ const getSizeOfDirectory = async (directory) => {
|
|
|
14
14
|
return totalSize;
|
|
15
15
|
};
|
|
16
16
|
//#endregion
|
|
17
|
+
//#region src/observer.ts
|
|
18
|
+
const handleMetadata = /* @__PURE__ */ new WeakMap();
|
|
19
|
+
const observers = /* @__PURE__ */ new Set();
|
|
20
|
+
const isPathWithin = (path, rootPath) => {
|
|
21
|
+
if (path.length < rootPath.length) return false;
|
|
22
|
+
return rootPath.every((component, index) => path[index] === component);
|
|
23
|
+
};
|
|
24
|
+
const relativePath = (path, rootPath) => {
|
|
25
|
+
return path.slice(rootPath.length);
|
|
26
|
+
};
|
|
27
|
+
const setFileSystemHandleMetadata = (handle, path) => {
|
|
28
|
+
handleMetadata.set(handle, { path });
|
|
29
|
+
};
|
|
30
|
+
const notifyFileSystemObservers = (type, changedHandle, path, relativePathMovedFrom = null) => {
|
|
31
|
+
for (const observer of observers) observer.queueRecord(type, changedHandle, path, relativePathMovedFrom);
|
|
32
|
+
};
|
|
33
|
+
var FileSystemObserver = class {
|
|
34
|
+
#callback;
|
|
35
|
+
#observations = /* @__PURE__ */ new Map();
|
|
36
|
+
#records = [];
|
|
37
|
+
#scheduled = false;
|
|
38
|
+
constructor(callback) {
|
|
39
|
+
if (typeof callback !== "function") throw new TypeError("FileSystemObserver callback must be a function");
|
|
40
|
+
this.#callback = callback;
|
|
41
|
+
}
|
|
42
|
+
async observe(handle) {
|
|
43
|
+
const metadata = handleMetadata.get(handle);
|
|
44
|
+
if (!metadata) throw new DOMException("The supplied handle is not managed by opfs-mock.", "NotFoundError");
|
|
45
|
+
this.#observations.set(handle, {
|
|
46
|
+
root: handle,
|
|
47
|
+
path: metadata.path
|
|
48
|
+
});
|
|
49
|
+
observers.add(this);
|
|
50
|
+
}
|
|
51
|
+
disconnect() {
|
|
52
|
+
this.#observations.clear();
|
|
53
|
+
this.#records = [];
|
|
54
|
+
this.#scheduled = false;
|
|
55
|
+
observers.delete(this);
|
|
56
|
+
}
|
|
57
|
+
queueRecord(type, changedHandle, path, relativePathMovedFrom) {
|
|
58
|
+
for (const observation of this.#observations.values()) {
|
|
59
|
+
if (!isPathWithin(path, observation.path)) continue;
|
|
60
|
+
this.#records.push({
|
|
61
|
+
changedHandle: type === "disappeared" || type === "errored" || type === "unknown" ? null : changedHandle,
|
|
62
|
+
relativePathComponents: relativePath(path, observation.path),
|
|
63
|
+
relativePathMovedFrom,
|
|
64
|
+
root: observation.root,
|
|
65
|
+
type
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (this.#records.length > 0 && !this.#scheduled) {
|
|
69
|
+
this.#scheduled = true;
|
|
70
|
+
queueMicrotask(() => {
|
|
71
|
+
this.#scheduled = false;
|
|
72
|
+
const records = this.#records;
|
|
73
|
+
this.#records = [];
|
|
74
|
+
if (records.length > 0) this.#callback(records, this);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const installFileSystemObserver = () => {
|
|
80
|
+
Object.defineProperty(globalThis, "FileSystemObserver", {
|
|
81
|
+
configurable: true,
|
|
82
|
+
value: FileSystemObserver,
|
|
83
|
+
writable: true
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
17
87
|
//#region src/opfs.ts
|
|
18
88
|
const isObject = (v) => typeof v === "object" && v !== null;
|
|
19
89
|
const isLegacyWriteParams = (v) => isObject(v) && !("type" in v) && "data" in v;
|
|
20
|
-
const
|
|
90
|
+
const isArrayBuffer = (v) => Object.prototype.toString.call(v) === "[object ArrayBuffer]";
|
|
91
|
+
const fileSystemFileHandleFactory = (name, fileData, path, exists, onRemove, permissions) => {
|
|
21
92
|
const checkPermission = async (mode = "read") => {
|
|
22
93
|
if (await (permissions?.queryPermission?.({ mode }) ?? Promise.resolve("granted")) !== "granted") throw new DOMException("Permission denied", "NotAllowedError");
|
|
23
94
|
};
|
|
24
|
-
|
|
95
|
+
const handle = {
|
|
25
96
|
kind: "file",
|
|
26
97
|
name,
|
|
27
98
|
queryPermission: permissions?.queryPermission ?? (async () => "granted"),
|
|
28
99
|
requestPermission: permissions?.requestPermission ?? (async () => "granted"),
|
|
29
|
-
remove: async () => {
|
|
100
|
+
remove: async (_options) => {
|
|
30
101
|
await checkPermission("readwrite");
|
|
31
102
|
if (!exists()) throw new DOMException("A requested file or directory could not be found at the time an operation was processed.", "NotFoundError");
|
|
32
103
|
onRemove?.();
|
|
104
|
+
notifyFileSystemObservers("disappeared", null, path);
|
|
33
105
|
},
|
|
34
106
|
isSameEntry: async function(other) {
|
|
35
107
|
return other === this;
|
|
@@ -89,7 +161,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
|
|
|
89
161
|
const ab = await chunk.arrayBuffer();
|
|
90
162
|
encoded = new Uint8Array(ab);
|
|
91
163
|
} else if (ArrayBuffer.isView(chunk)) encoded = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
92
|
-
else if (chunk
|
|
164
|
+
else if (isArrayBuffer(chunk)) encoded = new Uint8Array(chunk);
|
|
93
165
|
else if (isLegacyWriteParams(chunk)) {
|
|
94
166
|
const wp = chunk;
|
|
95
167
|
if (wp.position !== void 0 && wp.position !== null) {
|
|
@@ -103,7 +175,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
|
|
|
103
175
|
const ab = await data.arrayBuffer();
|
|
104
176
|
encoded = new Uint8Array(ab);
|
|
105
177
|
} else if (ArrayBuffer.isView(data)) encoded = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
106
|
-
else if (data
|
|
178
|
+
else if (isArrayBuffer(data)) encoded = new Uint8Array(data);
|
|
107
179
|
else throw new TypeError("Invalid data in WriteParams");
|
|
108
180
|
} else throw new TypeError("Invalid data type written to the file. Data must be of type FileSystemWriteChunkType.");
|
|
109
181
|
const requiredSize = cursorPosition + encoded.length;
|
|
@@ -121,6 +193,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
|
|
|
121
193
|
isClosed = true;
|
|
122
194
|
fileData.content = content;
|
|
123
195
|
fileData.lastModified = Date.now();
|
|
196
|
+
notifyFileSystemObservers("modified", handle, path);
|
|
124
197
|
};
|
|
125
198
|
const doAbort = async (reason) => {
|
|
126
199
|
if (isAborted) return;
|
|
@@ -161,7 +234,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
|
|
|
161
234
|
if (fileData.locked) throw new DOMException("A sync access handle is already open for this file", "InvalidStateError");
|
|
162
235
|
fileData.locked = true;
|
|
163
236
|
let closed = false;
|
|
164
|
-
|
|
237
|
+
const syncHandle = {
|
|
165
238
|
getSize: () => {
|
|
166
239
|
if (closed) throw new DOMException("The access handle is closed", "InvalidStateError");
|
|
167
240
|
return fileData.content.byteLength;
|
|
@@ -190,6 +263,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
|
|
|
190
263
|
if (data instanceof DataView) for (let i = 0; i < data.byteLength; i++) fileData.content[at + i] = data.getUint8(i);
|
|
191
264
|
else fileData.content.set(data, at);
|
|
192
265
|
fileData.lastModified = Date.now();
|
|
266
|
+
notifyFileSystemObservers("modified", syncHandle, path);
|
|
193
267
|
return writeLength;
|
|
194
268
|
},
|
|
195
269
|
truncate: (size) => {
|
|
@@ -201,6 +275,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
|
|
|
201
275
|
fileData.content = newBuffer;
|
|
202
276
|
}
|
|
203
277
|
fileData.lastModified = Date.now();
|
|
278
|
+
notifyFileSystemObservers("modified", syncHandle, path);
|
|
204
279
|
},
|
|
205
280
|
flush: async () => {
|
|
206
281
|
if (closed) throw new DOMException("The access handle is closed", "InvalidStateError");
|
|
@@ -210,10 +285,14 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
|
|
|
210
285
|
fileData.locked = false;
|
|
211
286
|
}
|
|
212
287
|
};
|
|
288
|
+
setFileSystemHandleMetadata(syncHandle, path);
|
|
289
|
+
return syncHandle;
|
|
213
290
|
}
|
|
214
291
|
};
|
|
292
|
+
setFileSystemHandleMetadata(handle, path);
|
|
293
|
+
return handle;
|
|
215
294
|
};
|
|
216
|
-
const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
295
|
+
const fileSystemDirectoryHandleFactory = (name, permissions, onRemove, path = []) => {
|
|
217
296
|
const files = /* @__PURE__ */ new Map();
|
|
218
297
|
const directories = /* @__PURE__ */ new Map();
|
|
219
298
|
const getJoinedMaps = () => {
|
|
@@ -227,11 +306,20 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
|
227
306
|
name,
|
|
228
307
|
queryPermission: permissions?.queryPermission ?? (async () => "granted"),
|
|
229
308
|
requestPermission: permissions?.requestPermission ?? (async () => "granted"),
|
|
230
|
-
remove: async () => {
|
|
309
|
+
remove: async (options) => {
|
|
231
310
|
await checkPermission("readwrite");
|
|
232
|
-
if (!onRemove)
|
|
233
|
-
|
|
311
|
+
if (!onRemove) {
|
|
312
|
+
if (options?.recursive) {
|
|
313
|
+
files.clear();
|
|
314
|
+
directories.clear();
|
|
315
|
+
notifyFileSystemObservers("modified", handle, path);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
throw new DOMException("The root directory cannot be removed.", "InvalidModificationError");
|
|
319
|
+
}
|
|
320
|
+
if (!options?.recursive) for await (const _ of handle.values()) throw new DOMException("The directory is not empty", "InvalidModificationError");
|
|
234
321
|
onRemove();
|
|
322
|
+
notifyFileSystemObservers("disappeared", null, path);
|
|
235
323
|
},
|
|
236
324
|
isSameEntry: async function(other) {
|
|
237
325
|
return other === this;
|
|
@@ -244,7 +332,8 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
|
244
332
|
content: new Uint8Array(),
|
|
245
333
|
lastModified: Date.now(),
|
|
246
334
|
id: Symbol("file")
|
|
247
|
-
}, () => files.has(fileName), () => files.delete(fileName), permissions));
|
|
335
|
+
}, [...path, fileName], () => files.has(fileName), () => files.delete(fileName), permissions));
|
|
336
|
+
notifyFileSystemObservers("appeared", files.get(fileName) ?? null, [...path, fileName]);
|
|
248
337
|
} else await checkPermission("read");
|
|
249
338
|
const fileHandle = files.get(fileName);
|
|
250
339
|
if (!fileHandle) throw new DOMException(`File not found: ${fileName}`, "NotFoundError");
|
|
@@ -254,8 +343,9 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
|
254
343
|
if (files.has(dirName)) throw new DOMException(`A file with the same name exists: ${dirName}`, "TypeMismatchError");
|
|
255
344
|
if (!directories.has(dirName) && options?.create) {
|
|
256
345
|
await checkPermission("readwrite");
|
|
257
|
-
const dir = fileSystemDirectoryHandleFactory(dirName, permissions, () => directories.delete(dirName));
|
|
346
|
+
const dir = fileSystemDirectoryHandleFactory(dirName, permissions, () => directories.delete(dirName), [...path, dirName]);
|
|
258
347
|
directories.set(dirName, dir);
|
|
348
|
+
notifyFileSystemObservers("appeared", dir, [...path, dirName]);
|
|
259
349
|
} else await checkPermission("read");
|
|
260
350
|
const directoryHandle = directories.get(dirName);
|
|
261
351
|
if (!directoryHandle) throw new DOMException(`Directory not found: ${dirName}`, "NotFoundError");
|
|
@@ -265,12 +355,14 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
|
265
355
|
await checkPermission("readwrite");
|
|
266
356
|
if (files.has(entryName)) {
|
|
267
357
|
files.delete(entryName);
|
|
358
|
+
notifyFileSystemObservers("disappeared", null, [...path, entryName]);
|
|
268
359
|
return;
|
|
269
360
|
}
|
|
270
361
|
const dir = directories.get(entryName);
|
|
271
362
|
if (dir) {
|
|
272
363
|
if (!options?.recursive) for await (const _ of dir.values()) throw new DOMException("The directory is not empty", "InvalidModificationError");
|
|
273
364
|
directories.delete(entryName);
|
|
365
|
+
notifyFileSystemObservers("disappeared", null, [...path, entryName]);
|
|
274
366
|
return;
|
|
275
367
|
}
|
|
276
368
|
throw new DOMException(`No such file or directory: ${entryName}`, "NotFoundError");
|
|
@@ -283,7 +375,8 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
|
283
375
|
},
|
|
284
376
|
entries: async function* () {
|
|
285
377
|
await checkPermission("read");
|
|
286
|
-
|
|
378
|
+
const joinedMaps = getJoinedMaps();
|
|
379
|
+
for (const [n, h] of joinedMaps.entries()) yield [n, h];
|
|
287
380
|
},
|
|
288
381
|
keys: async function* () {
|
|
289
382
|
await checkPermission("read");
|
|
@@ -291,7 +384,8 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
|
291
384
|
},
|
|
292
385
|
values: async function* () {
|
|
293
386
|
await checkPermission("read");
|
|
294
|
-
|
|
387
|
+
const joinedMaps = getJoinedMaps();
|
|
388
|
+
for (const h of joinedMaps.values()) yield h;
|
|
295
389
|
},
|
|
296
390
|
resolve: async function(possibleDescendant) {
|
|
297
391
|
await checkPermission("read");
|
|
@@ -308,6 +402,7 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
|
|
|
308
402
|
return traverseDirectory(this, possibleDescendant);
|
|
309
403
|
}
|
|
310
404
|
};
|
|
405
|
+
setFileSystemHandleMetadata(handle, path);
|
|
311
406
|
return handle;
|
|
312
407
|
};
|
|
313
408
|
//#endregion
|
|
@@ -337,13 +432,16 @@ const storageFactory = ({ usage = 0, quota = 1024 ** 3, queryPermission, request
|
|
|
337
432
|
};
|
|
338
433
|
const mockOPFS = () => {
|
|
339
434
|
if (!("navigator" in globalThis)) Object.defineProperty(globalThis, "navigator", {
|
|
435
|
+
configurable: true,
|
|
340
436
|
value: {},
|
|
341
437
|
writable: true
|
|
342
438
|
});
|
|
343
439
|
if (!globalThis.navigator.storage) Object.defineProperty(globalThis.navigator, "storage", {
|
|
440
|
+
configurable: true,
|
|
344
441
|
value: storageFactory(),
|
|
345
442
|
writable: true
|
|
346
443
|
});
|
|
444
|
+
installFileSystemObserver();
|
|
347
445
|
};
|
|
348
446
|
const resetMockOPFS = (options = {}) => {
|
|
349
447
|
const root = fileSystemDirectoryHandleFactory("root", {
|
|
@@ -351,6 +449,7 @@ const resetMockOPFS = (options = {}) => {
|
|
|
351
449
|
requestPermission: options.requestPermission
|
|
352
450
|
});
|
|
353
451
|
Object.defineProperty(globalThis.navigator.storage, "getDirectory", {
|
|
452
|
+
configurable: true,
|
|
354
453
|
value: () => root,
|
|
355
454
|
writable: true
|
|
356
455
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opfs-mock",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Mock all origin private file system APIs for your Jest or Vitest tests",
|
|
6
6
|
"author": "Jure Rotar <hello@jurerotar.com>",
|
|
@@ -60,14 +60,14 @@
|
|
|
60
60
|
"release": "npm publish --access public"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@biomejs/biome": "2.4.
|
|
64
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
65
|
-
"@vitest/coverage-v8": "4.
|
|
63
|
+
"@biomejs/biome": "2.4.15",
|
|
64
|
+
"@typescript/native-preview": "7.0.0-dev.20260522.1",
|
|
65
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
66
66
|
"@web-std/file": "3.0.3",
|
|
67
|
-
"happy-dom": "20.
|
|
68
|
-
"jsdom": "
|
|
69
|
-
"tsdown": "0.
|
|
70
|
-
"typescript": "
|
|
71
|
-
"vitest": "4.
|
|
67
|
+
"happy-dom": "20.9.0",
|
|
68
|
+
"jsdom": "29.1.1",
|
|
69
|
+
"tsdown": "0.22.0",
|
|
70
|
+
"typescript": "6.0.3",
|
|
71
|
+
"vitest": "4.1.7"
|
|
72
72
|
}
|
|
73
73
|
}
|