opfs-mock
Version:
Mock all origin private file system APIs for your Jest or Vitest tests
330 lines (327 loc) • 12.6 kB
JavaScript
//#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 };