UNPKG

opfs-mock

Version:

Mock all origin private file system APIs for your Jest or Vitest tests

330 lines (327 loc) 12.6 kB
//#region src/utils.ts const isFileHandle = (handle) => { return handle.kind === "file"; }; const isDirectoryHandle = (handle) => { return handle.kind === "directory"; }; const getSizeOfDirectory = async (directory) => { let totalSize = 0; for await (const handle of directory.values()) if (isFileHandle(handle)) { const file = await handle.getFile(); totalSize += file.size; } else if (isDirectoryHandle(handle)) totalSize += await getSizeOfDirectory(handle); return totalSize; }; //#endregion //#region src/opfs.ts const isObject = (v) => typeof v === "object" && v !== null; const isLegacyWriteParams = (v) => isObject(v) && !("type" in v) && "data" in v; const fileSystemFileHandleFactory = (name, fileData, exists) => { return { kind: "file", name, queryPermission: async () => { return "granted"; }, requestPermission: async () => { return "granted"; }, isSameEntry: async function(other) { return other === this; }, getFile: async () => { if (!exists()) throw new DOMException("A requested file or directory could not be found at the time an operation was processed.", "NotFoundError"); const f = new File([fileData.content], name, { lastModified: fileData.lastModified }); f._opfsId = fileData.id; return f; }, createWritable: async (options) => { const keepExistingData = options?.keepExistingData; let abortReason = ""; let isAborted = false; let isClosed = false; let content = keepExistingData ? new Uint8Array(fileData.content) : new Uint8Array(); let cursorPosition = keepExistingData ? fileData.content.length : 0; const writeChunk = async (chunk) => { if (isAborted) throw new Error(abortReason); if (isClosed) throw new TypeError("Cannot write to a CLOSED writable stream"); if (chunk === void 0) throw new TypeError("Cannot write undefined data to the stream"); if (typeof chunk === "object" && "type" in chunk) { if (chunk.type === "truncate") { if (typeof chunk.size !== "number" || chunk.size < 0) throw new TypeError("Invalid size value in truncate parameters"); if (chunk.size < content.length) content = content.slice(0, chunk.size); else { const extended = new Uint8Array(chunk.size); extended.set(content); content = extended; } cursorPosition = Math.min(cursorPosition, chunk.size); return; } if (chunk.type === "seek") { const pos = chunk.position; if (typeof pos !== "number" || pos < 0) throw new TypeError("Invalid position value in seek parameters"); cursorPosition = pos; return; } if (chunk.type === "write") { const wp = chunk; if (wp.size !== void 0 && wp.size !== null) { if (typeof wp.size !== "number" || wp.size < 0) throw new TypeError("Invalid size value in write parameters"); } if (wp.position !== void 0 && wp.position !== null) { if (typeof wp.position !== "number" || wp.position < 0) throw new TypeError("Invalid position value in write parameters"); cursorPosition = wp.position; } chunk = wp.data ?? new Uint8Array(); } } let encoded; if (typeof chunk === "string") encoded = new TextEncoder().encode(chunk); else if (chunk instanceof Blob) { const ab = await chunk.arrayBuffer(); encoded = new Uint8Array(ab); } else if (ArrayBuffer.isView(chunk)) encoded = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); else if (chunk instanceof ArrayBuffer) encoded = new Uint8Array(chunk); else if (isLegacyWriteParams(chunk)) { const wp = chunk; if (wp.position !== void 0 && wp.position !== null) { if (typeof wp.position !== "number" || wp.position < 0) throw new TypeError("Invalid position value in write parameters"); cursorPosition = wp.position; } const data = wp.data; if (data === void 0 || data === null) encoded = new Uint8Array(); else if (typeof data === "string") encoded = new TextEncoder().encode(data); else if (data instanceof Blob) { const ab = await data.arrayBuffer(); encoded = new Uint8Array(ab); } else if (ArrayBuffer.isView(data)) encoded = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); else if (data instanceof ArrayBuffer) encoded = new Uint8Array(data); else throw new TypeError("Invalid data in WriteParams"); } else throw new TypeError("Invalid data type written to the file. Data must be of type FileSystemWriteChunkType."); const requiredSize = cursorPosition + encoded.length; if (content.length < requiredSize) { const extended = new Uint8Array(requiredSize); extended.set(content); content = extended; } content.set(encoded, cursorPosition); cursorPosition += encoded.length; }; const doClose = async () => { if (isClosed) throw new TypeError("Cannot close a CLOSED writable stream"); if (isAborted) throw new TypeError("Cannot close a ERRORED writable stream"); isClosed = true; fileData.content = content; fileData.lastModified = Date.now(); }; const doAbort = async (reason) => { if (isAborted) return; if (reason && !abortReason) abortReason = String(reason); isAborted = true; }; const doTruncate = async (size) => { if (size < 0) throw new DOMException("Invalid truncate size", "IndexSizeError"); if (size < content.length) content = content.slice(0, size); else if (size > content.length) { const newBuffer = new Uint8Array(size); newBuffer.set(content); content = newBuffer; } cursorPosition = Math.min(cursorPosition, size); }; const doSeek = async (position) => { if (position < 0) throw new DOMException("Invalid seek position", "IndexSizeError"); cursorPosition = position; }; const writableStream = new WritableStream({ write: writeChunk, close: doClose, abort: doAbort }); const originalGetWriter = writableStream.getWriter.bind(writableStream); return Object.assign(writableStream, { getWriter: () => originalGetWriter(), write: async (_chunk) => writeChunk(_chunk), close: async () => doClose(), abort: async (reason) => doAbort(reason), truncate: async (size) => doTruncate(size), seek: async (position) => doSeek(position) }); }, createSyncAccessHandle: async () => { if (fileData.locked) throw new DOMException("A sync access handle is already open for this file", "InvalidStateError"); fileData.locked = true; let closed = false; return { getSize: () => { if (closed) throw new DOMException("The access handle is closed", "InvalidStateError"); return fileData.content.byteLength; }, read: (buffer, { at = 0 } = {}) => { if (closed) throw new DOMException("The access handle is closed", "InvalidStateError"); const content = fileData.content; if (at >= content.length) return 0; const available = content.length - at; const writable = buffer instanceof DataView ? buffer.byteLength : buffer.length; const bytesToRead = Math.min(writable, available); const slice = content.subarray(at, at + bytesToRead); if (buffer instanceof DataView) for (let i = 0; i < slice.length; i++) buffer.setUint8(i, slice[i]); else buffer.set(slice, 0); return bytesToRead; }, write: (data, { at = 0 } = {}) => { if (closed) throw new DOMException("The access handle is closed", "InvalidStateError"); const writeLength = data instanceof DataView ? data.byteLength : data.length; const requiredSize = at + writeLength; if (fileData.content.length < requiredSize) { const newBuffer = new Uint8Array(requiredSize); newBuffer.set(fileData.content); fileData.content = newBuffer; } if (data instanceof DataView) for (let i = 0; i < data.byteLength; i++) fileData.content[at + i] = data.getUint8(i); else fileData.content.set(data, at); fileData.lastModified = Date.now(); return writeLength; }, truncate: (size) => { if (closed) throw new DOMException("The access handle is closed", "InvalidStateError"); if (size < fileData.content.length) fileData.content = fileData.content.slice(0, size); else if (size > fileData.content.length) { const newBuffer = new Uint8Array(size); newBuffer.set(fileData.content); fileData.content = newBuffer; } fileData.lastModified = Date.now(); }, flush: async () => { if (closed) throw new DOMException("The access handle is closed", "InvalidStateError"); }, close: async () => { closed = true; fileData.locked = false; } }; } }; }; const fileSystemDirectoryHandleFactory = (name) => { const files = /* @__PURE__ */ new Map(); const directories = /* @__PURE__ */ new Map(); const getJoinedMaps = () => { return new Map([...files, ...directories]); }; return { kind: "directory", name, queryPermission: async () => "granted", requestPermission: async () => "granted", isSameEntry: async function(other) { return other === this; }, getFileHandle: async (fileName, options) => { if (directories.has(fileName)) throw new DOMException(`A directory with the same name exists: ${fileName}`, "TypeMismatchError"); if (!files.has(fileName) && options?.create) files.set(fileName, fileSystemFileHandleFactory(fileName, { content: new Uint8Array(), lastModified: Date.now(), id: Symbol("file") }, () => files.has(fileName))); const fileHandle = files.get(fileName); if (!fileHandle) throw new DOMException(`File not found: ${fileName}`, "NotFoundError"); return fileHandle; }, getDirectoryHandle: async (dirName, options) => { if (files.has(dirName)) throw new DOMException(`A file with the same name exists: ${dirName}`, "TypeMismatchError"); if (!directories.has(dirName) && options?.create) { const dir = fileSystemDirectoryHandleFactory(dirName); directories.set(dirName, dir); } const directoryHandle = directories.get(dirName); if (!directoryHandle) throw new DOMException(`Directory not found: ${dirName}`, "NotFoundError"); return directoryHandle; }, removeEntry: async (entryName, options) => { if (files.has(entryName)) { files.delete(entryName); return; } const dir = directories.get(entryName); if (dir) { if (!options?.recursive) for await (const _ of dir.values()) throw new DOMException("The directory is not empty", "InvalidModificationError"); directories.delete(entryName); return; } throw new DOMException(`No such file or directory: ${entryName}`, "NotFoundError"); }, [Symbol.asyncIterator]: async function* () { const entries = getJoinedMaps(); for (const [n, h] of entries) yield [n, h]; return void 0; }, entries: async function* () { yield* getJoinedMaps().entries(); }, keys: async function* () { yield* getJoinedMaps().keys(); }, values: async function* () { yield* getJoinedMaps().values(); }, resolve: async function(possibleDescendant) { const traverseDirectory = async (directory, target, path = []) => { if (await directory.isSameEntry(target)) return path; for await (const [nm, h] of directory.entries()) if (isDirectoryHandle(h)) { const result = await traverseDirectory(h, target, [...path, nm]); if (result) return result; } else if (isFileHandle(h)) { if (await h.isSameEntry(target)) return [...path, nm]; } return null; }; return traverseDirectory(this, possibleDescendant); } }; }; //#endregion //#region src/index.ts const storageFactory = ({ usage = 0, quota = 1024 ** 3 } = {}) => { const root = fileSystemDirectoryHandleFactory("root"); return { estimate: async () => { return { usage: usage + await getSizeOfDirectory(root), quota }; }, getDirectory: async () => { return root; }, persist: async () => { return true; }, persisted: async () => { return true; } }; }; const mockOPFS = () => { if (!("navigator" in globalThis)) Object.defineProperty(globalThis, "navigator", { value: {}, writable: true }); if (!globalThis.navigator.storage) Object.defineProperty(globalThis.navigator, "storage", { value: storageFactory(), writable: true }); }; const resetMockOPFS = () => { const root = fileSystemDirectoryHandleFactory("root"); Object.defineProperty(globalThis.navigator.storage, "getDirectory", { value: () => root, writable: true }); }; if (typeof globalThis !== "undefined") mockOPFS(); //#endregion export { mockOPFS, resetMockOPFS, storageFactory };