opfs-mock 2.0.0 → 2.1.1
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 +12 -3
- package/dist/index.js +134 -75
- package/package.json +15 -6
package/README.md
CHANGED
|
@@ -1,6 +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 20-23.
|
|
4
5
|
|
|
5
6
|
## Installation
|
|
6
7
|
|
|
@@ -8,6 +9,14 @@ This is a pure JS in-memory implementation of the [origin private file system](h
|
|
|
8
9
|
npm install -save-dev opfs-mock
|
|
9
10
|
```
|
|
10
11
|
|
|
12
|
+
## Limitations
|
|
13
|
+
|
|
14
|
+
- `opfs-mock` requires **Node.js v20.0.0** or higher. It can work on Node v18.0.0 with either `--experimental-fetch` flag enabled or a global
|
|
15
|
+
`File` polyfill.
|
|
16
|
+
|
|
17
|
+
- `jsdom` testing environment is missing `File.prototype.text()` method, which is required for reading opfs files. Ensure your opfs-dependant tests are ran
|
|
18
|
+
in `node` or `happy-dom` environment.
|
|
19
|
+
|
|
11
20
|
## Usage
|
|
12
21
|
|
|
13
22
|
It replicates the behavior of origin private file system, except data is not persisted to disk.
|
|
@@ -31,14 +40,14 @@ test('Your test', async () => {
|
|
|
31
40
|
});
|
|
32
41
|
```
|
|
33
42
|
|
|
34
|
-
`storageFactory` has
|
|
43
|
+
`storageFactory` has `quota` and `usage` values set to `1024 ** 3 (1 GB)` and `0` respectively. When calling `storage.estimate()`, `usage` is dynamically calculated by summing the predefined usage value and any additional computed storage consumption.
|
|
35
44
|
In case you need specific values, you can pass both as arguments to `storageFactory`.
|
|
36
45
|
|
|
37
46
|
```ts
|
|
38
47
|
import { storageFactory } from "opfs-mock";
|
|
39
48
|
|
|
40
49
|
test('Your test', async () => {
|
|
41
|
-
const storage = await storageFactory({ quota: 1_000_000,
|
|
50
|
+
const storage = await storageFactory({ quota: 1_000_000, usage: 1_000 });
|
|
42
51
|
const root = await storage.getDirectory();
|
|
43
52
|
const directoryHandle = await root.getFileHandle('test-file.txt', { create: true });
|
|
44
53
|
// rest of your test
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
// src/utils.ts
|
|
2
|
+
var isFileHandle = (handle) => {
|
|
3
|
+
return handle.kind === "file";
|
|
4
|
+
};
|
|
5
|
+
var isDirectoryHandle = (handle) => {
|
|
6
|
+
return handle.kind === "directory";
|
|
7
|
+
};
|
|
8
|
+
var getSizeOfDirectory = async (directory) => {
|
|
9
|
+
let totalSize = 0;
|
|
10
|
+
for await (const handle of directory.values()) {
|
|
11
|
+
if (isFileHandle(handle)) {
|
|
12
|
+
const file = await handle.getFile();
|
|
13
|
+
totalSize += file.size;
|
|
14
|
+
} else if (isDirectoryHandle(handle)) {
|
|
15
|
+
totalSize += await getSizeOfDirectory(handle);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return totalSize;
|
|
19
|
+
};
|
|
20
|
+
|
|
1
21
|
// src/opfs.ts
|
|
2
22
|
var fileSystemFileHandleFactory = (name, fileData) => {
|
|
3
23
|
return {
|
|
@@ -7,18 +27,28 @@ var fileSystemFileHandleFactory = (name, fileData) => {
|
|
|
7
27
|
return other.name === name && other.kind === "file";
|
|
8
28
|
},
|
|
9
29
|
getFile: async () => new File([fileData.content], name),
|
|
10
|
-
createWritable: async (
|
|
11
|
-
|
|
12
|
-
let
|
|
13
|
-
let
|
|
14
|
-
let
|
|
15
|
-
|
|
30
|
+
createWritable: async (options) => {
|
|
31
|
+
const keepExistingData = options?.keepExistingData;
|
|
32
|
+
let abortReason = "";
|
|
33
|
+
let isAborted = false;
|
|
34
|
+
let isClosed = false;
|
|
35
|
+
let content = keepExistingData ? fileData.content : "";
|
|
36
|
+
let cursorPosition = keepExistingData ? fileData.content.length : 0;
|
|
16
37
|
const writableStream = new WritableStream({
|
|
17
|
-
write:
|
|
18
|
-
|
|
19
|
-
|
|
38
|
+
write: () => {
|
|
39
|
+
},
|
|
40
|
+
close: () => {
|
|
41
|
+
},
|
|
42
|
+
abort: () => {
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return Object.assign(writableStream, {
|
|
46
|
+
getWriter: () => writableStream.getWriter(),
|
|
47
|
+
write: async function(chunk) {
|
|
48
|
+
if (isAborted) {
|
|
49
|
+
throw new Error(abortReason);
|
|
20
50
|
}
|
|
21
|
-
if (
|
|
51
|
+
if (isClosed) {
|
|
22
52
|
throw new TypeError("Cannot write to a CLOSED writable stream");
|
|
23
53
|
}
|
|
24
54
|
if (chunk === void 0) {
|
|
@@ -55,78 +85,95 @@ var fileSystemFileHandleFactory = (name, fileData) => {
|
|
|
55
85
|
} else {
|
|
56
86
|
throw new TypeError("Invalid data type written to the file. Data must be of type FileSystemWriteChunkType.");
|
|
57
87
|
}
|
|
58
|
-
|
|
88
|
+
content = content.slice(0, cursorPosition) + chunkText + content.slice(cursorPosition + chunkText.length);
|
|
59
89
|
cursorPosition += chunkText.length;
|
|
60
90
|
},
|
|
61
|
-
close: async ()
|
|
62
|
-
if (
|
|
63
|
-
throw new
|
|
91
|
+
close: async function() {
|
|
92
|
+
if (isClosed) {
|
|
93
|
+
throw new TypeError("Cannot close a CLOSED writable stream");
|
|
64
94
|
}
|
|
65
|
-
|
|
66
|
-
|
|
95
|
+
if (isAborted) {
|
|
96
|
+
throw new TypeError("Cannot close a ERRORED writable stream");
|
|
97
|
+
}
|
|
98
|
+
isClosed = true;
|
|
99
|
+
fileData.content = content;
|
|
67
100
|
},
|
|
68
|
-
abort: (reason)
|
|
69
|
-
if (
|
|
70
|
-
return
|
|
101
|
+
abort: async function(reason) {
|
|
102
|
+
if (isAborted) {
|
|
103
|
+
return;
|
|
71
104
|
}
|
|
72
|
-
if (
|
|
73
|
-
|
|
105
|
+
if (reason && !abortReason) {
|
|
106
|
+
abortReason = reason;
|
|
74
107
|
}
|
|
75
|
-
|
|
76
|
-
return Promise.resolve(
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const writer = writableStream.getWriter();
|
|
80
|
-
return Object.assign(writer, {
|
|
81
|
-
locked: false,
|
|
82
|
-
truncate: async (size) => {
|
|
108
|
+
isAborted = true;
|
|
109
|
+
return Promise.resolve(void 0);
|
|
110
|
+
},
|
|
111
|
+
truncate: async function(size) {
|
|
83
112
|
if (size < 0) {
|
|
84
113
|
throw new DOMException("Invalid truncate size", "IndexSizeError");
|
|
85
114
|
}
|
|
86
|
-
if (size <
|
|
87
|
-
|
|
115
|
+
if (size < content.length) {
|
|
116
|
+
content = content.slice(0, size);
|
|
88
117
|
} else {
|
|
89
|
-
|
|
118
|
+
content = content.padEnd(size, "\0");
|
|
90
119
|
}
|
|
91
120
|
cursorPosition = Math.min(cursorPosition, size);
|
|
92
121
|
},
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (position < 0 || position > newContent.length) {
|
|
122
|
+
seek: async function(position) {
|
|
123
|
+
if (position < 0 || position > content.length) {
|
|
96
124
|
throw new DOMException("Invalid seek position", "IndexSizeError");
|
|
97
125
|
}
|
|
98
126
|
cursorPosition = position;
|
|
99
127
|
}
|
|
100
128
|
});
|
|
101
129
|
},
|
|
102
|
-
createSyncAccessHandle: async () =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
createSyncAccessHandle: async () => {
|
|
131
|
+
let closed = false;
|
|
132
|
+
return {
|
|
133
|
+
getSize: () => {
|
|
134
|
+
if (closed) {
|
|
135
|
+
throw new DOMException("InvalidStateError", "The access handle is closed");
|
|
136
|
+
}
|
|
137
|
+
return fileData.content.length;
|
|
138
|
+
},
|
|
139
|
+
read: (buffer, { at = 0 } = {}) => {
|
|
140
|
+
if (closed) {
|
|
141
|
+
throw new DOMException("InvalidStateError", "The access handle is closed");
|
|
142
|
+
}
|
|
143
|
+
const text = new TextEncoder().encode(fileData.content);
|
|
144
|
+
const bytesRead = Math.min(buffer.length, text.length - at);
|
|
145
|
+
buffer.set(text.subarray(at, at + bytesRead));
|
|
146
|
+
return bytesRead;
|
|
147
|
+
},
|
|
148
|
+
write: (data, options) => {
|
|
149
|
+
const at = options?.at ?? 0;
|
|
150
|
+
if (closed) {
|
|
151
|
+
throw new DOMException("InvalidStateError", "The access handle is closed");
|
|
152
|
+
}
|
|
153
|
+
const newContent = new TextDecoder().decode(data);
|
|
154
|
+
if (at < fileData.content.length) {
|
|
155
|
+
fileData.content = fileData.content.slice(0, at) + newContent + fileData.content.slice(at + newContent.length);
|
|
156
|
+
} else {
|
|
157
|
+
fileData.content += newContent;
|
|
158
|
+
}
|
|
159
|
+
return data.byteLength;
|
|
160
|
+
},
|
|
161
|
+
truncate: (size) => {
|
|
162
|
+
if (closed) {
|
|
163
|
+
throw new DOMException("InvalidStateError", "The access handle is closed");
|
|
164
|
+
}
|
|
165
|
+
fileData.content = fileData.content.slice(0, size);
|
|
166
|
+
},
|
|
167
|
+
flush: async () => {
|
|
168
|
+
if (closed) {
|
|
169
|
+
throw new DOMException("InvalidStateError", "The access handle is closed");
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
close: async () => {
|
|
173
|
+
closed = true;
|
|
117
174
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// Flush is a no-op in memory
|
|
121
|
-
flush: async () => {
|
|
122
|
-
},
|
|
123
|
-
// Close is a no-op in memory
|
|
124
|
-
close: async () => {
|
|
125
|
-
},
|
|
126
|
-
truncate: async (size) => {
|
|
127
|
-
fileData.content = fileData.content.slice(0, size);
|
|
128
|
-
}
|
|
129
|
-
})
|
|
175
|
+
};
|
|
176
|
+
}
|
|
130
177
|
};
|
|
131
178
|
};
|
|
132
179
|
var fileSystemDirectoryHandleFactory = (name) => {
|
|
@@ -147,7 +194,7 @@ var fileSystemDirectoryHandleFactory = (name) => {
|
|
|
147
194
|
}
|
|
148
195
|
const fileHandle = files.get(fileName);
|
|
149
196
|
if (!fileHandle) {
|
|
150
|
-
throw new
|
|
197
|
+
throw new DOMException(`File not found: ${fileName}`, "NotFoundError");
|
|
151
198
|
}
|
|
152
199
|
return fileHandle;
|
|
153
200
|
},
|
|
@@ -157,18 +204,29 @@ var fileSystemDirectoryHandleFactory = (name) => {
|
|
|
157
204
|
}
|
|
158
205
|
const directoryHandle = directories.get(dirName);
|
|
159
206
|
if (!directoryHandle) {
|
|
160
|
-
throw new
|
|
207
|
+
throw new DOMException(`Directory not found: ${dirName}`, "NotFoundError");
|
|
161
208
|
}
|
|
162
209
|
return directoryHandle;
|
|
163
210
|
},
|
|
164
|
-
removeEntry: async (entryName) => {
|
|
211
|
+
removeEntry: async (entryName, options) => {
|
|
165
212
|
if (files.has(entryName)) {
|
|
166
213
|
files.delete(entryName);
|
|
167
214
|
} else if (directories.has(entryName)) {
|
|
168
|
-
|
|
215
|
+
if (options?.recursive) {
|
|
216
|
+
directories.delete(entryName);
|
|
217
|
+
} else {
|
|
218
|
+
throw new DOMException(`Failed to remove directory: $1${entryName}`, "InvalidModificationError");
|
|
219
|
+
}
|
|
169
220
|
} else {
|
|
170
|
-
throw new
|
|
221
|
+
throw new DOMException(`No such file or directory: $1${entryName}`, "NotFoundError");
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
[Symbol.asyncIterator]: async function* () {
|
|
225
|
+
const entries = getJoinedMaps();
|
|
226
|
+
for (const [name2, handle] of entries) {
|
|
227
|
+
yield [name2, handle];
|
|
171
228
|
}
|
|
229
|
+
return void 0;
|
|
172
230
|
},
|
|
173
231
|
entries: async function* () {
|
|
174
232
|
const joinedMaps = getJoinedMaps();
|
|
@@ -182,19 +240,18 @@ var fileSystemDirectoryHandleFactory = (name) => {
|
|
|
182
240
|
const joinedMaps = getJoinedMaps();
|
|
183
241
|
yield* joinedMaps.values();
|
|
184
242
|
},
|
|
185
|
-
resolve: async (possibleDescendant)
|
|
243
|
+
resolve: async function(possibleDescendant) {
|
|
186
244
|
const traverseDirectory = async (directory, target, path = []) => {
|
|
187
245
|
if (await directory.isSameEntry(target)) {
|
|
188
246
|
return path;
|
|
189
247
|
}
|
|
190
248
|
for await (const [name2, handle] of directory.entries()) {
|
|
191
|
-
if (handle
|
|
192
|
-
const
|
|
193
|
-
const result = await traverseDirectory(subDirectory, target, [...path, name2]);
|
|
249
|
+
if (isDirectoryHandle(handle)) {
|
|
250
|
+
const result = await traverseDirectory(handle, target, [...path, name2]);
|
|
194
251
|
if (result) {
|
|
195
252
|
return result;
|
|
196
253
|
}
|
|
197
|
-
} else if (handle
|
|
254
|
+
} else if (isFileHandle(handle)) {
|
|
198
255
|
if (await handle.isSameEntry(target)) {
|
|
199
256
|
return [...path, name2];
|
|
200
257
|
}
|
|
@@ -202,18 +259,20 @@ var fileSystemDirectoryHandleFactory = (name) => {
|
|
|
202
259
|
}
|
|
203
260
|
return null;
|
|
204
261
|
};
|
|
205
|
-
return traverseDirectory(
|
|
262
|
+
return traverseDirectory(this, possibleDescendant);
|
|
206
263
|
}
|
|
207
264
|
};
|
|
208
265
|
};
|
|
209
266
|
|
|
210
267
|
// src/index.ts
|
|
211
|
-
var storageFactory = ({ usage = 0, quota =
|
|
268
|
+
var storageFactory = ({ usage = 0, quota = 1024 ** 3 } = {}) => {
|
|
212
269
|
const root = fileSystemDirectoryHandleFactory("root");
|
|
213
270
|
return {
|
|
214
271
|
estimate: async () => {
|
|
272
|
+
const defaultUsage = usage;
|
|
273
|
+
const calculatedUsage = await getSizeOfDirectory(root);
|
|
215
274
|
return {
|
|
216
|
-
usage,
|
|
275
|
+
usage: defaultUsage + calculatedUsage,
|
|
217
276
|
quota
|
|
218
277
|
};
|
|
219
278
|
},
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opfs-mock",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
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>",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"homepage": "https://github.com/jurerotar/opfs-mock#
|
|
8
|
+
"homepage": "https://github.com/jurerotar/opfs-mock#readme",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/jurerotar/opfs-mock.git"
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"main": "dist/index.js",
|
|
27
27
|
"module": "dist/index.js",
|
|
28
28
|
"files": ["dist"],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20.0.0"
|
|
31
|
+
},
|
|
29
32
|
"scripts": {
|
|
30
33
|
"dev": "tsup --watch",
|
|
31
34
|
"build": "tsup",
|
|
@@ -34,14 +37,20 @@
|
|
|
34
37
|
"format:check": "npx @biomejs/biome format",
|
|
35
38
|
"format": "npx @biomejs/biome format --write",
|
|
36
39
|
"type-check": "tsc --noEmit",
|
|
37
|
-
"test": "
|
|
40
|
+
"test": "npm run test:node && npm run test:happy-dom",
|
|
41
|
+
"test:node": "vitest --environment=node",
|
|
42
|
+
"test:jsdom": "vitest --environment=jsdom",
|
|
43
|
+
"test:happy-dom": "vitest --environment=happy-dom",
|
|
38
44
|
"prepublishOnly": "npm run build",
|
|
39
45
|
"release": "npm publish --access public"
|
|
40
46
|
},
|
|
41
47
|
"devDependencies": {
|
|
42
48
|
"@biomejs/biome": "1.9.4",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
49
|
+
"@web-std/file": "3.0.3",
|
|
50
|
+
"happy-dom": "17.4.4",
|
|
51
|
+
"jsdom": "26.0.0",
|
|
52
|
+
"tsup": "8.4.0",
|
|
53
|
+
"typescript": "5.8.2",
|
|
54
|
+
"vitest": "3.0.9"
|
|
46
55
|
}
|
|
47
56
|
}
|