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.
Files changed (3) hide show
  1. package/README.md +12 -3
  2. package/dist/index.js +134 -75
  3. package/package.json +15 -6
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # opfs-mock
2
2
 
3
- This is a pure JS 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.
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 predefined `quota` and `estimate` values set to `0`, which is fine if you're not using these properties.
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, estimate: 1_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 (_options) => {
11
- let newContent = "";
12
- let cursorPosition = 0;
13
- let aborted = false;
14
- let closed = false;
15
- const locked = false;
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: async (chunk) => {
18
- if (aborted) {
19
- throw new DOMException("Write operation aborted", "AbortError");
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 (closed) {
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
- newContent = newContent.slice(0, cursorPosition) + chunkText + newContent.slice(cursorPosition + chunkText.length);
88
+ content = content.slice(0, cursorPosition) + chunkText + content.slice(cursorPosition + chunkText.length);
59
89
  cursorPosition += chunkText.length;
60
90
  },
61
- close: async () => {
62
- if (aborted) {
63
- throw new DOMException("Stream has been aborted", "AbortError");
91
+ close: async function() {
92
+ if (isClosed) {
93
+ throw new TypeError("Cannot close a CLOSED writable stream");
64
94
  }
65
- closed = true;
66
- fileData.content = newContent;
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 (aborted) {
70
- return Promise.reject(new TypeError("Cannot abort an already aborted writable stream"));
101
+ abort: async function(reason) {
102
+ if (isAborted) {
103
+ return;
71
104
  }
72
- if (locked) {
73
- return Promise.reject(new TypeError("Cannot abort a locked writable stream"));
105
+ if (reason && !abortReason) {
106
+ abortReason = reason;
74
107
  }
75
- aborted = true;
76
- return Promise.resolve(reason);
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 < newContent.length) {
87
- newContent = newContent.slice(0, size);
115
+ if (size < content.length) {
116
+ content = content.slice(0, size);
88
117
  } else {
89
- newContent = newContent.padEnd(size, "\0");
118
+ content = content.padEnd(size, "\0");
90
119
  }
91
120
  cursorPosition = Math.min(cursorPosition, size);
92
121
  },
93
- getWriter: () => writer,
94
- seek: async (position) => {
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
- getSize: () => fileData.content.length,
104
- read: (buffer, { at = 0 } = {}) => {
105
- const text = new TextEncoder().encode(fileData.content);
106
- const bytesRead = Math.min(buffer.length, text.length - at);
107
- buffer.set(text.subarray(at, at + bytesRead));
108
- return bytesRead;
109
- },
110
- write: (data, { at = 0 } = {}) => {
111
- const newContent = new TextDecoder().decode(data);
112
- const originalLength = fileData.content.length;
113
- if (at < originalLength) {
114
- fileData.content = fileData.content.slice(0, at) + newContent + fileData.content.slice(at + newContent.length);
115
- } else {
116
- fileData.content += newContent;
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
- return data.byteLength;
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 Error(`File not found: ${fileName}`);
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 Error(`Directory not found: ${dirName}`);
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
- directories.delete(entryName);
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 Error(`Entry not found: ${entryName}`);
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.kind === "directory") {
192
- const subDirectory = handle;
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.kind === "file") {
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(void 0, possibleDescendant);
262
+ return traverseDirectory(this, possibleDescendant);
206
263
  }
207
264
  };
208
265
  };
209
266
 
210
267
  // src/index.ts
211
- var storageFactory = ({ usage = 0, quota = 0 } = {}) => {
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.0.0",
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#README",
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": "vitest",
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
- "tsup": "8.3.6",
44
- "typescript": "5.7.3",
45
- "vitest": "3.0.6"
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
  }