diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts
deleted file mode 100644
index 81a6f00..0000000
--- a/tests/adapters.test.ts
+++ /dev/null
@@ -1,563 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-import { mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { DatabaseSync } from "node:sqlite";
-
-import { createFileSystem, FileSystemError, probeOpfs } from "../mod.ts";
-import { createMemoryAdapter } from "../src/adapter/memory.ts";
-import { defineAdapter } from "../src/adapter/definition.ts";
-import { createNodeAdapter } from "../src/adapter/node.ts";
-import { createUnstorageAdapter } from "../src/adapter/unstorage.ts";
-import { createUnstorageDriver } from "../src/driver/unstorage.ts";
-import { createRxDbAdapter, RxDbRecordJsonSchema } from "../src/adapter/rxdb.ts";
-import { createDb0Adapter } from "../src/adapter/db0.ts";
-import { createDrizzleAdapter } from "../src/adapter/drizzle.ts";
-import { basename, dirname, isAncestorPath, joinPath, normalizePath, splitPath } from "../src/path.ts";
-
-function memoryFileSystem(name = crypto.randomUUID(), options = {}) {
- return createFileSystem(createMemoryAdapter(), {
- coordination: "local",
- lockPrefix: `test:${name}`,
- ...options,
- });
-}
-
-function deferred() {
- let resolve;
- let reject;
- const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
- return { promise, resolve, reject };
-}
-
-async function waitFor(predicate, timeout = 1000) {
- const start = Date.now();
- while (!predicate()) {
- if (Date.now() - start > timeout) throw new Error("Timed out waiting for condition");
- await new Promise((resolve) => setTimeout(resolve, 1));
- }
-}
-
-class MemoryUnstorage {
- #values = new Map();
- disposed = false;
- async getItem(key) { return structuredClone(this.#values.get(key) ?? null); }
- async setItem(key, value) { this.#values.set(key, structuredClone(value)); }
- async removeItem(key) { this.#values.delete(key); }
- async getKeys(base = "") { return [...this.#values.keys()].filter((key) => key.startsWith(base)); }
- async dispose() { this.disposed = true; }
-}
-
-class FakeRxDocument {
- constructor(record, remove) { this.record = record; this.remove = remove; }
- toJSON() { return structuredClone(this.record); }
- async incrementalRemove() { this.remove(); }
-}
-
-class FakeRxCollection {
- #records = new Map();
- findOne(path) {
- return { exec: async () => {
- const record = this.#records.get(path);
- return record === undefined ? null : new FakeRxDocument(record, () => this.#records.delete(path));
- } };
- }
- find({ selector }) {
- return { exec: async () => [...this.#records.values()]
- .filter((record) => record.parent === selector.parent)
- .map((record) => new FakeRxDocument(record, () => this.#records.delete(record.path))) };
- }
- async incrementalUpsert(record) { this.#records.set(record.path, structuredClone(record)); }
-}
-
-class SqliteDb0Database {
- dialect = "sqlite";
- #database = new DatabaseSync(":memory:");
-
- prepare(sql) {
- const statement = this.#database.prepare(sql);
- return {
- all: async (...params) => statement.all(...params),
- get: async (...params) => statement.get(...params),
- run: async (...params) => ({ success: true, ...statement.run(...params) }),
- };
- }
-
- async dispose() {
- this.#database.close();
- }
-}
-
-class FakeDb0Database {
- constructor(dialect) { this.dialect = dialect; }
- #rows = new Map();
- prepared = [];
- disposed = false;
- prepare(sql) {
- this.prepared.push(sql);
- const rows = this.#rows;
- if (sql.startsWith("CREATE TABLE")) {
- return { all: async () => [], get: async () => undefined, run: async () => ({ success: true }) };
- }
- if (sql.startsWith("SELECT") && sql.includes("WHERE id")) {
- return { all: async () => [], get: async (id) => rows.get(id), run: async () => ({ success: true }) };
- }
- if (sql.startsWith("SELECT") && sql.includes("WHERE parent_path")) {
- return {
- all: async (parent) => [...rows.values()].filter((row) => row.parent_path === parent),
- get: async () => undefined,
- run: async () => ({ success: true }),
- };
- }
- if (sql.startsWith("INSERT")) {
- return {
- all: async () => [],
- get: async () => undefined,
- run: async (id, path, parent_path, name, kind, data, size, last_modified, media_type) => {
- rows.set(id, { path, parent_path, name, kind, data, size, last_modified, media_type });
- return { success: true };
- },
- };
- }
- if (sql.startsWith("DELETE")) {
- return { all: async () => [], get: async () => undefined, run: async (id) => { rows.delete(id); return { success: true }; } };
- }
- throw new Error(`Unexpected SQL: ${sql}`);
- }
- async dispose() { this.disposed = true; }
-}
-
-function createFakeDrizzle() {
- const table = {
- path: { name: "path" }, parent: { name: "parent" }, name: { name: "name" }, kind: { name: "kind" },
- data: { name: "data" }, size: { name: "size" }, lastModified: { name: "lastModified" }, mediaType: { name: "mediaType" },
- };
- const rows = [];
- const database = {
- select() {
- return {
- from() {
- return {
- where(condition) {
- const selected = () => rows.filter((row) => row[condition.column.name] === condition.value).map((row) => ({ ...row }));
- return {
- then(resolve, reject) { return Promise.resolve(selected()).then(resolve, reject); },
- limit(count) { return Promise.resolve(selected().slice(0, count)); },
- };
- },
- };
- },
- };
- },
- delete() {
- return {
- where(condition) {
- for (let index = rows.length - 1; index >= 0; index -= 1) {
- if (rows[index][condition.column.name] === condition.value) rows.splice(index, 1);
- }
- return Promise.resolve();
- },
- };
- },
- insert() {
- return { values(value) { rows.push({ ...value }); return Promise.resolve(); } };
- },
- };
- return { database, table, rows };
-}
-
-async function exerciseRecordBackend(fileSystem) {
- await fileSystem.writeFile("/records/a.txt", "A", { parents: true });
- await fileSystem.writeFile("/records/b.txt", "B", { parents: true });
- assert.equal(await fileSystem.readText("/records/a.txt"), "A");
- const names = [];
- for await (const entry of fileSystem.readDir("/records")) names.push(entry.name);
- names.sort();
- assert.deepEqual(names, ["a.txt", "b.txt"]);
- await fileSystem.move("/records/a.txt", "/records/c.txt");
- assert.equal(await fileSystem.exists("/records/a.txt"), false);
- assert.equal(await fileSystem.readText("/records/c.txt"), "A");
-}
-
-test("adapter and facade schemas reject invalid runtime contracts", async () => {
- const valid = createMemoryAdapter();
- assert.throws(() => defineAdapter({ ...valid, name: "" }));
- assert.throws(
- () => defineAdapter({ ...valid, capabilities: { ...valid.capabilities, streamRead: "yes" } }),
- );
- assert.throws(() => createFileSystem(valid, { coordination: "invalid" }));
-
- const fs = memoryFileSystem();
- await assert.rejects(fs.writeFile("/invalid.txt", "data", { mode: "invalid" }));
-});
-
-test("web-lock coordination requests shared tree and exclusive file locks", async () => {
- const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
- const requests = [];
- Object.defineProperty(globalThis, "navigator", {
- configurable: true,
- value: {
- locks: {
- async request(name, options, callback) {
- requests.push({ name, mode: options.mode });
- await callback();
- },
- },
- },
- });
-
- try {
- const fs = createFileSystem(createMemoryAdapter(), {
- coordination: "web-locks",
- lockPrefix: "test:web-locks",
- });
- await fs.writeFile("/locked.txt", "data", { parents: true });
- assert.deepEqual(requests, [
- { name: "test:web-locks:tree", mode: "shared" },
- { name: "test:web-locks:file:/locked.txt", mode: "exclusive" },
- ]);
- } finally {
- if (originalNavigator === undefined) delete globalThis.navigator;
- else Object.defineProperty(globalThis, "navigator", originalNavigator);
- }
-});
-
-test("path helpers normalize virtual paths and reject root escape", () => {
- assert.equal(normalizePath("a/./b/../c"), "/a/c");
- assert.equal(joinPath("/a", "b", "../c"), "/a/c");
- assert.deepEqual(splitPath("/a/c"), ["a", "c"]);
- assert.equal(dirname("/a/c"), "/a");
- assert.equal(basename("/a/c"), "c");
- assert.equal(isAncestorPath("/a", "/a/c"), true);
- assert.throws(() => normalizePath("../../escape"), (error) => error instanceof FileSystemError && error.code === "invalid-path");
- assert.throws(() => normalizePath("a\\b"), (error) => error instanceof FileSystemError && error.code === "invalid-path");
-});
-
-test("memory adapter preserves replace, append, update, range, and stat semantics", async () => {
- const fs = memoryFileSystem();
- await fs.writeFile("/data.txt", "hello", { parents: true });
- await fs.writeFile("/data.txt", " world", { mode: "append" });
- await fs.writeFile("/data.txt", "OPFS", { mode: "update", at: 6 });
- assert.equal(await fs.readText("/data.txt"), "hello OPFSd");
- assert.deepEqual([...await fs.readFile("/data.txt", { at: 6, length: 4 })], [...new TextEncoder().encode("OPFS")]);
- const stat = await fs.stat("/data.txt");
- assert.equal(stat.kind, "file");
- assert.equal(stat.size, 11);
-});
-
-test("openWritableFile creates and owns one file lock until terminal cleanup", async () => {
- const root = await mkdtemp(join(tmpdir(), "opfs-positional-create-"));
- const adapter = createNodeAdapter({ root });
- const fs = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
- try {
- const file = await fs.openWritableFile("/nested/output.bin", { create: true, parents: true });
- await file.write(new TextEncoder().encode("AB"), { at: 0 });
- const queued = fs.writeFile("/nested/output.bin", "after");
- let settled = false;
- void queued.finally(() => { settled = true; });
- await new Promise((resolve) => setTimeout(resolve, 5));
- assert.equal(settled, false);
- await file.close();
- await queued;
- assert.equal(await fs.readText("/nested/output.bin"), "after");
- } finally {
- await fs.close();
- if (root !== undefined) await rm(root, { recursive: true, force: true });
- }
-});
-
-test("openSyncFile creates and owns one file lock until close", async () => {
- const root = await mkdtemp(join(tmpdir(), "opfs-sync-create-"));
- const adapter = createNodeAdapter({ root });
- const fs = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
- try {
- const file = await fs.openSyncFile("/nested/sync.bin", { create: true, parents: true });
- file.writeAll(new TextEncoder().encode("AB"), { at: 0 });
- const queued = fs.writeFile("/nested/sync.bin", "after");
- let settled = false;
- void queued.finally(() => { settled = true; });
- await new Promise((resolve) => setTimeout(resolve, 5));
- assert.equal(settled, false);
- file.close();
- await queued;
- assert.equal(await fs.readText("/nested/sync.bin"), "after");
- } finally {
- await fs.close();
- await rm(root, { recursive: true, force: true });
- }
-});
-
-test("record backends reject long-lived positional files instead of emulating them", async () => {
- const fs = memoryFileSystem();
- await fs.ensureFile("/media.bin");
- await assert.rejects(
- fs.openWritableFile("/media.bin"),
- (error) => error instanceof FileSystemError && error.code === "not-supported",
- );
-});
-
-test("OPFS-shaped handles work over a non-OPFS memory backend", async () => {
- const fs = memoryFileSystem();
- const directory = await fs.root.getDirectoryHandle("docs", { create: true });
- const file = await directory.getFileHandle("note.txt", { create: true });
- const writable = await file.createWritable();
- await writable.write(new Blob(["blob"]));
- await writable.write({ type: "write", position: 4, data: " data" });
- await writable.close();
- assert.equal(await (await file.getFile()).text(), "blob data");
- assert.equal(await file.isSameEntry(await directory.getFileHandle("note.txt")), true);
- assert.deepEqual(await fs.root.resolve(file), ["docs", "note.txt"]);
- const entries = [];
- for await (const [name, child] of directory) entries.push([name, child.kind]);
- assert.deepEqual(entries, [["note.txt", "file"]]);
-});
-
-test("staged writable abort discards bytes", async () => {
- const fs = memoryFileSystem();
- await fs.writeFile("/file.txt", "before", { parents: true });
- const file = await fs.getFileHandle("/file.txt");
- const writable = await file.createWritable({ keepExistingData: true });
- const writer = writable.getWriter();
- await writer.write({ type: "write", position: 0, data: "after" });
- await writer.abort("discard");
- writer.releaseLock();
- assert.equal(await fs.readText("/file.txt"), "before");
-});
-
-test("record adapters cap streamed writes and cancel the producer", async () => {
- const fs = memoryFileSystem(undefined, { maxBufferedWriteBytes: 4 });
- let cancelled = false;
- const stream = new ReadableStream({
- start(controller) { controller.enqueue(new Uint8Array([1, 2, 3])); controller.enqueue(new Uint8Array([4, 5])); },
- cancel() { cancelled = true; },
- });
- await assert.rejects(fs.writeFile("/large.bin", stream, { parents: true }), (error) => error instanceof FileSystemError && error.code === "too-large");
- assert.equal(cancelled, true);
- assert.equal(await fs.exists("/large.bin"), false);
-});
-
-test("copy replaces stale trees and fallback move removes the source", async () => {
- const fs = memoryFileSystem();
- await fs.writeFile("/source/nested/a.txt", "A", { parents: true });
- await fs.writeFile("/source/b.txt", "B", { parents: true });
- await fs.writeFile("/destination/stale.txt", "stale", { parents: true });
- await fs.copy("/source", "/destination", { overwrite: true, concurrency: 2 });
- assert.equal(await fs.readText("/destination/nested/a.txt"), "A");
- assert.equal(await fs.exists("/destination/stale.txt"), false);
- await fs.move("/destination", "/moved", { concurrency: 2 });
- assert.equal(await fs.exists("/destination"), false);
- assert.equal(await fs.readText("/moved/b.txt"), "B");
-});
-
-test("overlap is rejected before destructive overwrite", async () => {
- const fs = memoryFileSystem();
- await fs.writeFile("/a/b/file.txt", "safe", { parents: true });
- await assert.rejects(fs.copy("/a", "/a/c", { overwrite: true }), (error) => error instanceof FileSystemError && error.code === "invalid-operation");
- await assert.rejects(fs.copy("/a/b/file.txt", "/a", { overwrite: true }), (error) => error instanceof FileSystemError && error.code === "invalid-operation");
- assert.equal(await fs.readText("/a/b/file.txt"), "safe");
-});
-
-test("aborted queued write does not poison later same-file mutations", async () => {
- const fs = memoryFileSystem();
- const releaseFirst = deferred();
- let firstStarted = false;
- async function* firstData() { firstStarted = true; yield new TextEncoder().encode("first"); await releaseFirst.promise; }
- const first = fs.writeFile("/queue.txt", firstData(), { parents: true });
- await waitFor(() => firstStarted);
- const controller = new AbortController();
- const second = fs.writeFile("/queue.txt", "second", { signal: controller.signal });
- controller.abort("cancel queued write");
- await assert.rejects(second, (error) => error instanceof FileSystemError && error.code === "aborted");
- const third = fs.writeFile("/queue.txt", "third");
- releaseFirst.resolve();
- await first;
- await third;
- assert.equal(await fs.readText("/queue.txt"), "third");
-});
-
-test("independent file writes can progress concurrently", async () => {
- const fs = memoryFileSystem();
- const releaseFirst = deferred();
- let firstStarted = false;
- async function* firstData() {
- firstStarted = true;
- yield new TextEncoder().encode("first");
- await releaseFirst.promise;
- }
-
- const first = fs.writeFile("/parallel/a.txt", firstData(), { parents: true });
- await waitFor(() => firstStarted);
- await fs.writeFile("/parallel/b.txt", "second", { parents: true });
- assert.equal(await fs.readText("/parallel/b.txt"), "second");
- releaseFirst.resolve();
- await first;
-});
-
-test("tree mutation waits for an active file write", async () => {
- const fs = memoryFileSystem();
- const release = deferred();
- let started = false;
- async function* data() { started = true; yield new TextEncoder().encode("data"); await release.promise; }
- const write = fs.writeFile("/tree/file.txt", data(), { parents: true });
- await waitFor(() => started);
- let emptied = false;
- const empty = fs.emptyDir("/tree").then(() => { emptied = true; });
- await new Promise((resolve) => setTimeout(resolve, 10));
- assert.equal(emptied, false);
- release.resolve();
- await write;
- await empty;
- assert.equal(await fs.exists("/tree/file.txt"), false);
-});
-
-test("openReadStream propagates AbortSignal after the stream opens", async () => {
- const fs = memoryFileSystem();
- await fs.writeFile("/abort.bin", new Uint8Array(1024), { parents: true });
- const controller = new AbortController();
- const stream = await fs.openReadStream("/abort.bin", { signal: controller.signal });
- controller.abort("stop");
- await assert.rejects(new Response(stream).arrayBuffer(), (error) => error instanceof FileSystemError && error.code === "aborted");
-});
-
-test("unstorage adapter uses the high-level Storage contract and owns it only when requested", async () => {
- const storage = new MemoryUnstorage();
- const adapter = createUnstorageAdapter(storage, { disposeStorage: true });
- const fs = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
- await exerciseRecordBackend(fs);
- await fs.close();
- assert.equal(storage.disposed, true);
-});
-
-test("reverse unstorage driver can expose any filesystem backend", async () => {
- const fs = memoryFileSystem();
- const driver = createUnstorageDriver(fs);
- await driver.setItem("cache:item", "value", {});
- assert.equal(await driver.getItem("cache:item"), "value");
- assert.deepEqual(await driver.getKeys("cache", { maxDepth: 2 }), ["cache:item"]);
- await driver.setItemRaw("cache:binary", new Uint8Array([1, 2, 3]), {});
- assert.deepEqual([...await driver.getItemRaw("cache:binary", {})], [1, 2, 3]);
- await driver.setItem("odd% key/part:item?", "encoded", {});
- await driver.setItem("odd~25:item", "tilde", {});
- await driver.setItem("prefix", "parent-value", {});
- await driver.setItem("prefix:child", "child-value", {});
- assert.equal(await driver.getItem("prefix"), "parent-value");
- assert.equal(await driver.getItem("prefix:child"), "child-value");
- assert.equal(await driver.getItem("odd% key/part:item?"), "encoded");
- assert.equal(await driver.getItem("odd~25:item"), "tilde");
- const encodedKeys = await driver.getKeys("", { maxDepth: 4 });
- assert.equal(encodedKeys.includes("odd% key/part:item?"), true);
- assert.equal(encodedKeys.includes("odd~25:item"), true);
- assert.equal(encodedKeys.includes("prefix"), true);
- assert.equal(encodedKeys.includes("prefix:child"), true);
- await driver.removeItem("cache:item", {});
- assert.equal(await driver.hasItem("cache:item", {}), false);
- await driver.removeItem("cache:missing", {});
- await driver.clear("cache:missing", {});
- assert.deepEqual(await driver.getKeys("odd% key/part:item?:", { maxDepth: 1 }), []);
- await driver.clear("prefix:", {});
- assert.equal(await driver.getItem("prefix"), "parent-value");
- assert.equal(await driver.getItem("prefix:child"), null);
-});
-
-test("RxDB bridge targets RxCollection and therefore remains storage-engine agnostic", async () => {
- assert.equal(RxDbRecordJsonSchema.primaryKey, "path");
- assert.deepEqual(RxDbRecordJsonSchema.indexes, ["parent"]);
- assert.equal(RxDbRecordJsonSchema.oneOf.length, 2);
- assert.deepEqual(RxDbRecordJsonSchema.oneOf[1].required, ["kind", "data", "size", "mediaType"]);
- const fs = createFileSystem(createRxDbAdapter(new FakeRxCollection()), { coordination: "local" });
- await exerciseRecordBackend(fs);
-});
-
-for (const dialect of ["sqlite", "libsql", "postgresql", "mysql"]) {
- test(`db0 bridge executes portable record operations for ${dialect}`, async () => {
- const database = new FakeDb0Database(dialect);
- const adapter = await createDb0Adapter(database, { disposeDatabase: true });
- const fs = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
- await exerciseRecordBackend(fs);
- await fs.close();
- assert.equal(database.disposed, true);
- assert.equal(database.prepared.some((sql) => sql.startsWith("CREATE TABLE")), true);
- assert.equal(database.prepared.some((sql) => sql.includes("?")), true);
- if (dialect === "mysql") assert.equal(database.prepared.some((sql) => sql.includes("ON DUPLICATE KEY UPDATE")), true);
- });
-}
-
-test("db0 SQLite SQL executes against a real SQLite engine", async () => {
- const database = new SqliteDb0Database();
- const adapter = await createDb0Adapter(database, { disposeDatabase: true });
- const fs = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
- await exerciseRecordBackend(fs);
- await fs.close();
-});
-
-test("Drizzle bridge uses caller-provided dialect schema and common CRUD builders", async () => {
- const { database, table } = createFakeDrizzle();
- const fs = createFileSystem(createDrizzleAdapter({ database, table }), { coordination: "local" });
- await exerciseRecordBackend(fs);
-});
-
-test("Node adapter performs real streaming, native move, and synchronous random access", async () => {
- const root = await mkdtemp(join(tmpdir(), "okikio-opfs-"));
- try {
- const adapter = createNodeAdapter({ root });
- const fs = createFileSystem(adapter, { coordination: "local" });
- const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("stream")); controller.close(); } });
- await fs.writeFile("/nested/file.txt", stream, { parents: true });
- assert.equal(await fs.readText("/nested/file.txt"), "stream");
- await fs.move("/nested/file.txt", "/nested/moved.txt");
- assert.equal(await fs.exists("/nested/file.txt"), false);
-
- await fs.ensureFile("/nested/positional.bin");
- const positional = await fs.openWritableFile("/nested/positional.bin");
- await positional.write(new TextEncoder().encode("CD"), { at: 2 });
- await positional.write(new TextEncoder().encode("AB"), { at: 0 });
- await positional.flush();
- assert.equal(await fs.readText("/nested/positional.bin"), "ABCD");
-
- let queuedPositionalWriteCompleted = false;
- const queuedPositionalWrite = fs.writeFile("/nested/positional.bin", "after-positional")
- .then(() => { queuedPositionalWriteCompleted = true; });
- await new Promise((resolve) => setTimeout(resolve, 10));
- assert.equal(queuedPositionalWriteCompleted, false);
- await positional.close();
- await positional.close();
- await queuedPositionalWrite;
- assert.equal(await fs.readText("/nested/positional.bin"), "after-positional");
-
- const sync = await fs.openSyncFile("/nested/moved.txt");
- sync.writeAll(new TextEncoder().encode("NODE"), { at: 0 });
- sync.flush();
-
- let queuedWriteCompleted = false;
- const queuedWrite = fs.writeFile("/nested/moved.txt", "after-sync")
- .then(() => { queuedWriteCompleted = true; });
- await new Promise((resolve) => setTimeout(resolve, 10));
- assert.equal(queuedWriteCompleted, false);
-
- sync.close();
- await queuedWrite;
- assert.equal(await fs.readText("/nested/moved.txt"), "after-sync");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
-});
-
-test("filesystem disposal follows explicit adapter ownership", async () => {
- let disposed = 0;
- const adapter = createMemoryAdapter();
- adapter.dispose = async () => { disposed += 1; };
- const borrowed = createFileSystem(adapter, { coordination: "local" });
- await borrowed.close();
- assert.equal(disposed, 0);
- const owned = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
- await owned.close();
- await owned.close();
- assert.equal(disposed, 1);
-});
-
-test("probeOpfs is non-throwing outside a browser OPFS context", async () => {
- const capabilities = await probeOpfs();
- assert.equal(typeof capabilities.rootAvailable, "boolean");
- if (!capabilities.rootAvailable) assert.equal(typeof capabilities.rootError?.name, "string");
-});
diff --git a/tests/azure.test.ts b/tests/azure.test.ts
new file mode 100644
index 0000000..547287a
--- /dev/null
+++ b/tests/azure.test.ts
@@ -0,0 +1,608 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { AZURE_LIMITS, AzureError, createAzureClient } from "../src/azure.ts";
+import { RequestCapture } from "./http.ts";
+import { streamBytes } from "./stream.ts";
+
+/** Creates one Azure-style XML response without coupling tests to an HTTP server. */
+function xml(value: string, init: ResponseInit = {}): Response {
+ return new Response(value, { status: 200, headers: { "content-type": "application/xml", ...(init.headers ?? {}) }, ...init });
+}
+
+/** Refreshable bearer source used to prove per-request token resolution. */
+class BearerTokenSource {
+ /** Number of token requests observed. */
+ calls = 0;
+
+ /** Returns one token value that identifies its refresh call. */
+ get(): string {
+ this.calls += 1;
+ return `token-${this.calls}`;
+ }
+}
+
+describe("Azure Blob client", () => {
+ it("keeps SAS authorization on the source URL during provider-side copy", async () => {
+ const requests: Request[] = [];
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sv=2026-04-06&sig=secret" },
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ if (request.method === "HEAD") {
+ return new Response(null, { status: 200, headers: { "content-length": "4", etag: "\"etag\"" } });
+ }
+ return new Response(null, { status: 202, headers: { "x-ms-copy-status": "success" } });
+ },
+ });
+
+ await client.copy!("source.txt", "copy.txt");
+
+ const copy = requests.find((request) => request.method === "PUT");
+ expect(copy?.headers.get("x-ms-copy-source")).toContain("/data/source.txt?");
+ expect(copy?.headers.get("x-ms-copy-source")).toContain("sig=secret");
+ expect(copy?.headers.get("x-ms-requires-sync")).toBe("true");
+ });
+
+ it("uses Put Block From URL for blobs above the 256 MiB synchronous copy limit", async () => {
+ const requests: Request[] = [];
+ const size = 256 * 1024 * 1024 + 1;
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "bearer", token: "token" },
+ blockSize: 100 * 1024 * 1024,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ const url = new URL(request.url);
+ if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) {
+ return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"source-etag\"" } });
+ }
+ if (request.method === "HEAD") {
+ return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"copy-etag\"" } });
+ }
+ return new Response(null, { status: 201 });
+ },
+ });
+
+ await client.copy!("source.bin", "copy.bin", { sourceIfMatch: "\"source-etag\"" });
+
+ const blocks = requests.filter((request) => new URL(request.url).searchParams.get("comp") === "block");
+ expect(blocks).toHaveLength(3);
+ expect(blocks[0]?.headers.get("x-ms-source-range")).toBe(`bytes=0-${100 * 1024 * 1024 - 1}`);
+ expect(blocks[0]?.headers.get("x-ms-copy-source-authorization")).toBe("Bearer token");
+ expect(blocks[0]?.headers.get("x-ms-source-if-match")).toBe("\"source-etag\"");
+ expect(requests.some((request) => new URL(request.url).searchParams.get("comp") === "blocklist")).toBe(true);
+ });
+
+ it("rejects direct server-side copy when the selected service version predates the API", async () => {
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ version: "2017-11-09",
+ fetch: async () => new Response(null, { status: 500 }),
+ });
+
+ await expect(client.copy!("source.bin", "copy.bin")).rejects.toBeInstanceOf(AzureError);
+ });
+
+ it("parses Azure list responses with prefixes and continuation markers", async () => {
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ fetch: async () => xml(`
+
+
+ root/a.txt4text/plain"e"
+ root/nested/
+
+ next
+ `),
+ });
+
+ const page = await client.list({ prefix: "root/", delimiter: "/" });
+ expect(page.objects[0]?.key).toBe("root/a.txt");
+ expect(page.objects[0]?.mediaType).toBe("text/plain");
+ expect(page.prefixes).toEqual(["root/nested/"]);
+ expect(page.cursor).toBe("next");
+ });
+
+ it("retains Azure request IDs and service codes on failures", async () => {
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ fetch: async () => xml("AuthorizationFailuredenied", {
+ status: 403,
+ headers: { "x-ms-request-id": "request-1" },
+ }),
+ });
+
+ try {
+ await client.get("private.txt");
+ throw new Error("expected Azure failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(AzureError);
+ if (error instanceof AzureError) {
+ expect(error.code).toBe("AuthorizationFailure");
+ expect(error.requestId).toBe("request-1");
+ }
+ }
+ });
+ it("creates the documented Azurite Shared Key canonical signature", async () => {
+ let request: Request | undefined;
+ const client = createAzureClient({
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential: {
+ kind: "shared-key",
+ account: "devstoreaccount1",
+ key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
+ },
+ now: () => new Date("2026-08-14T12:00:00.000Z"),
+ fetch: async (input, init) => {
+ request = new Request(input, init);
+ return new Response(null, { status: 201 });
+ },
+ });
+
+ await client.request({ method: "PUT", query: { restype: "container" } });
+
+ expect(request?.headers.get("authorization")).toBe(
+ "SharedKey devstoreaccount1:h5gDRN/kZdrsO5FfUgKPNZGwb9UgFzqZvIDC5iVFi94=",
+ );
+ expect(request?.headers.get("x-ms-date")).toBe("Fri, 14 Aug 2026 12:00:00 GMT");
+ expect(request?.headers.get("content-length")).toBe("0");
+ });
+
+
+ it("rejects Shared Key service versions older than the implemented signing format", () => {
+ expect(() => createAzureClient({
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential: {
+ kind: "shared-key",
+ account: "devstoreaccount1",
+ key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
+ },
+ version: "2009-07-17",
+ })).toThrow(RangeError);
+ });
+
+ it("signs zero Content-Length according to the selected Shared Key service version", async () => {
+ const oldCapture = new RequestCapture();
+ const modernCapture = new RequestCapture();
+ const credential = {
+ kind: "shared-key" as const,
+ account: "devstoreaccount1",
+ key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
+ };
+ const options = {
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential,
+ now: () => new Date("2026-08-14T12:00:00.000Z"),
+ };
+
+ await createAzureClient({ ...options, version: "2014-02-14", fetch: oldCapture.fetch.bind(oldCapture) }).request({
+ method: "PUT",
+ key: "zero.bin",
+ body: new Uint8Array(),
+ });
+ await createAzureClient({ ...options, version: "2015-02-21", fetch: modernCapture.fetch.bind(modernCapture) }).request({
+ method: "PUT",
+ key: "zero.bin",
+ body: new Uint8Array(),
+ });
+
+ expect(oldCapture.latest?.headers.get("authorization")).toBe(
+ "SharedKey devstoreaccount1:l0m1mkwouin+1Fe6pBOf3LgSCgsrZMzD4luPiqfRonQ=",
+ );
+ expect(modernCapture.latest?.headers.get("authorization")).toBe(
+ "SharedKey devstoreaccount1:JHL00B0fQHliBPS7Gz7O2DcsyB3DwEnHjFUUxfTKvIY=",
+ );
+ });
+
+ it("omits empty x-ms headers before 2016-05-31 and signs them from that version onward", async () => {
+ const credential = {
+ kind: "shared-key" as const,
+ account: "devstoreaccount1",
+ key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
+ };
+ const base = {
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential,
+ now: () => new Date("2026-08-14T12:00:00.000Z"),
+ };
+
+ const legacyEmpty = new RequestCapture();
+ const legacyAbsent = new RequestCapture();
+ await createAzureClient({ ...base, version: "2015-02-21", fetch: legacyEmpty.fetch.bind(legacyEmpty) }).request({
+ method: "HEAD",
+ key: "value",
+ headers: { "x-ms-meta-empty": "" },
+ });
+ await createAzureClient({ ...base, version: "2015-02-21", fetch: legacyAbsent.fetch.bind(legacyAbsent) }).request({
+ method: "HEAD",
+ key: "value",
+ });
+
+ const modernEmpty = new RequestCapture();
+ const modernAbsent = new RequestCapture();
+ await createAzureClient({ ...base, version: "2016-05-31", fetch: modernEmpty.fetch.bind(modernEmpty) }).request({
+ method: "HEAD",
+ key: "value",
+ headers: { "x-ms-meta-empty": "" },
+ });
+ await createAzureClient({ ...base, version: "2016-05-31", fetch: modernAbsent.fetch.bind(modernAbsent) }).request({
+ method: "HEAD",
+ key: "value",
+ });
+
+ expect(legacyEmpty.latest?.headers.get("authorization")).toBe(legacyAbsent.latest?.headers.get("authorization"));
+ expect(modernEmpty.latest?.headers.get("authorization")).not.toBe(modernAbsent.latest?.headers.get("authorization"));
+ });
+
+ it("validates block size against the selected Azure REST service version", () => {
+ expect(() => createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ version: "2015-04-05",
+ blockSize: AZURE_LIMITS.legacyBlockBytes + 1,
+ })).toThrow(RangeError);
+ });
+
+ it("keeps destination conditions off Put Block and applies them at Put Block List", async () => {
+ const requests: Request[] = [];
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ blockSize: 4,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ if (request.method === "HEAD") {
+ return new Response(null, { status: 200, headers: { "content-length": "6", etag: "\"done\"" } });
+ }
+ return new Response(null, { status: 201 });
+ },
+ });
+
+ const body = streamBytes([new Uint8Array([1, 2, 3, 4, 5, 6])]);
+ await client.put("stream.bin", body, { ifNoneMatch: "*", size: 6 });
+
+ const blocks = requests.filter((request) => new URL(request.url).searchParams.get("comp") === "block");
+ const commit = requests.find((request) => new URL(request.url).searchParams.get("comp") === "blocklist");
+ expect(blocks.every((request) => !request.headers.has("if-none-match"))).toBe(true);
+ expect(commit?.headers.get("if-none-match")).toBe("*");
+ });
+
+ it("rejects source bearer copy authorization before service version 2020-10-02", async () => {
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "bearer", token: "token" },
+ version: "2019-12-12",
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ if (request.method === "HEAD") {
+ return new Response(null, { status: 200, headers: { "content-length": "4", etag: "\"source\"" } });
+ }
+ return new Response(null, { status: 201 });
+ },
+ });
+
+ await expect(client.copy!("source.bin", "copy.bin")).rejects.toBeInstanceOf(AzureError);
+ });
+
+ it("canonicalizes Shared Key query fields independently from insertion order", async () => {
+ const first = new RequestCapture();
+ const second = new RequestCapture();
+ const credential = {
+ kind: "shared-key" as const,
+ account: "devstoreaccount1",
+ key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
+ };
+ const base = {
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential,
+ now: () => new Date("2026-08-14T12:00:00.000Z"),
+ };
+
+ await createAzureClient({ ...base, fetch: first.fetch.bind(first) }).request({
+ method: "GET",
+ query: { restype: "container", comp: "list", prefix: "root/" },
+ });
+ await createAzureClient({ ...base, fetch: second.fetch.bind(second) }).request({
+ method: "GET",
+ query: { prefix: "root/", comp: "list", restype: "container" },
+ });
+
+ expect(first.latest?.headers.get("authorization")).toBe(second.latest?.headers.get("authorization"));
+ });
+
+
+ it("collapses unquoted Shared Key whitespace without changing quoted-string whitespace", async () => {
+ const now = () => new Date("2026-08-14T12:00:00.000Z");
+ const credential = {
+ kind: "shared-key" as const,
+ account: "devstoreaccount1",
+ key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
+ };
+ const compact = new RequestCapture();
+ const spaced = new RequestCapture();
+ const quoted = new RequestCapture();
+
+ await createAzureClient({
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential,
+ now,
+ fetch: compact.fetch.bind(compact),
+ }).request({ method: "HEAD", key: "value", headers: { "x-ms-meta-note": 'alpha beta "two spaces"' } });
+
+ await createAzureClient({
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential,
+ now,
+ fetch: spaced.fetch.bind(spaced),
+ }).request({ method: "HEAD", key: "value", headers: { "x-ms-meta-note": 'alpha beta "two spaces"' } });
+
+ await createAzureClient({
+ endpoint: "http://127.0.0.1:10000/devstoreaccount1",
+ container: "opfs-test",
+ credential,
+ now,
+ fetch: quoted.fetch.bind(quoted),
+ }).request({ method: "HEAD", key: "value", headers: { "x-ms-meta-note": 'alpha beta "two spaces"' } });
+
+ expect(compact.latest?.headers.get("authorization")).toBe(spaced.latest?.headers.get("authorization"));
+ expect(compact.latest?.headers.get("authorization")).not.toBe(quoted.latest?.headers.get("authorization"));
+ });
+
+ it("keeps HTTP evidence when a proxy returns a malformed non-Azure failure body", async () => {
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ fetch: async () => new Response("upstream gateway failed", {
+ status: 502,
+ headers: { "x-ms-request-id": "gateway-request" },
+ }),
+ });
+
+ try {
+ await client.get("state.bin");
+ throw new Error("expected gateway failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(AzureError);
+ if (error instanceof AzureError) {
+ expect(error.status).toBe(502);
+ expect(error.requestId).toBe("gateway-request");
+ expect(error.code).toBeUndefined();
+ expect(error.message).toContain("HTTP 502");
+ }
+ }
+ });
+
+ it("uses SAS query authorization without adding an Authorization header", async () => {
+ const capture = new RequestCapture();
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sv=2026-04-06&sig=secret" },
+ fetch: capture.fetch.bind(capture),
+ });
+
+ await client.request({ method: "HEAD", key: "state.bin" });
+
+ expect(capture.latest?.headers.has("authorization")).toBe(false);
+ expect(new URL(capture.latest!.url).searchParams.get("sig")).toBe("secret");
+ });
+
+ it("resolves a bearer token immediately before every request", async () => {
+ const token = new BearerTokenSource();
+ const capture = new RequestCapture();
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "bearer", token: token.get.bind(token) },
+ fetch: capture.fetch.bind(capture),
+ });
+
+ await client.request({ method: "HEAD", key: "one" });
+ await client.request({ method: "HEAD", key: "two" });
+
+ expect(token.calls).toBe(2);
+ expect(capture.requests[0]?.headers.get("authorization")).toBe("Bearer token-1");
+ expect(capture.requests[1]?.headers.get("authorization")).toBe("Bearer token-2");
+ });
+
+ it("does not advertise server-side copy for caller-defined authorization headers", () => {
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "headers", get: () => ({ authorization: "Provider token" }) },
+ });
+
+ expect(client.capabilities.copy).toBe(false);
+ });
+
+ it("expands a known streamed block size to stay within 50,000 committed blocks", async () => {
+ const requests: Request[] = [];
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ blockSize: 1,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ return new Response(null, { status: 201 });
+ },
+ });
+
+ await expect(client.put(
+ "planned.bin",
+ streamBytes([new Uint8Array([1, 2, 3, 4])]),
+ { size: AZURE_LIMITS.maxCommittedBlocks + 1 },
+ )).rejects.toThrow(RangeError);
+
+ const blocks = requests.filter((request) => new URL(request.url).searchParams.get("comp") === "block");
+ expect(blocks).toHaveLength(2);
+ expect(blocks[0]?.headers.get("content-length")).toBe("2");
+ expect(blocks[1]?.headers.get("content-length")).toBe("2");
+ expect(requests.some((request) => new URL(request.url).searchParams.get("comp") === "blocklist")).toBe(false);
+ });
+
+ it("rejects a declared blob larger than the selected service-version block plan before Fetch", async () => {
+ let fetches = 0;
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "data",
+ credential: { kind: "sas", token: "?sig=secret" },
+ fetch: async () => {
+ fetches += 1;
+ return new Response(null, { status: 500 });
+ },
+ });
+ const max = AZURE_LIMITS.currentBlockBytes * AZURE_LIMITS.maxCommittedBlocks;
+
+ await expect(client.put(
+ "too-large.bin",
+ streamBytes([new Uint8Array([1])]),
+ { size: max + 1 },
+ )).rejects.toThrow(RangeError);
+ expect(fetches).toBe(0);
+ });
+
+});
+
+describe("Azure request policy", () => {
+ it("retries replayable 503 responses and rebuilds authorization per attempt", async () => {
+ let attempts = 0;
+ let authCalls = 0;
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "container",
+ credential: {
+ kind: "headers",
+ get: () => ({ authorization: `test-${++authCalls}` }),
+ },
+ request: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async (_input, init) => {
+ attempts += 1;
+ expect(init?.redirect).toBe("manual");
+ return new Response(null, { status: attempts === 1 ? 503 : 200 });
+ },
+ });
+
+ const response = await client.request({ method: "PUT", key: "retry.bin", body: new Uint8Array([1]) });
+
+ expect(response.status).toBe(200);
+ expect(attempts).toBe(2);
+ expect(authCalls).toBe(2);
+ expect(client.getMetrics().retries).toBe(1);
+ });
+
+ it("retries a replayable Fetch transport failure and returns the next response", async () => {
+ let attempts = 0;
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "container",
+ credential: { kind: "sas", token: "sv=test&sig=test" },
+ request: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ attempts += 1;
+ if (attempts === 1) throw new TypeError("temporary network failure");
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ const response = await client.request({ method: "GET", key: "retry-network.bin" });
+
+ expect(response.status).toBe(200);
+ expect(attempts).toBe(2);
+ expect(client.getMetrics().retries).toBe(1);
+ expect(client.getMetrics().failures).toBe(0);
+ });
+
+ it("does not retry deterministic authorization failures", async () => {
+ let authCalls = 0;
+ let fetches = 0;
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "container",
+ credential: {
+ kind: "headers",
+ get: () => {
+ authCalls += 1;
+ throw new TypeError("invalid authorization input");
+ },
+ },
+ request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ fetches += 1;
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ await expect(client.request({ method: "GET", key: "key" })).rejects.toThrow("invalid authorization input");
+ expect(authCalls).toBe(1);
+ expect(fetches).toBe(0);
+ });
+
+ it("lets a low-level caller disable retry for a replayable request", async () => {
+ let attempts = 0;
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "container",
+ credential: { kind: "sas", token: "sv=test&sig=test" },
+ request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ attempts += 1;
+ return new Response(null, { status: 503 });
+ },
+ });
+
+ const response = await client.request({ method: "PUT", key: "once.bin", body: new Uint8Array([1]), retry: false });
+ expect(response.status).toBe(503);
+ expect(attempts).toBe(1);
+ });
+
+ it("does not retry a one-shot streamed request body", async () => {
+ let attempts = 0;
+ const client = createAzureClient({
+ endpoint: "https://account.blob.core.windows.net",
+ container: "container",
+ credential: { kind: "sas", token: "sv=test&sig=test" },
+ request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ attempts += 1;
+ return new Response(null, { status: 503 });
+ },
+ });
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array([1]));
+ controller.close();
+ },
+ });
+
+ const response = await client.request({ method: "PUT", key: "stream.bin", body });
+
+ expect(response.status).toBe(503);
+ expect(attempts).toBe(1);
+ });
+});
diff --git a/tests/browser/adapters.spec.ts b/tests/browser/adapters.spec.ts
new file mode 100644
index 0000000..d58a606
--- /dev/null
+++ b/tests/browser/adapters.spec.ts
@@ -0,0 +1,17 @@
+import { expect, test } from "@playwright/test";
+
+/** Same-origin fixture page that exposes the browser adapter test API. */
+const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html";
+
+/** Opens the fixture page and waits until its module API is ready for Playwright calls. */
+async function ready(page: import("@playwright/test").Page): Promise {
+ await page.goto(APP_URL);
+ await page.waitForFunction(() => Boolean((window as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready));
+}
+
+for (const kind of ["localstorage", "indexeddb", "cache"] as const) {
+ test(`${kind} adapter executes against the real browser backend`, async ({ page }) => {
+ await ready(page);
+ expect(await page.evaluate(async ({ kind }) => await window.opfsTest.adapter(kind), { kind })).toBe(kind);
+ });
+}
diff --git a/tests/browser/dedicated-worker.ts b/tests/browser/dedicated-worker.ts
deleted file mode 100644
index 5d78370..0000000
--- a/tests/browser/dedicated-worker.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-///
-///
-///
-import { openFileSystem, probeOpfs } from "../../mod.ts";
-
-// Default type of `self` is `WorkerGlobalScope & typeof globalThis`
-// https://github.com/microsoft/TypeScript/issues/14877
-declare var self: DedicatedWorkerGlobalScope;
-
-self.onmessage = async () => {
- try {
- const capabilities = await probeOpfs();
- const fs = await openFileSystem();
- const file = await fs.openSyncFile("/matrix/dedicated.bin", { create: true, parents: true });
- try {
- file.truncate(0);
- file.writeAll(new TextEncoder().encode("dedicated-worker"));
- file.flush();
- } finally {
- file.close();
- }
- self.postMessage({ ok: true, capabilities, text: await fs.readText("/matrix/dedicated.bin") });
- } catch (error) {
- const err = error as Error & { code?: string };
- const name = err?.name;
- const code = err?.code;
- const message = err?.message;
- self.postMessage({ ok: false, name, code, message });
- }
-};
diff --git a/tests/browser/fixtures/app.ts b/tests/browser/fixtures/app.ts
new file mode 100644
index 0000000..f3596f3
--- /dev/null
+++ b/tests/browser/fixtures/app.ts
@@ -0,0 +1,428 @@
+import { openFileSystem, probeOpfs } from "../../../mod.ts";
+import { createCacheAdapter } from "../../../src/adapter/cache.ts";
+import { openIndexedDbAdapter } from "../../../src/adapter/indexeddb.ts";
+import { createLocalStorageAdapter } from "../../../src/adapter/localstorage.ts";
+import { createOpfsAdapter } from "../../../src/adapter/opfs.ts";
+import { createFileSystem } from "../../../src/filesystem.ts";
+
+/** Result returned by one real browser realm after probing and exercising OPFS. */
+interface RealmResultType {
+ /** Whether the browser exposes the realm or capability needed by the scenario. */
+ readonly supported: boolean;
+ /** Capability report captured in the same realm as the operation. */
+ readonly probe?: Awaited>;
+ /** Text read back after the fixture writes through OPFS. */
+ readonly value?: string;
+ /** Whether a synchronous access handle opened in the tested worker realm. */
+ readonly syncOpened?: boolean;
+ /** Native error name reported when synchronous access was exposed but could not open. */
+ readonly syncError?: string;
+}
+
+/** Browser record adapters exercised against their actual platform storage APIs. */
+type BrowserAdapterType = "localstorage" | "indexeddb" | "cache";
+
+/** Timing totals for the raw backend, direct adapter, and filesystem facade paths. */
+interface BenchmarkResultType {
+ /** Elapsed milliseconds for direct platform storage operations. */
+ readonly rawMs: number;
+ /** Elapsed milliseconds for the direct `AdapterType` path. */
+ readonly adapterMs: number;
+ /** Elapsed milliseconds for the complete `FileSystemType` path. */
+ readonly facadeMs: number;
+}
+
+/** Browser fixture API consumed by Playwright from the containing page. */
+interface BrowserTestApiType {
+ /** Signals that module initialization completed and Playwright can call the fixture. */
+ ready: true;
+ /** Probes OPFS in the Window realm without throwing for unsupported storage. */
+ probe(): ReturnType;
+ /** Writes and reads one value through native Window OPFS. */
+ roundTrip(path: string, value: string): Promise;
+ /** Reads an existing Window OPFS file without creating it. */
+ read(path: string): Promise;
+ /** Runs the OPFS scenario in a real DedicatedWorker. */
+ dedicated(path: string, value: string): Promise;
+ /** Runs the OPFS scenario in a real SharedWorker. */
+ shared(path: string, value: string): Promise;
+ /** Runs the OPFS scenario in a registered ServiceWorker. */
+ service(path: string, value: string): Promise;
+ /** Attempts an already-cancelled write and returns the observed terminal error name. */
+ abort(path: string): Promise;
+ /** Measures native OPFS against the direct OPFS adapter and facade. */
+ benchmark(iterations: number, bytes: number): Promise;
+ /** Measures one browser record backend against its adapter and facade paths. */
+ benchmarkAdapter(kind: BrowserAdapterType, iterations: number, bytes: number): Promise;
+ /** Proves one browser record adapter through a write/read facade round trip. */
+ adapter(kind: BrowserAdapterType): Promise;
+}
+
+declare global {
+ interface Window {
+ /** Playwright-facing test API installed only by this fixture page. */
+ opfsTest: BrowserTestApiType;
+ }
+}
+
+/** Writes and reads one value through the Window realm OPFS facade. */
+async function roundTripOpfs(path: string, value: string): Promise {
+ const probe = await probeOpfs();
+ if (!probe.rootAvailable) return { supported: true, probe };
+ const fileSystem = await openFileSystem();
+ try {
+ await fileSystem.writeFile(path, value, { parents: true });
+ return { supported: true, probe, value: await fileSystem.readText(path) };
+ } finally {
+ await fileSystem.close();
+ }
+}
+
+/** Reads one Window OPFS path while preserving a non-creating lookup. */
+async function readOpfs(path: string): Promise {
+ const probe = await probeOpfs();
+ if (!probe.rootAvailable) return null;
+ const fileSystem = await openFileSystem();
+ try {
+ return await fileSystem.exists(path, { kind: "file" }) ? await fileSystem.readText(path) : null;
+ } finally {
+ await fileSystem.close();
+ }
+}
+
+/** Runs the fixture in one real DedicatedWorker and disposes it after the result. */
+async function runDedicatedWorker(url: URL, path: string, value: string): Promise {
+ if (typeof Worker !== "function") return { supported: false };
+ const instance = new Worker(url, { type: "module" });
+ try {
+ return await new Promise((resolve, reject) => {
+ instance.onmessage = ({ data }) => resolve(data as RealmResultType);
+ instance.onerror = reject;
+ instance.postMessage({ path, value });
+ });
+ } finally {
+ instance.terminate();
+ }
+}
+
+/** Runs the fixture in one real SharedWorker and closes the borrowed message port after the result. */
+async function runSharedWorker(url: URL, path: string, value: string): Promise {
+ if (typeof SharedWorker !== "function") return { supported: false };
+ const instance = new SharedWorker(url, { type: "module" });
+ instance.port.start();
+ try {
+ return await new Promise((resolve, reject) => {
+ instance.port.onmessage = ({ data }) => resolve(data as RealmResultType);
+ instance.port.onmessageerror = reject;
+ instance.port.postMessage({ path, value });
+ });
+ } finally {
+ instance.port.close();
+ }
+}
+
+/** Registers a real ServiceWorker and waits for its OPFS result through a MessageChannel. */
+async function runServiceWorker(path: string, value: string): Promise {
+ if (!("serviceWorker" in navigator)) return { supported: false };
+ const registration = await navigator.serviceWorker.register(
+ new URL("./service.ts", import.meta.url),
+ { type: "module", scope: "/tests/browser/fixtures/" },
+ );
+ await navigator.serviceWorker.ready;
+ const active = registration.active ?? registration.waiting ?? registration.installing;
+ if (active === null) throw new Error("Service worker registration did not expose a worker.");
+ const channel = new MessageChannel();
+ const result = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("Service worker test timed out.")), 10_000);
+ channel.port1.onmessage = ({ data }) => {
+ clearTimeout(timer);
+ resolve(data as RealmResultType);
+ };
+ });
+ active.postMessage({ path, value }, [channel.port2]);
+ return await result;
+}
+
+/** Verifies that an already-aborted signal prevents a Window OPFS write from committing. */
+async function abortOpfsWrite(path: string): Promise {
+ const probe = await probeOpfs();
+ if (!probe.rootAvailable) return "unavailable";
+ const fileSystem = await openFileSystem();
+ const controller = new AbortController();
+ controller.abort(new DOMException("test abort", "AbortError"));
+ try {
+ try {
+ await fileSystem.writeFile(path, "never", { parents: true, signal: controller.signal });
+ return "committed";
+ } catch (error) {
+ return error instanceof Error ? error.name : String(error);
+ }
+ } finally {
+ await fileSystem.close();
+ }
+}
+
+/** Measures raw OPFS, direct adapter, and facade overhead in the same browser realm. */
+async function benchmarkOpfs(iterations: number, bytes: number): Promise {
+ const probe = await probeOpfs();
+ if (!probe.rootAvailable) return null;
+ const payload = new Uint8Array(bytes);
+ const root = await navigator.storage.getDirectory();
+ const rawName = `bench-raw-${crypto.randomUUID()}.bin`;
+ const adapterName = `bench-adapter-${crypto.randomUUID()}.bin`;
+ const facadeName = `bench-facade-${crypto.randomUUID()}.bin`;
+ const rawFile = await root.getFileHandle(rawName, { create: true });
+
+ const rawStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ const writable = await rawFile.createWritable();
+ await writable.write(payload);
+ await writable.close();
+ await (await rawFile.getFile()).arrayBuffer();
+ }
+ const rawMs = performance.now() - rawStart;
+
+ const direct = createOpfsAdapter(root);
+ const adapterStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await direct.writeFile(`/${adapterName}`, payload, { mode: "replace" });
+ await direct.readFile(`/${adapterName}`);
+ }
+ const adapterMs = performance.now() - adapterStart;
+
+ const fileSystem = await openFileSystem({ coordination: "none", metrics: "none" });
+ let facadeMs = 0;
+ try {
+ const facadeStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await fileSystem.writeFile(`/${facadeName}`, payload);
+ await fileSystem.readFile(`/${facadeName}`);
+ }
+ facadeMs = performance.now() - facadeStart;
+ } finally {
+ await fileSystem.close();
+ await root.removeEntry(rawName).catch(() => undefined);
+ await root.removeEntry(adapterName).catch(() => undefined);
+ await root.removeEntry(facadeName).catch(() => undefined);
+ }
+ return { rawMs, adapterMs, facadeMs };
+}
+
+/** Waits for one IndexedDB request and preserves its native failure. */
+function idbRequest(request: IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed."));
+ });
+}
+
+/** Waits until all writes in one IndexedDB transaction commit. */
+function idbTransaction(transaction: IDBTransaction): Promise {
+ return new Promise((resolve, reject) => {
+ transaction.oncomplete = () => resolve();
+ transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
+ transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed."));
+ });
+}
+
+/** Opens one raw IndexedDB database used only by the benchmark baseline. */
+function openRawIndexedDb(name: string): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(name, 1);
+ request.onupgradeneeded = () => request.result.createObjectStore("entries");
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed."));
+ });
+}
+
+/** Measures one browser record backend through raw, adapter, and facade paths. */
+async function benchmarkAdapter(
+ kind: BrowserAdapterType,
+ iterations: number,
+ bytes: number,
+): Promise {
+ const payload = new Uint8Array(bytes);
+ const id = crypto.randomUUID();
+ const path = "/bench/value.bin";
+
+ if (kind === "localstorage") {
+ const rawKey = `opfs-bench:${id}:raw`;
+ const rawValue = "x".repeat(bytes);
+ const rawStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ localStorage.setItem(rawKey, rawValue);
+ localStorage.getItem(rawKey);
+ }
+ const rawMs = performance.now() - rawStart;
+
+ const direct = createLocalStorageAdapter(localStorage, { prefix: `adapter-${id}` });
+ await direct.createDir("/bench");
+ const adapterStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await direct.writeFile(path, payload, { mode: "replace" });
+ await direct.readFile(path);
+ }
+ const adapterMs = performance.now() - adapterStart;
+
+ const fileSystem = createFileSystem(createLocalStorageAdapter(localStorage, { prefix: `facade-${id}` }), {
+ coordination: "none",
+ metrics: "none",
+ });
+ try {
+ await fileSystem.ensureDir("/bench");
+ const facadeStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await fileSystem.writeFile(path, payload);
+ await fileSystem.readFile(path);
+ }
+ return { rawMs, adapterMs, facadeMs: performance.now() - facadeStart };
+ } finally {
+ localStorage.removeItem(rawKey);
+ await fileSystem.close();
+ }
+ }
+
+ if (kind === "indexeddb") {
+ if (typeof indexedDB === "undefined") return null;
+ const rawName = `opfs-bench-raw-${id}`;
+ const rawDatabase = await openRawIndexedDb(rawName);
+ const rawStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ const write = rawDatabase.transaction("entries", "readwrite");
+ write.objectStore("entries").put(payload, "value");
+ await idbTransaction(write);
+ const read = rawDatabase.transaction("entries", "readonly");
+ const readCommitted = idbTransaction(read);
+ await idbRequest(read.objectStore("entries").get("value"));
+ await readCommitted;
+ }
+ const rawMs = performance.now() - rawStart;
+
+ const adapterName = `opfs-bench-adapter-${id}`;
+ const direct = await openIndexedDbAdapter({ name: adapterName });
+ await direct.createDir("/bench");
+ const adapterStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await direct.writeFile(path, payload, { mode: "replace" });
+ await direct.readFile(path);
+ }
+ const adapterMs = performance.now() - adapterStart;
+
+ const facadeName = `opfs-bench-facade-${id}`;
+ const fileSystem = createFileSystem(await openIndexedDbAdapter({ name: facadeName }), {
+ coordination: "none",
+ metrics: "none",
+ disposeAdapter: true,
+ });
+ try {
+ await fileSystem.ensureDir("/bench");
+ const facadeStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await fileSystem.writeFile(path, payload);
+ await fileSystem.readFile(path);
+ }
+ return { rawMs, adapterMs, facadeMs: performance.now() - facadeStart };
+ } finally {
+ rawDatabase.close();
+ await direct.dispose?.();
+ await fileSystem.close();
+ indexedDB.deleteDatabase(rawName);
+ indexedDB.deleteDatabase(adapterName);
+ indexedDB.deleteDatabase(facadeName);
+ }
+ }
+
+ if (typeof caches === "undefined") return null;
+ const rawName = `opfs-bench-raw-${id}`;
+ const rawCache = await caches.open(rawName);
+ const rawRequest = new Request(`https://opfs.invalid/bench/${id}`);
+ const rawStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await rawCache.put(rawRequest, new Response(payload));
+ const response = await rawCache.match(rawRequest);
+ await response?.arrayBuffer();
+ }
+ const rawMs = performance.now() - rawStart;
+
+ const adapterName = `opfs-bench-adapter-${id}`;
+ const adapterCache = await caches.open(adapterName);
+ const direct = createCacheAdapter(adapterCache, { prefix: id });
+ await direct.createDir("/bench");
+ const adapterStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await direct.writeFile(path, payload, { mode: "replace" });
+ await direct.readFile(path);
+ }
+ const adapterMs = performance.now() - adapterStart;
+
+ const facadeName = `opfs-bench-facade-${id}`;
+ const facadeCache = await caches.open(facadeName);
+ const fileSystem = createFileSystem(createCacheAdapter(facadeCache, { prefix: id }), { coordination: "none", metrics: "none" });
+ try {
+ await fileSystem.ensureDir("/bench");
+ const facadeStart = performance.now();
+ for (let index = 0; index < iterations; index += 1) {
+ await fileSystem.writeFile(path, payload);
+ await fileSystem.readFile(path);
+ }
+ return { rawMs, adapterMs, facadeMs: performance.now() - facadeStart };
+ } finally {
+ await fileSystem.close();
+ await caches.delete(rawName);
+ await caches.delete(adapterName);
+ await caches.delete(facadeName);
+ }
+}
+
+/** Runs one real browser record adapter through a filesystem write/read round trip. */
+async function roundTripAdapter(kind: BrowserAdapterType): Promise {
+ const id = crypto.randomUUID();
+ const path = `/adapters/${id}.txt`;
+ if (kind === "localstorage") {
+ const fileSystem = createFileSystem(createLocalStorageAdapter(localStorage, { prefix: id }));
+ try {
+ await fileSystem.writeFile(path, kind, { parents: true });
+ return await fileSystem.readText(path);
+ } finally {
+ await fileSystem.close();
+ }
+ }
+
+ if (kind === "indexeddb") {
+ const fileSystem = createFileSystem(await openIndexedDbAdapter({ name: id }), { disposeAdapter: true });
+ try {
+ await fileSystem.writeFile(path, kind, { parents: true });
+ return await fileSystem.readText(path);
+ } finally {
+ await fileSystem.close();
+ indexedDB.deleteDatabase(id);
+ }
+ }
+
+ const cache = await caches.open(id);
+ try {
+ const fileSystem = createFileSystem(createCacheAdapter(cache, { prefix: id }));
+ try {
+ await fileSystem.writeFile(path, kind, { parents: true });
+ return await fileSystem.readText(path);
+ } finally {
+ await fileSystem.close();
+ }
+ } finally {
+ await caches.delete(id);
+ }
+}
+
+window.opfsTest = {
+ ready: true,
+ probe: probeOpfs,
+ roundTrip: roundTripOpfs,
+ read: readOpfs,
+ dedicated: async (path, value) => await runDedicatedWorker(new URL("./dedicated.ts", import.meta.url), path, value),
+ shared: async (path, value) => await runSharedWorker(new URL("./shared.ts", import.meta.url), path, value),
+ service: runServiceWorker,
+ abort: abortOpfsWrite,
+ benchmark: benchmarkOpfs,
+ benchmarkAdapter,
+ adapter: roundTripAdapter,
+};
diff --git a/tests/browser/fixtures/dedicated.ts b/tests/browser/fixtures/dedicated.ts
new file mode 100644
index 0000000..77ee72a
--- /dev/null
+++ b/tests/browser/fixtures/dedicated.ts
@@ -0,0 +1,49 @@
+import { openFileSystem, probeOpfs } from "../../../mod.ts";
+
+/** DedicatedWorker global used to exercise worker-only OPFS capabilities. */
+declare const self: DedicatedWorkerGlobalScope;
+
+/**
+ * Runs one OPFS request in the DedicatedWorker realm.
+ *
+ * The fixture probes synchronous-access exposure in the same realm because
+ * capability exposure is more trustworthy than inferring support from a
+ * browser name.
+ */
+async function runDedicatedRequest(input: { readonly path: string; readonly value: string }): Promise {
+ const probe = await probeOpfs();
+ if (!probe.rootAvailable) {
+ self.postMessage({ supported: true, probe });
+ return;
+ }
+
+ const fileSystem = await openFileSystem();
+ try {
+ await fileSystem.writeFile(input.path, input.value, { parents: true });
+ let syncOpened = false;
+ let syncError: string | undefined;
+ if (probe.syncAccessHandleExposed) {
+ try {
+ const file = await fileSystem.openSyncFile(`/sync/${crypto.randomUUID()}.bin`, { create: true, parents: true });
+ file.close();
+ syncOpened = true;
+ } catch (error) {
+ syncError = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
+ }
+ }
+
+ self.postMessage({
+ supported: true,
+ probe,
+ value: await fileSystem.readText(input.path),
+ syncOpened,
+ ...(syncError === undefined ? {} : { syncError }),
+ });
+ } finally {
+ await fileSystem.close();
+ }
+}
+
+self.onmessage = (event: MessageEvent<{ path: string; value: string }>) => {
+ void runDedicatedRequest(event.data);
+};
diff --git a/tests/browser/fixtures/frame.html b/tests/browser/fixtures/frame.html
new file mode 100644
index 0000000..d5bd505
--- /dev/null
+++ b/tests/browser/fixtures/frame.html
@@ -0,0 +1,5 @@
+
+
+OPFS frame
+
+
diff --git a/tests/browser/fixtures/index.html b/tests/browser/fixtures/index.html
new file mode 100644
index 0000000..5abfbca
--- /dev/null
+++ b/tests/browser/fixtures/index.html
@@ -0,0 +1,5 @@
+
+
+OPFS browser tests
+
+
diff --git a/tests/browser/fixtures/service.ts b/tests/browser/fixtures/service.ts
new file mode 100644
index 0000000..b25871d
--- /dev/null
+++ b/tests/browser/fixtures/service.ts
@@ -0,0 +1,32 @@
+import { openFileSystem, probeOpfs } from "../../../mod.ts";
+
+/** ServiceWorker global used to verify OPFS without relying on Playwright-only worker instrumentation. */
+declare const self: ServiceWorkerGlobalScope;
+
+/** Runs one message-scoped OPFS request while the caller keeps the ServiceWorker event alive. */
+async function runServiceRequest(
+ port: MessagePort,
+ input: { readonly path: string; readonly value: string },
+): Promise {
+ const probe = await probeOpfs();
+ if (!probe.rootAvailable) {
+ port.postMessage({ supported: true, probe });
+ return;
+ }
+
+ const fileSystem = await openFileSystem();
+ try {
+ await fileSystem.writeFile(input.path, input.value, { parents: true });
+ port.postMessage({ supported: true, probe, value: await fileSystem.readText(input.path) });
+ } finally {
+ await fileSystem.close();
+ }
+}
+
+self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
+self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
+self.addEventListener("message", (event: ExtendableMessageEvent) => {
+ const port = event.ports[0];
+ if (port === undefined) return;
+ event.waitUntil(runServiceRequest(port, event.data));
+});
diff --git a/tests/browser/fixtures/shared.ts b/tests/browser/fixtures/shared.ts
new file mode 100644
index 0000000..9c6657a
--- /dev/null
+++ b/tests/browser/fixtures/shared.ts
@@ -0,0 +1,32 @@
+import { openFileSystem, probeOpfs } from "../../../mod.ts";
+
+/** SharedWorker global used to exercise storage shared by connected documents. */
+declare const self: SharedWorkerGlobalScope;
+
+/** Runs one OPFS request received through a SharedWorker message port. */
+async function runSharedRequest(
+ port: MessagePort,
+ input: { readonly path: string; readonly value: string },
+): Promise {
+ const probe = await probeOpfs();
+ if (!probe.rootAvailable) {
+ port.postMessage({ supported: true, probe });
+ return;
+ }
+
+ const fileSystem = await openFileSystem();
+ try {
+ await fileSystem.writeFile(input.path, input.value, { parents: true });
+ port.postMessage({ supported: true, probe, value: await fileSystem.readText(input.path) });
+ } finally {
+ await fileSystem.close();
+ }
+}
+
+self.onconnect = (event: MessageEvent) => {
+ const port = event.ports[0]!;
+ port.onmessage = (message: MessageEvent<{ path: string; value: string }>) => {
+ void runSharedRequest(port, message.data);
+ };
+ port.start();
+};
diff --git a/tests/browser/iframe.html b/tests/browser/iframe.html
deleted file mode 100644
index daa1426..0000000
--- a/tests/browser/iframe.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
diff --git a/tests/browser/iframe.spec.ts b/tests/browser/iframe.spec.ts
new file mode 100644
index 0000000..ca612fc
--- /dev/null
+++ b/tests/browser/iframe.spec.ts
@@ -0,0 +1,72 @@
+import { expect, test } from "@playwright/test";
+
+/** Same-origin fixture used as the top-level embedding document. */
+const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html";
+/** Second-origin fixture used to exercise storage partition and policy behavior. */
+const CROSS_URL = "http://127.0.0.1:4174/tests/browser/fixtures/frame.html";
+
+/** Waits until an iframe has installed the shared browser test API. */
+async function waitForApi(frame: import("@playwright/test").Frame): Promise {
+ await frame.waitForFunction(() => Boolean((window as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready));
+}
+
+test("same-origin iframe observes its real OPFS placement", async ({ page }) => {
+ await page.goto(APP_URL);
+ const framePromise = page.waitForEvent("framenavigated", {
+ predicate: (frame) => frame !== page.mainFrame() && frame.url().includes("/tests/browser/fixtures/frame.html"),
+ });
+ await page.evaluate(() => {
+ const frame = document.createElement("iframe");
+ frame.src = "/tests/browser/fixtures/frame.html";
+ document.body.append(frame);
+ });
+ const frame = await framePromise;
+ await waitForApi(frame);
+ const result = await frame.evaluate(async () => await window.opfsTest.roundTrip(`/frames/${crypto.randomUUID()}.txt`, "same"));
+ expect(result.probe?.embedded).toBe(true);
+ expect(result.probe?.sameOriginTop).toBe(true);
+ if (result.probe?.rootAvailable) expect(result.value).toBe("same");
+ else expect(result.probe?.rootError).toBeDefined();
+});
+
+test("cross-origin iframe reports partition/policy behavior instead of guessing by browser", async ({ page }) => {
+ await page.goto(APP_URL);
+ const framePromise = page.waitForEvent("framenavigated", {
+ predicate: (frame) => frame.url().startsWith("http://127.0.0.1:4174/"),
+ });
+ await page.evaluate((url) => {
+ const frame = document.createElement("iframe");
+ frame.src = url;
+ document.body.append(frame);
+ }, CROSS_URL);
+ const frame = await framePromise;
+ await waitForApi(frame);
+ const probe = await frame.evaluate(async () => await window.opfsTest.probe());
+ expect(probe.embedded).toBe(true);
+ expect(probe.sameOriginTop).toBe(false);
+ if (!probe.rootAvailable) expect(probe.rootError).toBeDefined();
+});
+
+test("opaque sandbox reports the platform result without browser-name assumptions", async ({ page }) => {
+ await page.goto(APP_URL);
+ await page.evaluate(() => {
+ const frame = document.createElement("iframe");
+ frame.sandbox.add("allow-scripts");
+ frame.srcdoc = "";
+ document.body.append(frame);
+ });
+ const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!;
+ await frame.waitForFunction(() => (window as unknown as { ready?: boolean }).ready === true);
+ const result = await frame.evaluate(async () => {
+ const storage = navigator.storage as StorageManager & { getDirectory?: () => Promise };
+ if (typeof storage?.getDirectory !== "function") return { available: false, name: "NotSupportedError" };
+ try {
+ await storage.getDirectory();
+ return { available: true, name: null };
+ } catch (error) {
+ return { available: false, name: error instanceof DOMException ? error.name : "Error" };
+ }
+ });
+ expect(typeof result.available).toBe("boolean");
+ if (!result.available) expect(typeof result.name).toBe("string");
+});
diff --git a/tests/browser/iframe.ts b/tests/browser/iframe.ts
deleted file mode 100644
index b912cc0..0000000
--- a/tests/browser/iframe.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { openFileSystem, probeOpfs } from "../../mod.ts";
-
-const label = new URL(location.href).searchParams.get("label") ?? "iframe";
-try {
- const capabilities = await probeOpfs();
- const fs = await openFileSystem();
- const path = `/matrix/${label}.txt`;
- await fs.writeFile(path, label, { parents: true });
- parent.postMessage({ type: "iframe-result", label, ok: true, capabilities, text: await fs.readText(path) }, "*");
-} catch (error) {
- parent.postMessage({ type: "iframe-result", label, ok: false, name: error?.name, code: error?.code, message: error?.message }, "*");
-}
\ No newline at end of file
diff --git a/tests/browser/index.html b/tests/browser/index.html
deleted file mode 100644
index 3eb6441..0000000
--- a/tests/browser/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
- OPFS matrix running
-
-
-
- OPFS browser matrix
- running...
-
-
-
diff --git a/tests/browser/index.ts b/tests/browser/index.ts
deleted file mode 100644
index 49b8927..0000000
--- a/tests/browser/index.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { openFileSystem, probeOpfs } from "../../mod.ts";
-
-const results = {};
-const output = document.querySelector("#output");
-const record = (name, value) => {
- results[name] = value;
- output.textContent = JSON.stringify(results, null, 2);
-};
-
-async function windowCase() {
- try {
- const capabilities = await probeOpfs();
- const fs = await openFileSystem();
- await fs.writeFile("/matrix/window.txt", "window", { parents: true });
- return { ok: true, capabilities, text: await fs.readText("/matrix/window.txt") };
- } catch (error) {
- const err = error as Error & { code?: string };
- const name = err?.name;
- const code = err?.code;
- const message = err?.message;
- return { ok: false, name, code, message: error?.message };
- }
-}
-
-function workerCase() {
- return new Promise((resolve) => {
- const worker = new Worker("./dedicated-worker.mjs", { type: "module" });
- worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
- worker.onerror = (event) => { worker.terminate(); resolve({ ok: false, message: event.message }); };
- worker.postMessage("run");
- });
-}
-
-function sharedWorkerCase() {
- return new Promise((resolve) => {
- if (typeof SharedWorker !== "function") return resolve({ ok: false, unavailable: true });
- const worker = new SharedWorker("./shared-worker.mjs", { type: "module", name: "opfs-matrix" });
- worker.port.onmessage = (event) => resolve(event.data);
- worker.port.start();
- worker.port.postMessage("run");
- });
-}
-
-async function serviceWorkerCase() {
- if (!("serviceWorker" in navigator)) return { ok: false, unavailable: true };
- try {
- const registration = await navigator.serviceWorker.register("./service-worker.mjs", { type: "module", scope: "./" });
- await navigator.serviceWorker.ready;
- const active = registration.active ?? registration.waiting ?? registration.installing;
- if (!active) return { ok: false, message: "No service worker instance" };
- return await new Promise((resolve) => {
- const timeout = setTimeout(() => resolve({ ok: false, message: "Service worker result timed out" }), 5000);
- const listener = (event) => {
- if (event.data?.type !== "service-worker-result") return;
- clearTimeout(timeout);
- navigator.serviceWorker.removeEventListener("message", listener);
- resolve(event.data);
- };
- navigator.serviceWorker.addEventListener("message", listener);
- active.postMessage("run");
- });
- } catch (error) {
- return { ok: false, name: error?.name, message: error?.message };
- }
-}
-
-function iframeCase({ url, label, sandbox }) {
- return new Promise((resolve) => {
- const iframe = document.createElement("iframe");
- iframe.hidden = true;
- iframe.src = `${url}?label=${encodeURIComponent(label)}`;
- if (sandbox) iframe.sandbox = sandbox;
- const timeout = setTimeout(() => {
- window.removeEventListener("message", listener);
- iframe.remove();
- resolve({ ok: false, message: "iframe result timed out" });
- }, 5000);
- const listener = (event) => {
- if (event.data?.type !== "iframe-result" || event.data.label !== label) return;
- clearTimeout(timeout);
- window.removeEventListener("message", listener);
- iframe.remove();
- resolve(event.data);
- };
- window.addEventListener("message", listener);
- document.body.append(iframe);
- });
-}
-
-record("window", await windowCase());
-record("dedicatedWorker", await workerCase());
-record("sharedWorker", await sharedWorkerCase());
-record("serviceWorker", await serviceWorkerCase());
-record("sameOriginIframe", await iframeCase({ url: "./iframe.html", label: "same-origin" }));
-record("opaqueSandboxIframe", await iframeCase({ url: "./iframe.html", label: "opaque-sandbox", sandbox: "allow-scripts" }));
-record("crossOriginIframe", await iframeCase({ url: `http://127.0.0.1:${location.port}/.agents/browser/iframe.html`, label: "cross-origin" }));
-
-window.__OPFS_RESULTS__ = results;
-document.title = Object.values(results).every((result) => result && typeof result === "object") ? "OPFS matrix complete" : "OPFS matrix incomplete";
\ No newline at end of file
diff --git a/tests/browser/opfs.spec.ts b/tests/browser/opfs.spec.ts
new file mode 100644
index 0000000..e098b9b
--- /dev/null
+++ b/tests/browser/opfs.spec.ts
@@ -0,0 +1,66 @@
+import { chromium, expect, firefox, test, webkit } from "@playwright/test";
+
+/** Same-origin fixture page used by Window and persistence scenarios. */
+const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html";
+
+/** Opens the fixture page and waits for its OPFS test API. */
+async function ready(page: import("@playwright/test").Page): Promise {
+ await page.goto(APP_URL);
+ await page.waitForFunction(() => Boolean((window as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready));
+}
+
+test("window probes the actual capability and round-trips when OPFS is available", async ({ page }) => {
+ await ready(page);
+ const result = await page.evaluate(async () => await window.opfsTest.roundTrip(`/window/${crypto.randomUUID()}.txt`, "window"));
+ expect(result.supported).toBe(true);
+ expect(result.probe?.context).toBe("window");
+ if (result.probe?.rootAvailable) expect(result.value).toBe("window");
+ else expect(result.probe?.rootError).toBeDefined();
+});
+
+test("an aborted write cannot commit", async ({ page }) => {
+ await ready(page);
+ const result = await page.evaluate(async () => await window.opfsTest.abort(`/abort/${crypto.randomUUID()}.txt`));
+ expect(["AbortError", "unavailable"]).toContain(result);
+});
+
+test("fresh browser contexts do not inherit another context's OPFS file", async ({ browser }) => {
+ const path = `/isolation/${crypto.randomUUID()}.txt`;
+ const first = await browser.newContext();
+ const firstPage = await first.newPage();
+ await ready(firstPage);
+ const written = await firstPage.evaluate(async ({ path }) => await window.opfsTest.roundTrip(path, "private"), { path });
+ await first.close();
+ if (!written.probe?.rootAvailable) {
+ expect(written.probe?.rootError).toBeDefined();
+ return;
+ }
+
+ const second = await browser.newContext();
+ const secondPage = await second.newPage();
+ await ready(secondPage);
+ expect(await secondPage.evaluate(async ({ path }) => await window.opfsTest.read(path), { path })).toBeNull();
+ await second.close();
+});
+
+test("a persistent profile reopens the same OPFS data", async ({ browserName }, testInfo) => {
+ const browserType = browserName === "chromium" ? chromium : browserName === "firefox" ? firefox : webkit;
+ const profile = testInfo.outputPath("profile");
+ const path = `/persistence/${crypto.randomUUID()}.txt`;
+
+ const first = await browserType.launchPersistentContext(profile);
+ const firstPage = await first.newPage();
+ await ready(firstPage);
+ const written = await firstPage.evaluate(async ({ path }) => await window.opfsTest.roundTrip(path, "persisted"), { path });
+ await first.close();
+ if (!written.probe?.rootAvailable) {
+ expect(written.probe?.rootError).toBeDefined();
+ return;
+ }
+
+ const second = await browserType.launchPersistentContext(profile);
+ const secondPage = await second.newPage();
+ await ready(secondPage);
+ expect(await secondPage.evaluate(async ({ path }) => await window.opfsTest.read(path), { path })).toBe("persisted");
+ await second.close();
+});
diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts
new file mode 100644
index 0000000..32cf1d5
--- /dev/null
+++ b/tests/browser/playwright.config.ts
@@ -0,0 +1,39 @@
+import { defineConfig, devices } from "@playwright/test";
+import { env } from "node:process";
+
+/** Primary same-origin fixture server. */
+const first = "http://127.0.0.1:4173";
+/** Secondary origin used only for cross-origin iframe scenarios. */
+const second = "http://127.0.0.1:4174";
+/** Whether Playwright should enable CI-only retries and strict focused-test checks. */
+const ci = Boolean(env.CI);
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: "*.spec.ts",
+ fullyParallel: true,
+ forbidOnly: ci,
+ retries: ci ? 2 : 0,
+ reporter: ci ? "github" : "line",
+ use: {
+ baseURL: first,
+ trace: "retain-on-failure",
+ },
+ webServer: [
+ {
+ command: "deno run -A npm:vite@8.2.1 --host 127.0.0.1 --port 4173 --strictPort",
+ url: first,
+ reuseExistingServer: !ci,
+ },
+ {
+ command: "deno run -A npm:vite@8.2.1 --host 127.0.0.1 --port 4174 --strictPort",
+ url: second,
+ reuseExistingServer: !ci,
+ },
+ ],
+ projects: [
+ { name: "chromium", use: { ...devices["Desktop Chrome"] } },
+ { name: "firefox", use: { ...devices["Desktop Firefox"] } },
+ { name: "webkit", use: { ...devices["Desktop Safari"] } },
+ ],
+});
diff --git a/tests/browser/service-worker.spec.ts b/tests/browser/service-worker.spec.ts
new file mode 100644
index 0000000..553a58f
--- /dev/null
+++ b/tests/browser/service-worker.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from "@playwright/test";
+
+/** Same-origin fixture page that registers and communicates with the ServiceWorker. */
+const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html";
+
+/** Opens the fixture page and waits for its OPFS test API. */
+async function ready(page: import("@playwright/test").Page): Promise {
+ await page.goto(APP_URL);
+ await page.waitForFunction(() => Boolean((window as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready));
+}
+
+test("ServiceWorker behavior is verified through page messaging in every browser", async ({ page }) => {
+ await ready(page);
+ const result = await page.evaluate(async () =>
+ await window.opfsTest.service(`/service/${crypto.randomUUID()}.txt`, "service")
+ );
+ test.skip(!result.supported, "ServiceWorker is not exposed in this browser context.");
+ expect(result.probe?.context).toBe("service-worker");
+ if (result.probe?.rootAvailable) expect(result.value).toBe("service");
+ else expect(result.probe?.rootError).toBeDefined();
+});
+
+test("Chromium exposes the registered service worker to Playwright instrumentation", async ({ browserName, context, page }) => {
+ test.skip(browserName !== "chromium", "Playwright serviceWorkers() inspection is Chromium-only.");
+ await ready(page);
+ const result = await page.evaluate(async () =>
+ await window.opfsTest.service(`/service/${crypto.randomUUID()}.txt`, "instrumented")
+ );
+ test.skip(!result.supported, "ServiceWorker is not exposed in this Chromium context.");
+ expect(context.serviceWorkers().length).toBeGreaterThan(0);
+});
diff --git a/tests/browser/service-worker.ts b/tests/browser/service-worker.ts
deleted file mode 100644
index abb2473..0000000
--- a/tests/browser/service-worker.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-///
-///
-///
-import { openFileSystem, probeOpfs } from "../../mod.ts";
-
-// Default type of `self` is `WorkerGlobalScope & typeof globalThis`
-// https://github.com/microsoft/TypeScript/issues/14877
-declare var self: ServiceWorkerGlobalScope;
-
-self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
-self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
-self.addEventListener("message", (event) => {
- const work = (async () => {
- try {
- const capabilities = await probeOpfs();
- const fs = await openFileSystem();
- await fs.writeFile("/matrix/service.txt", "service-worker", { parents: true });
- event.source?.postMessage({ type: "service-worker-result", ok: true, capabilities, text: await fs.readText("/matrix/service.txt") });
- } catch (error) {
- const err = error as Error & { code?: string };
- const name = err?.name;
- const code = err?.code;
- const message = err?.message;
- event.source?.postMessage({ type: "service-worker-result", ok: false, name, code, message });
- }
- })();
- event.waitUntil(work);
-});
diff --git a/tests/browser/shared-worker.ts b/tests/browser/shared-worker.ts
deleted file mode 100644
index 2d247a5..0000000
--- a/tests/browser/shared-worker.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-///
-///
-///
-import { openFileSystem, probeOpfs } from "../../mod.ts";
-
-// Default type of `self` is `WorkerGlobalScope & typeof globalThis`
-// https://github.com/microsoft/TypeScript/issues/14877
-declare var self: SharedWorkerGlobalScope;
-
-self.onconnect = (event) => {
- const port = event.ports[0];
- port.onmessage = async () => {
- try {
- const capabilities = await probeOpfs();
- const fs = await openFileSystem();
- await fs.writeFile("/matrix/shared.txt", "shared-worker", { parents: true });
- port.postMessage({ ok: true, capabilities, text: await fs.readText("/matrix/shared.txt") });
- } catch (error) {
- const err = error as Error & { code?: string };
- const name = err?.name;
- const code = err?.code;
- const message = err?.message;
- port.postMessage({ ok: false, name, code, message });
- }
- };
- port.start();
-};
\ No newline at end of file
diff --git a/tests/browser/worker.spec.ts b/tests/browser/worker.spec.ts
new file mode 100644
index 0000000..8113d1d
--- /dev/null
+++ b/tests/browser/worker.spec.ts
@@ -0,0 +1,36 @@
+import { expect, test } from "@playwright/test";
+
+/** Same-origin fixture page that creates real DedicatedWorker and SharedWorker instances. */
+const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html";
+
+/** Opens the fixture page and waits for its OPFS test API. */
+async function ready(page: import("@playwright/test").Page): Promise {
+ await page.goto(APP_URL);
+ await page.waitForFunction(() => Boolean((window as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready));
+}
+
+test("DedicatedWorker uses real OPFS and probes synchronous access", async ({ page }) => {
+ await ready(page);
+ const result = await page.evaluate(async () =>
+ await window.opfsTest.dedicated(`/dedicated/${crypto.randomUUID()}.txt`, "dedicated")
+ );
+ test.skip(!result.supported, "DedicatedWorker is not exposed in this browser context.");
+ expect(result.probe?.context).toBe("dedicated-worker");
+ if (result.probe?.rootAvailable) {
+ expect(result.value).toBe("dedicated");
+ if (result.probe.syncAccessHandleExposed && !result.syncOpened) expect(result.syncError).toBeDefined();
+ } else {
+ expect(result.probe?.rootError).toBeDefined();
+ }
+});
+
+test("SharedWorker uses the browser's actual storage capability", async ({ page }) => {
+ await ready(page);
+ const result = await page.evaluate(async () =>
+ await window.opfsTest.shared(`/shared/${crypto.randomUUID()}.txt`, "shared")
+ );
+ test.skip(!result.supported, "SharedWorker is not exposed in this browser context.");
+ expect(["shared-worker", "worker"]).toContain(result.probe?.context);
+ if (result.probe?.rootAvailable) expect(result.value).toBe("shared");
+ else expect(result.probe?.rootError).toBeDefined();
+});
diff --git a/tests/bun.test.ts b/tests/bun.test.ts
new file mode 100644
index 0000000..34b13b1
--- /dev/null
+++ b/tests/bun.test.ts
@@ -0,0 +1,34 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { createFileSystem } from "../mod.ts";
+import { createBunAdapter } from "../src/adapter/bun.ts";
+
+describe("Bun adapter", () => {
+ it("uses real Bun file and synchronous filesystem APIs", async () => {
+ const root = await mkdtemp(join(tmpdir(), "okikio-opfs-bun-"));
+ const fileSystem = createFileSystem(createBunAdapter({ root }), { coordination: "local" });
+ try {
+ await fileSystem.writeFile("/nested/file.txt", "bun", { parents: true });
+ expect(await fileSystem.readText("/nested/file.txt")).toBe("bun");
+ const sync = await fileSystem.openSyncFile("/nested/file.txt");
+ sync.writeAll(new TextEncoder().encode("BUN"), { at: 0 });
+ sync.flush();
+ let queued = false;
+ const write = fileSystem.writeFile("/nested/file.txt", "after-sync").then(() => {
+ queued = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(queued).toBe(false);
+ sync.close();
+ await write;
+ expect(await fileSystem.readText("/nested/file.txt")).toBe("after-sync");
+ } finally {
+ await fileSystem.close();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/tests/chunk.test.ts b/tests/chunk.test.ts
new file mode 100644
index 0000000..851e19a
--- /dev/null
+++ b/tests/chunk.test.ts
@@ -0,0 +1,35 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { split } from "../src/chunk.ts";
+
+describe("byte stream chunking", () => {
+ it("forms fixed chunks from pathological one-byte producer chunks", async () => {
+ let value = 0;
+ const source = new ReadableStream({
+ pull(controller) {
+ if (value === 10) {
+ controller.close();
+ return;
+ }
+ controller.enqueue(Uint8Array.of(value));
+ value += 1;
+ },
+ });
+
+ const output: number[][] = [];
+ for await (const chunk of split(source, 4)) output.push([...chunk]);
+ expect(output).toEqual([[0, 1, 2, 3], [4, 5, 6, 7], [8, 9]]);
+ });
+
+ it("cancels the source when the consumer stops before the stream ends", async () => {
+ let cancelled = false;
+ const source = new ReadableStream({
+ pull(controller) { controller.enqueue(new Uint8Array(8)); },
+ cancel() { cancelled = true; },
+ });
+
+ for await (const _chunk of split(source, 4)) break;
+ expect(cancelled).toBe(true);
+ });
+});
diff --git a/tests/consumer.window.ts b/tests/consumer.window.ts
deleted file mode 100644
index ccd8571..0000000
--- a/tests/consumer.window.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { openFileSystem, probeOpfs, type WalkEntryType } from "../mod.ts";
-import { joinPath } from "../src/path.ts";
-
-const capabilities = await probeOpfs();
-if (capabilities.rootAvailable) {
- const fileSystem = await openFileSystem();
- await fileSystem.writeFile(
- joinPath("cache", "record.json"),
- JSON.stringify({ ok: true }),
- { parents: true },
- );
- for await (const entry of fileSystem.walk("/cache")) {
- const typed: WalkEntryType = entry;
- void typed.path;
- }
-}
diff --git a/tests/consumer.worker.ts b/tests/consumer.worker.ts
deleted file mode 100644
index 05c8040..0000000
--- a/tests/consumer.worker.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { openFileSystem, probeOpfs, type SyncFileType } from "../mod.ts";
-
-const capabilities = await probeOpfs();
-if (capabilities.rootAvailable && capabilities.syncAccessHandleExposed && capabilities.syncAccessHandleAllowedByContext) {
- const fileSystem = await openFileSystem();
- const file: SyncFileType = await fileSystem.openSyncFile("/cache/data.bin", {
- create: true,
- parents: true,
- });
- try {
- file.writeAll(new Uint8Array([1, 2, 3]));
- file.flush();
- } finally {
- file.close();
- }
-}
diff --git a/tests/deno-kv-partition.test.ts b/tests/deno-kv-partition.test.ts
new file mode 100644
index 0000000..b54e2f4
--- /dev/null
+++ b/tests/deno-kv-partition.test.ts
@@ -0,0 +1,306 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createFileSystem, FileSystemError } from "../mod.ts";
+import {
+ createDenoKvAdapter,
+ DENO_KV_MAX_VALUE_BYTES,
+ type DenoKvEntryType,
+ type DenoKvType,
+} from "../src/adapter/deno-kv.ts";
+
+/** Stable JSON-ish key string used only by the in-memory Deno KV contract double. */
+function id(key: readonly unknown[]): string {
+ return JSON.stringify(key);
+}
+
+/** Returns whether one tuple starts with another tuple. */
+function starts(key: readonly unknown[], prefix: readonly unknown[]): boolean {
+ return prefix.every((value, index) => key[index] === value);
+}
+
+/** Conservative serialized-size estimate that rejects oversized test values before storage. */
+function size(value: unknown): number {
+ if (value instanceof Uint8Array) return value.byteLength;
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
+}
+
+/**
+ * Deno KV contract double with the documented per-value ceiling enforced.
+ *
+ * It deliberately exposes stored tuples so tests can prove partition cleanup and
+ * listing behavior without depending on a Deno executable in the portable suite.
+ */
+class FakeDenoKv implements DenoKvType {
+ readonly values = new Map();
+ partGets = 0;
+ listMatches = 0;
+
+ async get(key: readonly unknown[]): Promise> {
+ if (key[1] === "part") this.partGets += 1;
+ const found = this.values.get(id(key));
+ return { key, value: (found?.value as T | undefined) ?? null };
+ }
+
+ async set(key: readonly unknown[], value: unknown): Promise {
+ if (size(value) > DENO_KV_MAX_VALUE_BYTES) throw new RangeError("Deno KV value exceeds 64 KiB");
+ this.values.set(id(key), { key: [...key], value });
+ }
+
+ async delete(key: readonly unknown[]): Promise {
+ this.values.delete(id(key));
+ }
+
+ async *list(selector: { readonly prefix: readonly unknown[] }): AsyncIterable> {
+ for (const entry of this.values.values()) {
+ if (!starts(entry.key, selector.prefix)) continue;
+ this.listMatches += 1;
+ yield { key: entry.key, value: entry.value as T };
+ }
+ }
+}
+
+/** Deterministic byte fixture with enough entropy to make accidental truncation visible. */
+function bytes(length: number): Uint8Array {
+ return Uint8Array.from({ length }, (_, index) => index % 251);
+}
+
+describe("Deno KV partitioned records", () => {
+ it("stores a large logical file below the physical value ceiling and reconstructs it exactly", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), {
+ coordination: "none",
+ metrics: "basic",
+ });
+ const input = bytes(220 * 1024);
+
+ await fileSystem.writeFile("/large.bin", input);
+
+ expect(await fileSystem.readFile("/large.bin")).toEqual(input);
+ const inspection = fileSystem.inspect();
+ expect(inspection.partition?.layout).toBe("deno-kv-parts-v2");
+ expect(inspection.limits.maxValueBytes).toBe(DENO_KV_MAX_VALUE_BYTES);
+ expect(fileSystem.plan({ operation: "write", source: "bytes", size: input.byteLength }).support).toBe("partitioned");
+ expect([...database.values.values()].every((entry) => size(entry.value) <= DENO_KV_MAX_VALUE_BYTES)).toBe(true);
+ await fileSystem.close();
+ });
+
+ it("indexes directory listings by direct parent instead of the complete descendant path prefix", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database), { coordination: "none" });
+ await fileSystem.writeFile("/tree/root.bin", new Uint8Array([1]), { parents: true });
+ await fileSystem.writeFile("/tree/child/leaf.bin", new Uint8Array([2]), { parents: true });
+ await fileSystem.writeFile("/tree/child/grand/deep.bin", new Uint8Array([3]), { parents: true });
+ database.listMatches = 0;
+
+ const entries: string[] = [];
+ for await (const entry of fileSystem.readDir("/tree")) entries.push(`${entry.kind}:${entry.name}`);
+
+ expect(entries.sort()).toEqual(["directory:child", "file:root.bin"]);
+ expect(database.listMatches).toBe(2);
+ await fileSystem.close();
+ });
+
+ it("lists partitioned file metadata without loading its physical body parts", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database), { coordination: "none" });
+ await fileSystem.writeFile("/large.bin", bytes(160 * 1024));
+ database.partGets = 0;
+
+ const names: string[] = [];
+ for await (const entry of fileSystem.readDir("/")) names.push(entry.name);
+
+ expect(names).toEqual(["large.bin"]);
+ expect(database.partGets).toBe(0);
+ await fileSystem.close();
+ });
+
+
+ it("stats and ranges avoid reconstructing unrelated partition bodies", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), { coordination: "none" });
+ const input = bytes(180 * 1024);
+ await fileSystem.writeFile("/large.bin", input);
+
+ database.partGets = 0;
+ const stat = await fileSystem.stat("/large.bin");
+ expect(stat.kind).toBe("file");
+ if (stat.kind === "file") expect(stat.size).toBe(input.byteLength);
+ expect(database.partGets).toBe(0);
+
+ const range = await fileSystem.readFile("/large.bin", { at: 50 * 1024, length: 2048 });
+ expect(range).toEqual(input.slice(50 * 1024, 52 * 1024));
+ expect(database.partGets).toBe(1);
+ await fileSystem.close();
+ });
+
+ it("streams large replacements through the partition lane without facade buffering", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), {
+ coordination: "none",
+ metrics: "basic",
+ });
+ const input = bytes(190 * 1024);
+ const source = new ReadableStream({
+ start(controller) {
+ for (let at = 0; at < input.byteLength; at += 7 * 1024) controller.enqueue(input.slice(at, at + 7 * 1024));
+ controller.close();
+ },
+ });
+
+ const plan = fileSystem.plan({ operation: "write", source: "stream", mode: "replace" });
+ expect(plan.support).toBe("partitioned");
+ await fileSystem.writeFile("/stream.bin", source);
+
+ expect(await fileSystem.readFile("/stream.bin")).toEqual(input);
+ expect(fileSystem.getMetrics().peakBufferedBytes).toBe(0);
+ expect(fileSystem.getMetrics().operations.write?.partitioned).toBe(1);
+ await fileSystem.close();
+ });
+
+ it("can disable the partitioned stream optimization and force the bounded facade fallback", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database), {
+ coordination: "none",
+ metrics: "basic",
+ optimizations: { streamWrite: false },
+ maxBufferedWriteBytes: 256 * 1024,
+ });
+ const input = bytes(96 * 1024);
+ const source = new ReadableStream({
+ start(controller) {
+ controller.enqueue(input);
+ controller.close();
+ },
+ });
+
+ expect(fileSystem.inspect().support.streamWrite.replace).toBe("emulated");
+ expect(fileSystem.plan({
+ operation: "write",
+ source: "stream",
+ size: input.byteLength,
+ inputBytes: input.byteLength,
+ }).bufferBytes).toBe(input.byteLength);
+ await fileSystem.writeFile("/fallback.bin", source);
+
+ expect(await fileSystem.readFile("/fallback.bin")).toEqual(input);
+ expect(fileSystem.getMetrics().peakBufferedBytes).toBeGreaterThanOrEqual(input.byteLength);
+ expect(fileSystem.getMetrics().operations.write?.emulated).toBe(1);
+ await fileSystem.close();
+ });
+
+ it("classifies a small append by the resulting partitioned logical file size", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database), {
+ coordination: "none",
+ metrics: "basic",
+ });
+ await fileSystem.writeFile("/value.bin", bytes(96 * 1024));
+ const before = fileSystem.getMetrics().operations.write?.partitioned ?? 0;
+
+ await fileSystem.writeFile("/value.bin", new Uint8Array([1, 2, 3]), { mode: "append" });
+
+ expect(fileSystem.getMetrics().operations.write?.partitioned).toBe(before + 1);
+ const stat = await fileSystem.stat("/value.bin");
+ expect(stat.kind).toBe("file");
+ if (stat.kind === "file") expect(stat.size).toBe(96 * 1024 + 3);
+ await fileSystem.close();
+ });
+
+ it("plans stream append input buffering separately from the resulting large file", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database), {
+ coordination: "none",
+ maxBufferedWriteBytes: 64 * 1024,
+ });
+
+ const plan = fileSystem.plan({
+ operation: "write",
+ source: "stream",
+ mode: "append",
+ size: 300 * 1024 * 1024,
+ inputBytes: 1024,
+ });
+
+ expect(plan.supported).toBe(true);
+ expect(plan.support).toBe("partitioned");
+ expect(plan.bufferBytes).toBe(1024);
+ await fileSystem.close();
+ });
+
+ it("patches partitioned append and update writes without changing untouched bytes", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), {
+ coordination: "none",
+ });
+ const initial = bytes(150 * 1024);
+ await fileSystem.writeFile("/patch.bin", initial);
+
+ const appended = new Uint8Array([9, 8, 7, 6]);
+ await fileSystem.writeFile("/patch.bin", appended, { mode: "append" });
+ const afterAppend = await fileSystem.readFile("/patch.bin");
+ expect(afterAppend.slice(0, initial.byteLength)).toEqual(initial);
+ expect(afterAppend.slice(initial.byteLength)).toEqual(appended);
+
+ const patch = new Uint8Array([1, 3, 5, 7, 9]);
+ const at = 47 * 1024 + 11;
+ await fileSystem.writeFile("/patch.bin", patch, { mode: "update", at });
+ const expected = afterAppend.slice();
+ expected.set(patch, at);
+ expect(await fileSystem.readFile("/patch.bin")).toEqual(expected);
+ await fileSystem.close();
+ });
+
+ it("preserves zero-filled gaps and truncate semantics in direct partitioned updates", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), {
+ coordination: "none",
+ });
+ await fileSystem.writeFile("/gap.bin", bytes(96 * 1024));
+
+ const at = 120 * 1024;
+ await fileSystem.writeFile("/gap.bin", new Uint8Array([4, 5]), { mode: "update", at });
+ const expanded = await fileSystem.readFile("/gap.bin");
+ expect(expanded.byteLength).toBe(at + 2);
+ expect(expanded.slice(96 * 1024, at).every((value) => value === 0)).toBe(true);
+ expect([...expanded.slice(at)]).toEqual([4, 5]);
+
+ await fileSystem.writeFile("/gap.bin", new Uint8Array([6, 7, 8]), {
+ mode: "update",
+ at: 32 * 1024,
+ truncate: true,
+ });
+ const truncated = await fileSystem.readFile("/gap.bin");
+ expect(truncated.byteLength).toBe(32 * 1024 + 3);
+ expect([...truncated.slice(-3)]).toEqual([6, 7, 8]);
+ await fileSystem.close();
+ });
+
+ it("removes the previous partition generation after a successful smaller overwrite", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database), { coordination: "none" });
+ await fileSystem.writeFile("/value.bin", bytes(180 * 1024));
+ expect([...database.values.values()].some((entry) => entry.key[1] === "part")).toBe(true);
+
+ await fileSystem.writeFile("/value.bin", new Uint8Array([1, 2, 3]));
+
+ expect([...database.values.values()].some((entry) => entry.key[1] === "part")).toBe(false);
+ expect([...await fileSystem.readFile("/value.bin")]).toEqual([1, 2, 3]);
+ await fileSystem.close();
+ });
+
+ it("fails before the provider rejects a large inline value when partitioning is disabled", async () => {
+ const database = new FakeDenoKv();
+ const fileSystem = createFileSystem(createDenoKvAdapter(database, { partition: "never" }), { coordination: "none" });
+
+ try {
+ await fileSystem.writeFile("/too-large.bin", bytes(80 * 1024));
+ throw new Error("expected too-large failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(FileSystemError);
+ if (error instanceof FileSystemError) expect(error.code).toBe("too-large");
+ } finally {
+ await fileSystem.close();
+ }
+ });
+});
diff --git a/tests/deno-kv.test.ts b/tests/deno-kv.test.ts
new file mode 100644
index 0000000..d3a759d
--- /dev/null
+++ b/tests/deno-kv.test.ts
@@ -0,0 +1,30 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createFileSystem } from "../mod.ts";
+import { createDenoKvAdapter } from "../src/adapter/deno-kv.ts";
+
+describe("Deno KV adapter", () => {
+ it("executes against a real local Deno KV database", async () => {
+ const path = await Deno.makeTempFile({ prefix: "okikio-opfs-kv-" });
+ await Deno.remove(path);
+ const database = await Deno.openKv(path);
+ const fileSystem = createFileSystem(createDenoKvAdapter(database), { coordination: "none" });
+ try {
+ await fileSystem.writeFile("/kv/value.txt", "deno-kv", { parents: true });
+ expect(await fileSystem.readText("/kv/value.txt")).toBe("deno-kv");
+ expect((await fileSystem.stat("/kv")).kind).toBe("directory");
+
+ const large = Uint8Array.from({ length: 180 * 1024 }, (_, index) => index % 251);
+ await fileSystem.writeFile("/kv/large.bin", large);
+ expect(await fileSystem.readFile("/kv/large.bin", { at: 70 * 1024, length: 4096 })).toEqual(
+ large.slice(70 * 1024, 74 * 1024),
+ );
+ expect(await fileSystem.readFile("/kv/large.bin")).toEqual(large);
+ } finally {
+ await fileSystem.close();
+ database.close();
+ await Deno.remove(path).catch(() => undefined);
+ }
+ });
+});
diff --git a/tests/deno.test.ts b/tests/deno.test.ts
new file mode 100644
index 0000000..595105b
--- /dev/null
+++ b/tests/deno.test.ts
@@ -0,0 +1,31 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createFileSystem } from "../mod.ts";
+import { createDenoAdapter } from "../src/adapter/deno.ts";
+
+describe("Deno adapter", () => {
+ it("uses real Deno filesystem and synchronous file APIs", async () => {
+ const root = await Deno.makeTempDir({ prefix: "okikio-opfs-" });
+ const fileSystem = createFileSystem(createDenoAdapter({ root }), { coordination: "local" });
+ try {
+ await fileSystem.writeFile("/nested/file.txt", "deno", { parents: true });
+ expect(await fileSystem.readText("/nested/file.txt")).toBe("deno");
+ const sync = await fileSystem.openSyncFile("/nested/file.txt");
+ sync.writeAll(new TextEncoder().encode("DENO"), { at: 0 });
+ sync.flush();
+ let queued = false;
+ const write = fileSystem.writeFile("/nested/file.txt", "after-sync").then(() => {
+ queued = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(queued).toBe(false);
+ sync.close();
+ await write;
+ expect(await fileSystem.readText("/nested/file.txt")).toBe("after-sync");
+ } finally {
+ await fileSystem.close();
+ await Deno.remove(root, { recursive: true });
+ }
+ });
+});
diff --git a/tests/ecosystems.test.ts b/tests/ecosystems.test.ts
new file mode 100644
index 0000000..bed1489
--- /dev/null
+++ b/tests/ecosystems.test.ts
@@ -0,0 +1,355 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createFileSystem } from "../mod.ts";
+import { createDb0Adapter } from "../src/adapter/db0.ts";
+import { createDrizzleAdapter } from "../src/adapter/drizzle.ts";
+import { createMemoryAdapter } from "../src/adapter/memory.ts";
+import { createRxDbAdapter, RxDbRecordJsonSchema } from "../src/adapter/rxdb.ts";
+import { createUnstorageAdapter } from "../src/adapter/unstorage.ts";
+import { createKeyValueDriver } from "../src/driver/kv.ts";
+import { createUnstorageDriver } from "../src/driver/unstorage.ts";
+import { Db0Bridge, DrizzleBridge, KeyValueBridge, RxDbBridge, UnstorageBridge } from "../src/bridge.ts";
+import { defineBridge } from "../src/bridge/definition.ts";
+
+/** In-memory unstorage-shaped resource used to verify forward adapter semantics and disposal ownership. */
+class MemoryUnstorage {
+ /** Stored unstorage values keyed exactly as the adapter writes them. */
+ #values = new Map();
+ /** Records whether explicit database ownership was disposed. */
+ disposed = false;
+
+ /** Returns one cloned value so tests cannot pass through shared object identity. */
+ async getItem(key: string): Promise {
+ return structuredClone(this.#values.get(key) ?? null);
+ }
+
+ /** Replaces one key with a cloned value. */
+ async setItem(key: string, value: unknown): Promise {
+ this.#values.set(key, structuredClone(value));
+ }
+
+ /** Removes one exact unstorage key. */
+ async removeItem(key: string): Promise {
+ this.#values.delete(key);
+ }
+
+ /** Lists keys under the requested unstorage prefix. */
+ async getKeys(base = ""): Promise {
+ return [...this.#values.keys()].filter((key) => key.startsWith(base));
+ }
+
+ /** Marks the database disposed for adapter-ownership assertions. */
+ async dispose(): Promise {
+ this.disposed = true;
+ }
+}
+
+/** Minimal RxDocument-shaped value that preserves JSON reads and incremental removal. */
+class FakeRxDocument {
+ /** Current stored record represented by this document. */
+ readonly record: Record;
+ /** Collection callback that removes this document by primary key. */
+ readonly remove: () => void;
+
+ /** Creates a document view over one stored record and its removal operation. */
+ constructor(record: Record, remove: () => void) {
+ this.record = record;
+ this.remove = remove;
+ }
+
+ /** Returns a detached JSON representation like RxDocument.toJSON(). */
+ toJSON(): Record {
+ return structuredClone(this.record);
+ }
+
+ /** Removes this document through the collection callback. */
+ async incrementalRemove(): Promise {
+ this.remove();
+ }
+}
+
+/** Minimal RxCollection-shaped store used to verify the collection-level RxDB bridge. */
+class FakeRxCollection {
+ /** Records keyed by the OPFS path primary key. */
+ #records = new Map>();
+
+ /** Returns the RxDB query shape used by the adapter for one primary-key lookup. */
+ findOne(path: string) {
+ return {
+ exec: async () => {
+ const record = this.#records.get(path);
+ return record === undefined
+ ? null
+ : new FakeRxDocument(record, () => this.#records.delete(path));
+ },
+ };
+ }
+
+ /** Returns direct children selected by their stored parent path. */
+ find({ selector }: { selector: { parent: string } }) {
+ return {
+ exec: async () => [...this.#records.values()]
+ .filter((record) => record.parent === selector.parent)
+ .map((record) => new FakeRxDocument(record, () => this.#records.delete(String(record.path)))),
+ };
+ }
+
+ /** Replaces one record using RxDB incremental-upsert semantics. */
+ async incrementalUpsert(record: Record): Promise {
+ this.#records.set(String(record.path), structuredClone(record));
+ }
+}
+
+/** db0-shaped database double that records generated SQL for every supported dialect branch. */
+class FakeDb0Database {
+ /** Dialect exposed to db0 SQL generation. */
+ readonly dialect: "sqlite" | "libsql" | "postgresql" | "mysql";
+ /** Logical rows keyed by the adapter hash identity. */
+ #rows = new Map>();
+ /** SQL statements prepared by the adapter, retained for dialect assertions. */
+ readonly prepared: string[] = [];
+ /** Records whether explicit database ownership was disposed. */
+ disposed = false;
+
+ /** Selects the db0 dialect branch exercised by this database double. */
+ constructor(dialect: "sqlite" | "libsql" | "postgresql" | "mysql") {
+ this.dialect = dialect;
+ }
+
+ /** Creates the small statement contract required by the db0 adapter. */
+ prepare(sql: string) {
+ this.prepared.push(sql);
+ const rows = this.#rows;
+ if (sql.startsWith("CREATE TABLE")) {
+ return { all: async () => [], get: async () => undefined, run: async () => ({ success: true }) };
+ }
+ if (sql.startsWith("SELECT") && sql.includes("WHERE id")) {
+ return {
+ all: async () => [],
+ get: async (id: string) => rows.get(id),
+ run: async () => ({ success: true }),
+ };
+ }
+ if (sql.startsWith("SELECT") && sql.includes("WHERE parent_path")) {
+ return {
+ all: async (parent: string) => [...rows.values()].filter((row) => row.parent_path === parent),
+ get: async () => undefined,
+ run: async () => ({ success: true }),
+ };
+ }
+ if (sql.startsWith("INSERT")) {
+ return {
+ all: async () => [],
+ get: async () => undefined,
+ run: async (...values: unknown[]) => {
+ const [id, path, parentPath, name, kind, data, size, lastModified, mediaType] = values;
+ rows.set(String(id), {
+ path,
+ parent_path: parentPath,
+ name,
+ kind,
+ data,
+ size,
+ last_modified: lastModified,
+ media_type: mediaType,
+ });
+ return { success: true };
+ },
+ };
+ }
+ if (sql.startsWith("DELETE")) {
+ return {
+ all: async () => [],
+ get: async () => undefined,
+ run: async (id: string) => {
+ rows.delete(id);
+ return { success: true };
+ },
+ };
+ }
+ throw new Error(`Unexpected SQL: ${sql}`);
+ }
+
+ /** Marks the database disposed for adapter-ownership assertions. */
+ async dispose(): Promise {
+ this.disposed = true;
+ }
+}
+
+/** Creates a minimal Drizzle CRUD surface and caller-owned table mapping for bridge tests. */
+function createFakeDrizzle() {
+ const table = {
+ path: { name: "path" },
+ parent: { name: "parent" },
+ name: { name: "name" },
+ kind: { name: "kind" },
+ data: { name: "data" },
+ size: { name: "size" },
+ lastModified: { name: "lastModified" },
+ mediaType: { name: "mediaType" },
+ };
+ const rows: Array> = [];
+ const database = {
+ select() {
+ return {
+ from() {
+ return {
+ where(condition: { column: { name: string }; value: unknown }) {
+ const selected = () => rows
+ .filter((row) => row[condition.column.name] === condition.value)
+ .map((row) => ({ ...row }));
+ return {
+ then(resolve: (value: Record[]) => unknown, reject: (reason: unknown) => unknown) {
+ return Promise.resolve(selected()).then(resolve, reject);
+ },
+ limit(count: number) {
+ return Promise.resolve(selected().slice(0, count));
+ },
+ };
+ },
+ };
+ },
+ };
+ },
+ delete() {
+ return {
+ where(condition: { column: { name: string }; value: unknown }) {
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
+ if (rows[index]?.[condition.column.name] === condition.value) rows.splice(index, 1);
+ }
+ return Promise.resolve();
+ },
+ };
+ },
+ insert() {
+ return {
+ values(value: Record) {
+ rows.push({ ...value });
+ return Promise.resolve();
+ },
+ };
+ },
+ };
+ return { database, table };
+}
+
+/** Exercises the common record-backed filesystem contract against one ecosystem adapter. */
+async function exerciseRecordBackend(fileSystem: ReturnType): Promise {
+ await fileSystem.writeFile("/records/a.txt", "A", { parents: true });
+ await fileSystem.writeFile("/records/b.txt", "B", { parents: true });
+ expect(await fileSystem.readText("/records/a.txt")).toBe("A");
+ const names: string[] = [];
+ for await (const entry of fileSystem.readDir("/records")) names.push(entry.name);
+ names.sort();
+ expect(names).toEqual(["a.txt", "b.txt"]);
+ await fileSystem.move("/records/a.txt", "/records/c.txt");
+ expect(await fileSystem.exists("/records/a.txt")).toBe(false);
+ expect(await fileSystem.readText("/records/c.txt")).toBe("A");
+}
+
+describe("ecosystem adapters", () => {
+ it("reports bridge directions and concrete unsupported reasons", () => {
+ expect(UnstorageBridge.directions.toOpfs.supported).toBe(true);
+ expect(UnstorageBridge.directions.fromOpfs.supported).toBe(true);
+
+ for (const bridge of [RxDbBridge, Db0Bridge, DrizzleBridge]) {
+ expect(bridge.directions.toOpfs.supported).toBe(true);
+ expect(bridge.directions.fromOpfs.supported).toBe(false);
+ expect(bridge.directions.fromOpfs.reason?.length).toBeGreaterThan(0);
+ }
+ expect(KeyValueBridge.directions.toOpfs.supported).toBe(false);
+ expect(KeyValueBridge.directions.toOpfs.reason?.length).toBeGreaterThan(0);
+ expect(KeyValueBridge.directions.fromOpfs.supported).toBe(true);
+ });
+
+ it("rejects a third-party bridge that hides why a direction is unsupported", () => {
+ expect(() => defineBridge({
+ name: "invalid-bridge",
+ directions: {
+ toOpfs: { supported: false },
+ fromOpfs: { supported: false },
+ },
+ })).toThrow();
+ });
+
+ it("uses the high-level unstorage contract and explicit disposal ownership", async () => {
+ const storage = new MemoryUnstorage();
+ const adapter = createUnstorageAdapter(storage as never, { disposeStorage: true });
+ const fileSystem = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
+ await exerciseRecordBackend(fileSystem);
+ await fileSystem.close();
+ expect(storage.disposed).toBe(true);
+ });
+
+ it("exposes any filesystem through the reusable key-value driver", async () => {
+ const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: "none" });
+ const driver = createKeyValueDriver(fileSystem);
+ expect(driver.inspect().adapter).toBe("memory");
+ expect(driver.plan({ operation: "write", source: "stream", mode: "replace", size: 1024 }).support).toBe("emulated");
+ await driver.set("prefix", "parent-value");
+ await driver.set("prefix:child", "child-value");
+ await driver.setRaw("binary", new Uint8Array([1, 2, 3]));
+
+ expect(await driver.get("prefix")).toBe("parent-value");
+ expect(await driver.get("prefix:child")).toBe("child-value");
+ expect([...(await driver.getRaw("binary"))!]).toEqual([1, 2, 3]);
+ expect(await driver.keys()).toEqual(expect.arrayContaining(["prefix", "prefix:child", "binary"]));
+ expect(driver.getMetrics().operations.write?.count).toBeGreaterThan(0);
+
+ await driver.clear("prefix", { preserveExact: true });
+ expect(await driver.get("prefix")).toBe("parent-value");
+ expect(await driver.get("prefix:child")).toBe(null);
+ await fileSystem.close();
+ });
+
+ it("exposes any filesystem as an unstorage driver without key collisions", async () => {
+ const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: "local" });
+ const driver = createUnstorageDriver(fileSystem);
+ expect(driver.inspect().adapter).toBe("memory");
+ expect(driver.plan({ operation: "write", source: "bytes", mode: "replace", size: 3 }).supported).toBe(true);
+ await driver.setItem("prefix", "parent-value", {});
+ await driver.setItem("prefix:child", "child-value", {});
+ await driver.setItem("odd% key/part:item?", "encoded", {});
+ await driver.setItem("odd~25:item", "tilde", {});
+ expect(await driver.getItem("prefix")).toBe("parent-value");
+ expect(await driver.getItem("prefix:child")).toBe("child-value");
+ expect(await driver.getItem("odd% key/part:item?")).toBe("encoded");
+ expect(await driver.getItem("odd~25:item")).toBe("tilde");
+ const keys = await driver.getKeys("", { maxDepth: 4 });
+ expect(keys).toContain("prefix");
+ expect(keys).toContain("prefix:child");
+ await driver.clear("prefix:", {});
+ expect(await driver.getItem("prefix")).toBe("parent-value");
+ expect(await driver.getItem("prefix:child")).toBe(null);
+ });
+
+ it("targets RxCollection above the selected RxStorage engine", async () => {
+ expect(RxDbRecordJsonSchema.primaryKey).toBe("path");
+ expect(RxDbRecordJsonSchema.indexes).toEqual(["parent"]);
+ const fileSystem = createFileSystem(createRxDbAdapter(new FakeRxCollection() as never), { coordination: "local" });
+ await exerciseRecordBackend(fileSystem);
+ });
+
+ for (const dialect of ["sqlite", "libsql", "postgresql", "mysql"] as const) {
+ it(`executes db0 record operations for ${dialect}`, async () => {
+ const database = new FakeDb0Database(dialect);
+ const adapter = await createDb0Adapter(database as never, { disposeDatabase: true });
+ const fileSystem = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
+ await exerciseRecordBackend(fileSystem);
+ await fileSystem.close();
+ expect(database.disposed).toBe(true);
+ expect(database.prepared.some((sql) => sql.startsWith("CREATE TABLE"))).toBe(true);
+ expect(database.prepared.some((sql) => sql.includes("?"))).toBe(true);
+ if (dialect === "mysql") {
+ expect(database.prepared.some((sql) => sql.includes("ON DUPLICATE KEY UPDATE"))).toBe(true);
+ }
+ });
+ }
+
+ it("uses the common Drizzle CRUD surface with a caller-owned table", async () => {
+ const { database, table } = createFakeDrizzle();
+ const fileSystem = createFileSystem(createDrizzleAdapter({ database, table } as never), { coordination: "local" });
+ await exerciseRecordBackend(fileSystem);
+ });
+});
diff --git a/tests/filesystem.test.ts b/tests/filesystem.test.ts
new file mode 100644
index 0000000..039b526
--- /dev/null
+++ b/tests/filesystem.test.ts
@@ -0,0 +1,263 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createFileSystem, FileSystemError, probeOpfs } from "../mod.ts";
+import { defineAdapter } from "../src/adapter/definition.ts";
+import { createMemoryAdapter } from "../src/adapter/memory.ts";
+import { basename, dirname, isAncestorPath, joinPath, normalizePath, splitPath } from "../src/path.ts";
+
+/** Creates an isolated memory-backed facade so lock state cannot leak between filesystem tests. */
+function createMemoryFileSystem(name: string = crypto.randomUUID(), options: Record = {}) {
+ return createFileSystem(createMemoryAdapter(), {
+ coordination: "local",
+ lockPrefix: `test:${name}`,
+ ...options,
+ });
+}
+
+/** Creates a controllable promise used to prove ordering and lock admission. */
+function deferred(): { promise: Promise; resolve: () => void } {
+ let resolve!: () => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+/** Waits for an observable test condition without depending on an arbitrary fixed delay. */
+async function waitFor(predicate: () => boolean, timeout = 1000): Promise {
+ const start = Date.now();
+ while (!predicate()) {
+ if (Date.now() - start > timeout) throw new Error("Timed out waiting for condition.");
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ }
+}
+
+/** State shared between a blocked stream fixture and the test that releases it. */
+interface BlockedStreamType {
+ /** Becomes true immediately before the fixture yields its first chunk. */
+ started: boolean;
+ /** Promise gate that keeps the stream active until the test allows completion. */
+ readonly release: ReturnType;
+}
+
+/** Yields one text chunk, then keeps the write active until the test releases its gate. */
+async function* blockedData(state: BlockedStreamType, value: string): AsyncGenerator {
+ state.started = true;
+ yield new TextEncoder().encode(value);
+ await state.release.promise;
+}
+
+/** Asserts one operation rejects with the stable filesystem error code expected by callers. */
+async function expectFileSystemError(promise: Promise, code: string): Promise {
+ try {
+ await promise;
+ } catch (error) {
+ expect(error).toBeInstanceOf(FileSystemError);
+ if (error instanceof FileSystemError) expect(error.code).toBe(code);
+ return;
+ }
+ throw new Error(`Expected FileSystemError '${code}'.`);
+}
+
+describe("filesystem facade", () => {
+ it("rejects invalid adapter and facade contracts", async () => {
+ const valid = createMemoryAdapter();
+ expect(() => defineAdapter({ ...valid, name: "" })).toThrow(TypeError);
+ expect(() => defineAdapter({
+ ...valid,
+ capabilities: { ...valid.capabilities, streamRead: "yes" },
+ } as never)).toThrow(TypeError);
+ expect(() => defineAdapter({
+ ...valid,
+ capabilities: { ...valid.capabilities, nativeCopy: true },
+ })).toThrow(TypeError);
+ expect(() => defineAdapter({
+ ...valid,
+ capabilities: { ...valid.capabilities, streamWriteModes: ["replace"] },
+ })).toThrow(TypeError);
+ expect(() => createFileSystem(valid, { coordination: "invalid" as never })).toThrow(TypeError);
+ let invalidWriteRejected = false;
+ try {
+ await createMemoryFileSystem().writeFile("/invalid.txt", "data", { mode: "invalid" as never });
+ } catch {
+ invalidWriteRejected = true;
+ }
+ expect(invalidWriteRejected).toBe(true);
+ });
+
+ it("requests shared tree and exclusive file Web Locks", async () => {
+ const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
+ const requests: Array<{ name: string; mode: string }> = [];
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: {
+ locks: {
+ async request(name: string, options: { mode: string }, callback: () => Promise) {
+ requests.push({ name, mode: options.mode });
+ await callback();
+ },
+ },
+ },
+ });
+ try {
+ const fileSystem = createFileSystem(createMemoryAdapter(), {
+ coordination: "web-locks",
+ lockPrefix: "test:web-locks",
+ });
+ await fileSystem.writeFile("/locked.txt", "data", { parents: true });
+ expect(requests).toEqual([
+ { name: "test:web-locks:tree", mode: "shared" },
+ { name: "test:web-locks:file:/locked.txt", mode: "exclusive" },
+ ]);
+ } finally {
+ if (original === undefined) Reflect.deleteProperty(globalThis, "navigator");
+ else Object.defineProperty(globalThis, "navigator", original);
+ }
+ });
+
+ it("keeps canonical path behavior stable", () => {
+ expect(normalizePath("a/./b/../c")).toBe("/a/c");
+ expect(joinPath("/a", "b", "../c")).toBe("/a/c");
+ expect(splitPath("/a/c")).toEqual(["a", "c"]);
+ expect(dirname("/a/c")).toBe("/a");
+ expect(basename("/a/c")).toBe("c");
+ expect(isAncestorPath("/a", "/a/c")).toBe(true);
+ expect(() => normalizePath("../../escape")).toThrow(FileSystemError);
+ expect(() => normalizePath("a\\b")).toThrow(FileSystemError);
+ });
+
+ it("preserves replace, append, update, range, and stat semantics", async () => {
+ const fileSystem = createMemoryFileSystem();
+ await fileSystem.writeFile("/data.txt", "hello", { parents: true });
+ await fileSystem.writeFile("/data.txt", " world", { mode: "append" });
+ await fileSystem.writeFile("/data.txt", "OPFS", { mode: "update", at: 6 });
+ expect(await fileSystem.readText("/data.txt")).toBe("hello OPFSd");
+ expect([...await fileSystem.readFile("/data.txt", { at: 6, length: 4 })]).toEqual([
+ ...new TextEncoder().encode("OPFS"),
+ ]);
+ const stat = await fileSystem.stat("/data.txt");
+ expect(stat.kind).toBe("file");
+ if (stat.kind === "file") expect(stat.size).toBe(11);
+ });
+
+ it("commits staged writable data only on close", async () => {
+ const fileSystem = createMemoryFileSystem();
+ const file = await fileSystem.root.getFileHandle("staged.txt", { create: true });
+ const writable = await file.createWritable();
+ await writable.write("discard");
+ await writable.abort();
+ expect(await fileSystem.readText("/staged.txt")).toBe("");
+ });
+
+ it("caps record-backed streamed writes and cancels producers", async () => {
+ const fileSystem = createMemoryFileSystem("buffer-limit", { maxBufferedWriteBytes: 4 });
+ let cancelled = false;
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array([1, 2, 3]));
+ controller.enqueue(new Uint8Array([4, 5, 6]));
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+ await expectFileSystemError(fileSystem.writeFile("/too-large.bin", stream, { parents: true }), "too-large");
+ expect(cancelled).toBe(true);
+ });
+
+ it("replaces stale destination trees and removes a source after fallback move", async () => {
+ const fileSystem = createMemoryFileSystem();
+ await fileSystem.writeFile("/source/nested/a.txt", "A", { parents: true });
+ await fileSystem.writeFile("/source/b.txt", "B", { parents: true });
+ await fileSystem.writeFile("/destination/stale.txt", "stale", { parents: true });
+ await fileSystem.copy("/source", "/destination", { overwrite: true, concurrency: 2 });
+ expect(await fileSystem.readText("/destination/nested/a.txt")).toBe("A");
+ expect(await fileSystem.exists("/destination/stale.txt")).toBe(false);
+ await fileSystem.move("/destination", "/moved", { concurrency: 2 });
+ expect(await fileSystem.exists("/destination")).toBe(false);
+ expect(await fileSystem.readText("/moved/b.txt")).toBe("B");
+ });
+
+ it("rejects destructive source and destination overlap before mutation", async () => {
+ const fileSystem = createMemoryFileSystem();
+ await fileSystem.writeFile("/a/b/file.txt", "safe", { parents: true });
+ await expectFileSystemError(fileSystem.copy("/a", "/a/c", { overwrite: true }), "invalid-operation");
+ await expectFileSystemError(fileSystem.copy("/a/b/file.txt", "/a", { overwrite: true }), "invalid-operation");
+ expect(await fileSystem.readText("/a/b/file.txt")).toBe("safe");
+ });
+
+ it("recovers after an aborted queued same-file write", async () => {
+ const fileSystem = createMemoryFileSystem();
+ const state: BlockedStreamType = { started: false, release: deferred() };
+ const first = fileSystem.writeFile("/queue.txt", blockedData(state, "first"), { parents: true });
+ await waitFor(() => state.started);
+ const controller = new AbortController();
+ const second = fileSystem.writeFile("/queue.txt", "second", { signal: controller.signal });
+ controller.abort("cancel queued write");
+ await expectFileSystemError(second, "aborted");
+ const third = fileSystem.writeFile("/queue.txt", "third");
+ state.release.resolve();
+ await first;
+ await third;
+ expect(await fileSystem.readText("/queue.txt")).toBe("third");
+ });
+
+ it("lets independent files progress while one write is active", async () => {
+ const fileSystem = createMemoryFileSystem();
+ const state: BlockedStreamType = { started: false, release: deferred() };
+ const first = fileSystem.writeFile("/parallel/a.txt", blockedData(state, "first"), { parents: true });
+ await waitFor(() => state.started);
+ await fileSystem.writeFile("/parallel/b.txt", "second", { parents: true });
+ expect(await fileSystem.readText("/parallel/b.txt")).toBe("second");
+ state.release.resolve();
+ await first;
+ });
+
+ it("keeps structural mutation behind active file mutation", async () => {
+ const fileSystem = createMemoryFileSystem();
+ const state: BlockedStreamType = { started: false, release: deferred() };
+ const write = fileSystem.writeFile("/tree/file.txt", blockedData(state, "data"), { parents: true });
+ await waitFor(() => state.started);
+ let emptied = false;
+ const empty = fileSystem.emptyDir("/tree").then(() => {
+ emptied = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(emptied).toBe(false);
+ state.release.resolve();
+ await write;
+ await empty;
+ expect(await fileSystem.exists("/tree/file.txt")).toBe(false);
+ });
+
+ it("propagates cancellation after a read stream opens", async () => {
+ const fileSystem = createMemoryFileSystem();
+ await fileSystem.writeFile("/abort.bin", new Uint8Array(1024), { parents: true });
+ const controller = new AbortController();
+ const stream = await fileSystem.openReadStream("/abort.bin", { signal: controller.signal });
+ controller.abort("stop");
+ await expectFileSystemError(new Response(stream).arrayBuffer(), "aborted");
+ });
+
+ it("disposes an adapter only when ownership is explicit", async () => {
+ let disposed = 0;
+ const adapter = createMemoryAdapter();
+ adapter.dispose = async () => {
+ disposed += 1;
+ };
+ const borrowed = createFileSystem(adapter, { coordination: "local" });
+ await borrowed.close();
+ expect(disposed).toBe(0);
+ const owned = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
+ await owned.close();
+ await owned.close();
+ expect(disposed).toBe(1);
+ });
+
+ it("probes OPFS without throwing when the current context has no browser OPFS", async () => {
+ const capabilities = await probeOpfs();
+ expect(typeof capabilities.rootAvailable).toBe("boolean");
+ if (!capabilities.rootAvailable) expect(typeof capabilities.rootError?.name).toBe("string");
+ });
+});
diff --git a/tests/http.ts b/tests/http.ts
new file mode 100644
index 0000000..756c062
--- /dev/null
+++ b/tests/http.ts
@@ -0,0 +1,22 @@
+/**
+ * Records Fetch requests for deterministic protocol tests.
+ *
+ * The recorder returns an empty successful response. Tests that need
+ * operation-specific provider responses should use a dedicated fake instead of
+ * adding conditional protocol behavior to this generic capture object.
+ */
+export class RequestCapture {
+ /** Requests in the exact order Fetch received them. */
+ readonly requests: Request[] = [];
+
+ /** Records one request and returns an empty HTTP 200 response. */
+ async fetch(input: RequestInfo | URL, init?: RequestInit): Promise {
+ this.requests.push(new Request(input, init));
+ return new Response(null, { status: 200 });
+ }
+
+ /** Returns the most recently recorded request. */
+ get latest(): Request | undefined {
+ return this.requests.at(-1);
+ }
+}
diff --git a/tests/memory.test.ts b/tests/memory.test.ts
index 4423f57..7375837 100644
--- a/tests/memory.test.ts
+++ b/tests/memory.test.ts
@@ -1,41 +1,38 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
import { createFileSystem } from "../mod.ts";
import { createMemoryAdapter } from "../src/adapter/memory.ts";
-function assertEquals(actual: unknown, expected: unknown): void {
- const actualJson = JSON.stringify(actual);
- const expectedJson = JSON.stringify(expected);
- if (actualJson !== expectedJson) {
- throw new Error(`Expected ${expectedJson}, got ${actualJson}.`);
- }
-}
+describe("memory adapter", () => {
+ it("provides OPFS-shaped handles over the shared filesystem facade", async () => {
+ const fileSystem = createFileSystem(createMemoryAdapter(), {
+ coordination: "local",
+ lockPrefix: `test:memory:${crypto.randomUUID()}`,
+ });
+
+ const directory = await fileSystem.root.getDirectoryHandle("docs", { create: true });
+ const file = await directory.getFileHandle("note.txt", { create: true });
+ const writable = await file.createWritable();
+ await writable.write("hello");
+ await writable.close();
-Deno.test("uses OPFS-shaped handles over the memory adapter", async () => {
- const fileSystem = createFileSystem(createMemoryAdapter(), {
- coordination: "local",
- lockPrefix: "test:deno-memory",
+ expect(await fileSystem.readText("/docs/note.txt")).toBe("hello");
+ expect(await fileSystem.root.resolve(file)).toEqual(["docs", "note.txt"]);
});
- const directory = await fileSystem.root.getDirectoryHandle("docs", { create: true });
- const file = await directory.getFileHandle("note.txt", { create: true });
- const writable = await file.createWritable();
- await writable.write("hello");
- await writable.close();
+ it("allows independent files to progress concurrently", async () => {
+ const fileSystem = createFileSystem(createMemoryAdapter(), {
+ coordination: "local",
+ lockPrefix: `test:parallel:${crypto.randomUUID()}`,
+ });
- assertEquals(await fileSystem.readText("/docs/note.txt"), "hello");
- assertEquals(await fileSystem.root.resolve(file), ["docs", "note.txt"]);
-});
+ await Promise.all([
+ fileSystem.writeFile("/parallel/a.txt", "A", { parents: true }),
+ fileSystem.writeFile("/parallel/b.txt", "B", { parents: true }),
+ ]);
-Deno.test("keeps independent file writes separate", async () => {
- const fileSystem = createFileSystem(createMemoryAdapter(), {
- coordination: "local",
- lockPrefix: "test:deno-parallel",
+ expect(await fileSystem.readText("/parallel/a.txt")).toBe("A");
+ expect(await fileSystem.readText("/parallel/b.txt")).toBe("B");
});
-
- await Promise.all([
- fileSystem.writeFile("/parallel/a.txt", "A", { parents: true }),
- fileSystem.writeFile("/parallel/b.txt", "B", { parents: true }),
- ]);
-
- assertEquals(await fileSystem.readText("/parallel/a.txt"), "A");
- assertEquals(await fileSystem.readText("/parallel/b.txt"), "B");
});
diff --git a/tests/node.test.ts b/tests/node.test.ts
new file mode 100644
index 0000000..dd62ad2
--- /dev/null
+++ b/tests/node.test.ts
@@ -0,0 +1,102 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { DatabaseSync } from "node:sqlite";
+
+import { createFileSystem } from "../mod.ts";
+import { createDb0Adapter } from "../src/adapter/db0.ts";
+import { createNodeAdapter } from "../src/adapter/node.ts";
+import { createSqliteAdapter } from "../src/adapter/sqlite.ts";
+
+/** Real Node SQLite database wrapped in the db0 shape used by the adapter contract. */
+class SqliteDb0Database {
+ /** Selects db0 SQLite SQL generation. */
+ readonly dialect = "sqlite" as const;
+ /** Real in-memory Node SQLite engine used for integration behavior. */
+ #database = new DatabaseSync(":memory:");
+
+ /** Adapts one Node SQLite statement to db0 get/all/run semantics. */
+ prepare(sql: string) {
+ const statement = this.#database.prepare(sql);
+ return {
+ all: async (...params: never[]) => statement.all(...params),
+ get: async (...params: never[]) => statement.get(...params),
+ run: async (...params: never[]) => ({ success: true, ...statement.run(...params) }),
+ };
+ }
+
+ /** Closes the owned Node SQLite database. */
+ async dispose(): Promise {
+ this.#database.close();
+ }
+}
+
+describe("Node adapter", () => {
+ it("streams, renames, performs synchronous random access, and holds the path lock until close", async () => {
+ const root = await mkdtemp(join(tmpdir(), "okikio-opfs-"));
+ const fileSystem = createFileSystem(createNodeAdapter({ root }), { coordination: "local" });
+ try {
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode("stream"));
+ controller.close();
+ },
+ });
+ await fileSystem.writeFile("/nested/file.txt", stream, { parents: true });
+ expect(await fileSystem.readText("/nested/file.txt")).toBe("stream");
+ await fileSystem.move("/nested/file.txt", "/nested/moved.txt");
+ expect(await fileSystem.exists("/nested/file.txt")).toBe(false);
+ const sync = await fileSystem.openSyncFile("/nested/moved.txt");
+ sync.writeAll(new TextEncoder().encode("NODE"), { at: 0 });
+ sync.flush();
+ let queued = false;
+ const write = fileSystem.writeFile("/nested/moved.txt", "after-sync").then(() => {
+ queued = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(queued).toBe(false);
+ sync.close();
+ await write;
+ expect(await fileSystem.readText("/nested/moved.txt")).toBe("after-sync");
+ } finally {
+ await fileSystem.close();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("executes db0 SQLite SQL against the real Node SQLite engine", async () => {
+ const database = new SqliteDb0Database();
+ const adapter = await createDb0Adapter(database as never, { disposeDatabase: true });
+ const fileSystem = createFileSystem(adapter, { coordination: "local", disposeAdapter: true });
+ try {
+ await fileSystem.writeFile("/records/a.txt", "A", { parents: true });
+ expect(await fileSystem.readText("/records/a.txt")).toBe("A");
+ } finally {
+ await fileSystem.close();
+ }
+ });
+
+ it("executes the direct SQLite adapter against Node's real SQLite engine", async () => {
+ const database = new DatabaseSync(":memory:");
+ const adapter = await createSqliteAdapter({
+ prepare(sql) {
+ const statement = database.prepare(sql);
+ return {
+ all: (...params) => statement.all(...params),
+ get: (...params) => statement.get(...params),
+ run: (...params) => statement.run(...params),
+ };
+ },
+ close() { database.close(); },
+ }, { disposeDatabase: true });
+ const fileSystem = createFileSystem(adapter, { coordination: "none", disposeAdapter: true });
+ try {
+ await fileSystem.writeFile("/sqlite/value.txt", "sqlite", { parents: true });
+ expect(await fileSystem.readText("/sqlite/value.txt")).toBe("sqlite");
+ } finally {
+ await fileSystem.close();
+ }
+ });
+});
diff --git a/tests/object.test.ts b/tests/object.test.ts
new file mode 100644
index 0000000..4e228fc
--- /dev/null
+++ b/tests/object.test.ts
@@ -0,0 +1,276 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createFileSystem } from "../mod.ts";
+import {
+ createObjectAdapter,
+ type ObjectCopyOptionsType,
+ type ObjectEntryType,
+ type ObjectGetOptionsType,
+ type ObjectListOptionsType,
+ type ObjectListType,
+ type ObjectPutOptionsType,
+ type ObjectStatType,
+ type ObjectStoreType,
+} from "../src/adapter/object.ts";
+
+/** Materialized object and metadata retained by the in-memory provider double. */
+interface StoredObjectType {
+ /** Owned object bytes. */
+ readonly bytes: Uint8Array;
+ /** Provider-neutral metadata returned by HEAD/list operations. */
+ readonly stat: ObjectStatType;
+}
+
+/** Materializes one test stream so the provider double can persist its object body. */
+async function collect(source: ReadableStream): Promise {
+ return new Uint8Array(await new Response(source).arrayBuffer());
+}
+
+/**
+ * Small provider double that preserves object-store semantics instead of
+ * pretending to be a filesystem. Counters make it possible to prove when the
+ * facade takes a provider-native path instead of silently downloading bytes.
+ */
+class MemoryObjectStore implements ObjectStoreType {
+ /** Stable adapter/provider name surfaced through the object-store contract. */
+ readonly name = "object-test";
+ /** Native paths the provider double deliberately claims for facade-selection tests. */
+ readonly capabilities = {
+ rangeRead: true,
+ streamRead: true,
+ streamWrite: true,
+ copy: true,
+ conditionalWrite: true,
+ } as const;
+
+ /** Stored objects keyed by provider object key. */
+ readonly values = new Map();
+ /** Number of object GET operations, used to prove server-side copy avoids downloads. */
+ gets = 0;
+ /** Number of native provider copy operations. */
+ copies = 0;
+ /** Monotonic value used to produce deterministic synthetic ETags. */
+ version = 0;
+
+ /** Returns metadata for one exact object key. */
+ async head(key: string): Promise {
+ return this.values.get(key)?.stat ?? null;
+ }
+
+ /** Opens one full object or bounded byte range as a Web stream. */
+ async get(key: string, options: ObjectGetOptionsType = {}): Promise> {
+ this.gets += 1;
+ const value = this.values.get(key);
+ if (value === undefined) return new Response(null, { status: 404 }).body!;
+ const start = options.at ?? 0;
+ const end = options.length === undefined ? value.bytes.byteLength : Math.min(value.bytes.byteLength, start + options.length);
+ const bytes = value.bytes.slice(start, end);
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(bytes);
+ controller.close();
+ },
+ });
+ }
+
+ /** Replaces one object while enforcing the conditional-write contract. */
+ async put(
+ key: string,
+ body: Uint8Array | ReadableStream,
+ options: ObjectPutOptionsType = {},
+ ): Promise {
+ const current = this.values.get(key);
+ if (options.ifMatch !== undefined && current?.stat.etag !== options.ifMatch) {
+ throw new Error("precondition failed: if-match");
+ }
+ if (options.ifNoneMatch === "*" && current !== undefined) {
+ throw new Error("precondition failed: if-none-match");
+ }
+ const bytes = body instanceof Uint8Array ? body.slice() : await collect(body);
+ this.version += 1;
+ const stat: ObjectStatType = {
+ size: bytes.byteLength,
+ lastModified: this.version,
+ etag: `\"v${this.version}\"`,
+ ...(options.mediaType === undefined ? {} : { mediaType: options.mediaType }),
+ ...(options.metadata === undefined ? {} : { metadata: { ...options.metadata } }),
+ };
+ this.values.set(key, { bytes, stat });
+ return stat;
+ }
+
+ /** Removes one exact object key. */
+ async delete(key: string): Promise {
+ this.values.delete(key);
+ }
+
+ /** Lists provider keys and delimiter-derived prefixes under one prefix. */
+ async list(options: ObjectListOptionsType): Promise {
+ const objects: ObjectEntryType[] = [];
+ const prefixes = new Set();
+ for (const [key, value] of this.values) {
+ if (!key.startsWith(options.prefix)) continue;
+ const rest = key.slice(options.prefix.length);
+ if (options.delimiter !== undefined) {
+ const delimiter = rest.indexOf(options.delimiter);
+ if (delimiter >= 0) {
+ prefixes.add(`${options.prefix}${rest.slice(0, delimiter + options.delimiter.length)}`);
+ continue;
+ }
+ }
+ objects.push({ key, ...value.stat });
+ }
+ const limit = options.limit ?? Number.POSITIVE_INFINITY;
+ return { objects: objects.slice(0, limit), prefixes: [...prefixes].slice(0, limit) };
+ }
+
+ /** Copies one object without routing its bytes through the facade GET path. */
+ async copy(source: string, destination: string, options: ObjectCopyOptionsType = {}): Promise {
+ this.copies += 1;
+ const value = this.values.get(source);
+ if (value === undefined) throw new Error(`missing source ${source}`);
+ if (options.sourceIfMatch !== undefined && value.stat.etag !== options.sourceIfMatch) {
+ throw new Error("precondition failed: source if-match");
+ }
+ return await this.put(destination, value.bytes, {
+ ...(value.stat.mediaType === undefined ? {} : { mediaType: value.stat.mediaType }),
+ ...(value.stat.metadata === undefined ? {} : { metadata: value.stat.metadata }),
+ });
+ }
+}
+
+/** Creates a facade plus its observable provider double for one object-store test. */
+function createObjectFileSystem(store = new MemoryObjectStore()) {
+ return {
+ store,
+ fileSystem: createFileSystem(createObjectAdapter(store), { coordination: "none" }),
+ };
+}
+
+describe("object-store adapter", () => {
+ it("preserves empty directories and implicit prefix directories", async () => {
+ const { store, fileSystem } = createObjectFileSystem();
+ await fileSystem.mkdir("/empty", { recursive: true });
+ await fileSystem.writeFile("/external/nested.txt", "outside marker", { parents: true });
+
+ expect((await fileSystem.stat("/empty")).kind).toBe("directory");
+ expect((await fileSystem.stat("/external")).kind).toBe("directory");
+ expect(store.values.has("empty/")).toBe(true);
+
+ const names: string[] = [];
+ for await (const entry of fileSystem.readDir("/")) names.push(entry.name);
+ expect(names.sort()).toEqual(["empty", "external"]);
+ });
+
+ it("prefers an exact file when foreign objects also create the same prefix", async () => {
+ const { store, fileSystem } = createObjectFileSystem();
+ await store.put("mixed", new TextEncoder().encode("file"));
+ await store.put("mixed/child.txt", new TextEncoder().encode("child"));
+
+ expect((await fileSystem.stat("/mixed")).kind).toBe("file");
+ await fileSystem.writeFile("/mixed", "updated");
+ expect(await fileSystem.readText("/mixed")).toBe("updated");
+ });
+
+ it("uses ranged object reads without materializing the complete object", async () => {
+ const { fileSystem } = createObjectFileSystem();
+ await fileSystem.writeFile("/range.txt", "0123456789", { parents: true });
+ expect(new TextDecoder().decode(await fileSystem.readFile("/range.txt", { at: 3, length: 4 }))).toBe("3456");
+ });
+
+ it("applies append and update as optimistic read-modify-write operations", async () => {
+ const { fileSystem } = createObjectFileSystem();
+ await fileSystem.writeFile("/state.txt", "hello", { parents: true });
+ await fileSystem.writeFile("/state.txt", " world", { mode: "append" });
+ await fileSystem.writeFile("/state.txt", "OPFS", { mode: "update", at: 6 });
+ expect(await fileSystem.readText("/state.txt")).toBe("hello OPFSd");
+ });
+
+ it("can disable native copy and exposes the emulated route through inspection and metrics", async () => {
+ const store = new MemoryObjectStore();
+ const fileSystem = createFileSystem(createObjectAdapter(store), {
+ coordination: "none",
+ optimizations: { nativeCopy: false },
+ metrics: "basic",
+ });
+ await fileSystem.writeFile("/source.bin", new Uint8Array([1, 2, 3]), {
+ parents: true,
+ mediaType: "application/x-test",
+ });
+ store.gets = 0;
+ store.copies = 0;
+
+ expect(fileSystem.inspect().support.copy).toBe("emulated");
+ expect(fileSystem.plan({ operation: "copy", size: 3 }).support).toBe("emulated");
+ await fileSystem.copy("/source.bin", "/copy.bin");
+
+ expect(store.copies).toBe(0);
+ expect(store.gets).toBeGreaterThan(0);
+ expect([...await fileSystem.readFile("/copy.bin")]).toEqual([1, 2, 3]);
+ const stat = await fileSystem.stat("/copy.bin");
+ expect(stat.kind).toBe("file");
+ if (stat.kind === "file") expect(stat.mediaType).toBe("application/x-test");
+ await fileSystem.close();
+ });
+
+
+ it("rejects an oversized emulated copy when its streaming read route is disabled", async () => {
+ const store = new MemoryObjectStore();
+ const fileSystem = createFileSystem(createObjectAdapter(store), {
+ coordination: "none",
+ optimizations: { nativeCopy: false, streamRead: false },
+ maxBufferedWriteBytes: 2,
+ });
+ await fileSystem.writeFile("/source.bin", new Uint8Array([1, 2, 3]), { parents: true });
+
+ const plan = fileSystem.plan({ operation: "copy", size: 3 });
+ expect(plan.supported).toBe(false);
+ await expect(fileSystem.copy("/source.bin", "/copy.bin")).rejects.toMatchObject({ code: "too-large" });
+ await fileSystem.close();
+ });
+
+ it("fails an oversized streamed copy before opening the source when direct stream writes are disabled", async () => {
+ const store = new MemoryObjectStore();
+ const fileSystem = createFileSystem(createObjectAdapter(store), {
+ coordination: "none",
+ optimizations: { nativeCopy: false, streamWrite: false },
+ maxBufferedWriteBytes: 2,
+ });
+ await fileSystem.writeFile("/source.bin", new Uint8Array([1, 2, 3]), { parents: true });
+ store.gets = 0;
+
+ const plan = fileSystem.plan({ operation: "copy", size: 3 });
+ expect(plan.supported).toBe(false);
+ await expect(fileSystem.copy("/source.bin", "/copy.bin")).rejects.toMatchObject({ code: "too-large" });
+ expect(store.gets).toBe(0);
+ await fileSystem.close();
+ });
+
+ it("uses provider copy without opening the source stream", async () => {
+ const { store, fileSystem } = createObjectFileSystem();
+ await fileSystem.writeFile("/source.bin", new Uint8Array([1, 2, 3]), { parents: true });
+ store.gets = 0;
+
+ await fileSystem.copy("/source.bin", "/copy.bin");
+
+ expect(store.copies).toBe(1);
+ expect(store.gets).toBe(0);
+ expect([...await fileSystem.readFile("/copy.bin")]).toEqual([1, 2, 3]);
+ });
+
+ it("streams a replacement directly to a streaming object store", async () => {
+ const { store, fileSystem } = createObjectFileSystem();
+ let pulled = 0;
+ const source = new ReadableStream({
+ pull(controller) {
+ pulled += 1;
+ controller.enqueue(new Uint8Array([pulled]));
+ if (pulled === 3) controller.close();
+ },
+ });
+
+ await fileSystem.writeFile("/stream.bin", source, { parents: true });
+ expect([...store.values.get("stream.bin")!.bytes]).toEqual([1, 2, 3]);
+ });
+});
diff --git a/tests/path.test.ts b/tests/path.test.ts
index a3214f9..3e334c1 100644
--- a/tests/path.test.ts
+++ b/tests/path.test.ts
@@ -1,28 +1,30 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
import { FileSystemError } from "../src/error.ts";
import { basename, dirname, joinPath, normalizePath, splitPath } from "../src/path.ts";
+import { PathSchema } from "../src/schema.ts";
-function assertEquals(actual: unknown, expected: unknown): void {
- const actualJson = JSON.stringify(actual);
- const expectedJson = JSON.stringify(expected);
- if (actualJson !== expectedJson) {
- throw new Error(`Expected ${expectedJson}, got ${actualJson}.`);
- }
-}
+describe("virtual paths", () => {
+ it("exposes project schemas through the Standard Schema contract implemented by Zod", () => {
+ expect("~standard" in PathSchema).toBe(true);
+ });
-Deno.test("normalizes virtual filesystem paths", () => {
- assertEquals(normalizePath("a/./b/../c"), "/a/c");
- assertEquals(splitPath("/a/c"), ["a", "c"]);
- assertEquals(joinPath("/a", "b", "../c"), "/a/c");
- assertEquals(dirname("/a/c"), "/a");
- assertEquals(basename("/a/c"), "c");
-});
+ it("normalizes relative and dot segments", () => {
+ expect(normalizePath("a/./b/../c")).toBe("/a/c");
+ expect(splitPath("/a/c")).toEqual(["a", "c"]);
+ expect(joinPath("/a", "b", "../c")).toBe("/a/c");
+ expect(dirname("/a/c")).toBe("/a");
+ expect(basename("/a/c")).toBe("c");
+ });
-Deno.test("rejects paths that escape above the virtual root", () => {
- try {
- normalizePath("../../outside");
- } catch (error) {
- if (error instanceof FileSystemError && error.code === "invalid-path") return;
- throw error;
- }
- throw new Error("Expected normalizePath() to reject root escape.");
+ it("rejects escape above the virtual root", () => {
+ expect(() => normalizePath("../../outside")).toThrow(FileSystemError);
+ try {
+ normalizePath("../../outside");
+ } catch (error) {
+ expect(error).toBeInstanceOf(FileSystemError);
+ if (error instanceof FileSystemError) expect(error.code).toBe("invalid-path");
+ }
+ });
});
diff --git a/tests/provider.test.ts b/tests/provider.test.ts
new file mode 100644
index 0000000..d70d97c
--- /dev/null
+++ b/tests/provider.test.ts
@@ -0,0 +1,137 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+import { toBytes } from "@std/streams/to-bytes";
+
+import { createFileSystem } from "../mod.ts";
+import { createObjectAdapter } from "../src/adapter/object.ts";
+import { createAzureClient } from "../src/azure.ts";
+import { createS3Client } from "../src/s3.ts";
+import { streamBytes } from "./stream.ts";
+
+/** Official Azurite development account used only by the local emulator. */
+const AZURITE_ACCOUNT = "devstoreaccount1";
+/** Official Azurite development key documented by the Azurite project. */
+const AZURITE_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
+/** Provider container/bucket created by the Docker fixture. */
+const STORAGE_NAME = "opfs-test";
+/** Exact S3 multipart minimum used to force multipart behavior with a small fixture. */
+const S3_PART_SIZE = 5 * 1024 * 1024;
+
+/** Returns a unique object-key prefix so failed test cleanup cannot collide with another run. */
+function getPrefix(provider: string): string {
+ return `integration/${provider}/${crypto.randomUUID()}`;
+}
+
+/** Creates the local SeaweedFS S3 client used by Docker-backed integration tests. */
+function getS3Client() {
+ return createS3Client({
+ endpoint: "http://127.0.0.1:8333",
+ bucket: STORAGE_NAME,
+ region: "us-east-1",
+ credentials: { accessKeyId: "admin", secretAccessKey: "secret" },
+ partSize: S3_PART_SIZE,
+ concurrency: 2,
+ });
+}
+
+/** Creates the local Azurite client with Shared Key authentication. */
+function getAzureClient() {
+ return createAzureClient({
+ endpoint: `http://127.0.0.1:10000/${AZURITE_ACCOUNT}`,
+ container: STORAGE_NAME,
+ credential: { kind: "shared-key", account: AZURITE_ACCOUNT, key: AZURITE_KEY },
+ blockSize: 1024 * 1024,
+ concurrency: 2,
+ });
+}
+
+/** Ensures the Azurite container exists before blob operations begin. */
+async function ensureAzureContainer(): Promise {
+ const client = getAzureClient();
+ const response = await client.request({ method: "PUT", query: { restype: "container" } });
+ if (response.ok || response.status === 409) return;
+ throw new Error(`Azurite container setup failed with HTTP ${response.status}: ${await response.text()}`);
+}
+
+describe("Docker-backed object providers", () => {
+ it("exercises S3 signing, ranges, conditions, multipart upload, copy, listing, and filesystem translation", async () => {
+ const client = getS3Client();
+ const prefix = getPrefix("s3");
+ const basic = `${prefix}/basic.txt`;
+ const large = `${prefix}/large.bin`;
+ const copied = `${prefix}/copied.txt`;
+ const facadeKey = `${prefix}/facade/state.txt`;
+
+ try {
+ const original = new TextEncoder().encode("0123456789");
+ const written = await client.put(basic, original, { mediaType: "text/plain", ifNoneMatch: "*" });
+ expect(written.size).toBe(original.byteLength);
+ if (written.etag === undefined) throw new Error("S3 provider did not return an ETag for a completed object write.");
+ expect((await client.head(basic))?.etag).toBe(written.etag);
+ expect(new TextDecoder().decode(await toBytes(await client.get(basic, { at: 3, length: 4 })))).toBe("3456");
+ await expect(client.put(basic, original, { ifNoneMatch: "*" })).rejects.toBeDefined();
+
+ const first = new Uint8Array(S3_PART_SIZE);
+ first.fill(7);
+ const second = new Uint8Array(31);
+ second.fill(9);
+ await client.put(large, streamBytes([first, second]), { size: first.byteLength + second.byteLength });
+ expect((await client.head(large))?.size).toBe(first.byteLength + second.byteLength);
+
+ await client.copy!(basic, copied, { sourceIfMatch: written.etag });
+ expect(new TextDecoder().decode(await toBytes(await client.get(copied)))).toBe("0123456789");
+ const page = await client.list({ prefix: `${prefix}/`, delimiter: "/" });
+ expect(page.objects.some((entry) => entry.key === basic)).toBe(true);
+
+ await using fileSystem = createFileSystem(createObjectAdapter(client, { prefix }), { coordination: "none" });
+ await fileSystem.writeFile("/facade/state.txt", "through facade", { parents: true });
+ expect(await fileSystem.readText("/facade/state.txt")).toBe("through facade");
+ expect((await client.head(facadeKey))?.size).toBe(14);
+ } finally {
+ for (const key of [basic, large, copied, facadeKey, `${prefix}/facade/`]) {
+ await client.delete(key).catch(() => undefined);
+ }
+ }
+ });
+
+ it("exercises Azure Shared Key, ranges, conditions, block upload, copy, listing, and filesystem translation", async () => {
+ await ensureAzureContainer();
+ const client = getAzureClient();
+ const prefix = getPrefix("azure");
+ const basic = `${prefix}/basic.txt`;
+ const large = `${prefix}/large.bin`;
+ const copied = `${prefix}/copied.txt`;
+ const facadeKey = `${prefix}/facade/state.txt`;
+
+ try {
+ const original = new TextEncoder().encode("0123456789");
+ const written = await client.put(basic, original, { mediaType: "text/plain", ifNoneMatch: "*" });
+ expect(written.size).toBe(original.byteLength);
+ if (written.etag === undefined) throw new Error("Azure provider did not return an ETag for a completed blob write.");
+ expect((await client.head(basic))?.etag).toBe(written.etag);
+ expect(new TextDecoder().decode(await toBytes(await client.get(basic, { at: 3, length: 4 })))).toBe("3456");
+ await expect(client.put(basic, original, { ifNoneMatch: "*" })).rejects.toBeDefined();
+
+ const first = new Uint8Array(1024 * 1024);
+ first.fill(3);
+ const second = new Uint8Array(1024 * 1024 + 17);
+ second.fill(4);
+ await client.put(large, streamBytes([first, second]), { size: first.byteLength + second.byteLength });
+ expect((await client.head(large))?.size).toBe(first.byteLength + second.byteLength);
+
+ await client.copy!(basic, copied, { sourceIfMatch: written.etag });
+ expect(new TextDecoder().decode(await toBytes(await client.get(copied)))).toBe("0123456789");
+ const page = await client.list({ prefix: `${prefix}/`, delimiter: "/" });
+ expect(page.objects.some((entry) => entry.key === basic)).toBe(true);
+
+ await using fileSystem = createFileSystem(createObjectAdapter(client, { prefix }), { coordination: "none" });
+ await fileSystem.writeFile("/facade/state.txt", "through facade", { parents: true });
+ expect(await fileSystem.readText("/facade/state.txt")).toBe("through facade");
+ expect((await client.head(facadeKey))?.size).toBe(14);
+ } finally {
+ for (const key of [basic, large, copied, facadeKey, `${prefix}/facade/`]) {
+ await client.delete(key).catch(() => undefined);
+ }
+ }
+ });
+});
diff --git a/tests/provider/compose.yml b/tests/provider/compose.yml
new file mode 100644
index 0000000..fba3928
--- /dev/null
+++ b/tests/provider/compose.yml
@@ -0,0 +1,27 @@
+services:
+ s3:
+ image: chrislusf/seaweedfs:4.41
+ command: ["mini", "-dir=/data"]
+ environment:
+ AWS_ACCESS_KEY_ID: admin
+ AWS_SECRET_ACCESS_KEY: secret
+ S3_BUCKET: opfs-test
+ ports:
+ - "8333:8333"
+ volumes:
+ - s3-data:/data
+
+ azure:
+ image: mcr.microsoft.com/azure-storage/azurite:3.36.0
+ command:
+ - azurite-blob
+ - --blobHost
+ - 0.0.0.0
+ - --skipApiVersionCheck
+ - --disableTelemetry
+ - --inMemoryPersistence
+ ports:
+ - "10000:10000"
+
+volumes:
+ s3-data:
diff --git a/tests/s3.test.ts b/tests/s3.test.ts
new file mode 100644
index 0000000..4f44dac
--- /dev/null
+++ b/tests/s3.test.ts
@@ -0,0 +1,758 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createS3Client, S3Error, S3_LIMITS } from "../src/s3.ts";
+import { RequestCapture } from "./http.ts";
+import { streamBytes } from "./stream.ts";
+
+/** AWS documentation credentials used only for deterministic Signature Version 4 tests. */
+const credentials = {
+ accessKeyId: "AKIDEXAMPLE",
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
+};
+
+/** Creates one S3-style XML response without coupling tests to an HTTP server. */
+function xml(value: string, init: ResponseInit = {}): Response {
+ return new Response(value, { status: 200, headers: { "content-type": "application/xml", ...(init.headers ?? {}) }, ...init });
+}
+
+/** Refreshable credential source used to prove per-request SigV4 resolution. */
+class S3CredentialSource {
+ /** Number of times the client requested fresh credentials. */
+ calls = 0;
+
+ /** Returns valid temporary credentials with a request-specific session token. */
+ get(): typeof credentials & { sessionToken: string } {
+ this.calls += 1;
+ return { ...credentials, sessionToken: `session-${this.calls}` };
+ }
+}
+
+describe("S3 client", () => {
+ it("creates a deterministic Signature Version 4 request", async () => {
+ let request: Request | undefined;
+ const client = createS3Client({
+ endpoint: "https://s3.amazonaws.com",
+ bucket: "examplebucket",
+ region: "us-east-1",
+ credentials,
+ now: () => new Date("2013-05-24T00:00:00.000Z"),
+ fetch: async (input, init) => {
+ request = new Request(input, init);
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ await client.request({ method: "GET", key: "test file.txt", query: { z: "last", a: "first" } });
+
+ expect(request?.url).toBe("https://s3.amazonaws.com/examplebucket/test%20file.txt?a=first&z=last");
+ expect(request?.headers.get("x-amz-date")).toBe("20130524T000000Z");
+ expect(request?.headers.get("authorization")).toBe(
+ "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20130524/us-east-1/s3/aws4_request, " +
+ "SignedHeaders=host;x-amz-content-sha256;x-amz-date, " +
+ "Signature=f9026f9c6df0d2208a26fd69dcb05b43269bda5a28e17f4186bb1570a3a600da",
+ );
+ });
+
+ it("parses ListObjectsV2 without a protocol-specific XML regex", async () => {
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ fetch: async () => xml(`
+
+ root/a.txt2026-08-14T12:00:00.000Z"a"4
+ root/nested/
+ next-token
+ `),
+ });
+
+ const page = await client.list({ prefix: "root/", delimiter: "/" });
+ expect(page.objects[0]?.key).toBe("root/a.txt");
+ expect(page.objects[0]?.size).toBe(4);
+ expect(page.prefixes).toEqual(["root/nested/"]);
+ expect(page.cursor).toBe("next-token");
+ });
+
+ it("treats an embedded CompleteMultipartUpload error as a failure even after HTTP 200", async () => {
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ fetch: async (input) => {
+ const url = new URL(input instanceof Request ? input.url : input);
+ if (url.searchParams.has("uploadId")) {
+ return xml(`InternalErrorassembly failedr1`);
+ }
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ try {
+ await client.completeUpload({ key: "large.bin", id: "upload" }, [{ number: 1, etag: "\"part\"" }]);
+ throw new Error("expected embedded multipart failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(S3Error);
+ if (error instanceof S3Error) {
+ expect(error.code).toBe("InternalError");
+ expect(error.requestId).toBe("r1");
+ }
+ }
+ });
+
+ it("places multipart write preconditions on completion rather than initiation", async () => {
+ const requests: Request[] = [];
+ const fiveMiB = 5 * 1024 * 1024;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ partSize: fiveMiB,
+ concurrency: 2,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ const url = new URL(request.url);
+ if (request.method === "POST" && url.searchParams.has("uploads")) {
+ return xml("u1");
+ }
+ if (request.method === "PUT" && url.searchParams.has("partNumber")) {
+ return new Response(null, { status: 200, headers: { etag: `\"p${url.searchParams.get("partNumber")}\"` } });
+ }
+ if (request.method === "POST" && url.searchParams.has("uploadId")) {
+ return xml("\"final\"");
+ }
+ if (request.method === "HEAD") {
+ return new Response(null, { status: 200, headers: { "content-length": String(fiveMiB + 1), etag: "\"final\"" } });
+ }
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ const body = streamBytes([new Uint8Array(fiveMiB), new Uint8Array([1])]);
+ await client.put("large.bin", body, { ifMatch: "\"old\"" });
+
+ const initiate = requests.find((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploads"));
+ const complete = requests.find((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploadId"));
+ expect(initiate?.headers.has("if-match")).toBe(false);
+ expect(complete?.headers.get("if-match")).toBe("\"old\"");
+ });
+
+ it("retains provider request identity on S3 errors", async () => {
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ fetch: async () => xml(
+ "AccessDenieddeniedrequest-1host-1",
+ { status: 403 },
+ ),
+ });
+
+ try {
+ await client.get("private.txt");
+ throw new Error("expected S3 failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(S3Error);
+ if (error instanceof S3Error) {
+ expect(error.code).toBe("AccessDenied");
+ expect(error.requestId).toBe("request-1");
+ expect(error.hostId).toBe("host-1");
+ }
+ }
+ });
+ it("treats an embedded CopyObject error as a failure even after HTTP 200", async () => {
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ fetch: async (input) => {
+ const request = input instanceof Request ? input : new Request(input);
+ if (request.method === "HEAD" && request.url.endsWith("/source.bin")) {
+ return new Response(null, { status: 200, headers: { "content-length": "4", etag: "\"source\"" } });
+ }
+ if (request.method === "PUT") {
+ return xml("SlowDowncopy failedcopy-r1");
+ }
+ return new Response(null, { status: 404 });
+ },
+ });
+
+ try {
+ await client.copy!("source.bin", "copy.bin");
+ throw new Error("expected embedded copy failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(S3Error);
+ if (error instanceof S3Error) {
+ expect(error.code).toBe("SlowDown");
+ expect(error.requestId).toBe("copy-r1");
+ }
+ }
+ });
+
+ it("uses UploadPartCopy rather than CopyObject above the 5 GB single-copy limit", async () => {
+ const requests: Request[] = [];
+ const size = S3_LIMITS.maxCopyBytes + 1;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ copyPartSize: 1024 * 1024 * 1024,
+ concurrency: 2,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ const url = new URL(request.url);
+ if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) {
+ return new Response(null, {
+ status: 200,
+ headers: { "content-length": String(size), etag: "\"source\"", "content-type": "application/octet-stream" },
+ });
+ }
+ if (request.method === "POST" && url.searchParams.has("uploads")) {
+ return xml("copy-upload");
+ }
+ if (request.method === "PUT" && url.searchParams.has("partNumber")) {
+ const number = url.searchParams.get("partNumber");
+ return xml(`"p${number}"`);
+ }
+ if (request.method === "POST" && url.searchParams.has("uploadId")) {
+ return xml(""final"");
+ }
+ if (request.method === "HEAD") {
+ return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"final\"" } });
+ }
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ await client.copy!("source.bin", "copy.bin", { sourceIfMatch: "\"source\"" });
+
+ const parts = requests.filter((request) => new URL(request.url).searchParams.has("partNumber"));
+ expect(parts).toHaveLength(5);
+ expect(parts[0]?.headers.get("x-amz-copy-source-range")).toBe(`bytes=0-${1024 * 1024 * 1024 - 1}`);
+ expect(parts[0]?.headers.get("x-amz-copy-source-if-match")).toBe("\"source\"");
+ expect(requests.some((request) => request.method === "PUT" && !new URL(request.url).searchParams.has("partNumber"))).toBe(false);
+ });
+
+
+ it("surfaces embedded UploadPartCopy failures and aborts the unfinished multipart copy", async () => {
+ let aborted = false;
+ const size = S3_LIMITS.maxCopyBytes + 1;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials,
+ copyPartSize: 1024 * 1024 * 1024,
+ concurrency: 1,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ const url = new URL(request.url);
+ if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) {
+ return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"source\"" } });
+ }
+ if (request.method === "POST" && url.searchParams.has("uploads")) {
+ return xml("copy-upload");
+ }
+ if (request.method === "PUT" && url.searchParams.has("partNumber")) {
+ return xml("SlowDowncopy part failedpart-r1");
+ }
+ if (request.method === "DELETE" && url.searchParams.has("uploadId")) {
+ aborted = true;
+ return new Response(null, { status: 204 });
+ }
+ return new Response(null, { status: 500 });
+ },
+ });
+
+ try {
+ await client.copy!("source.bin", "copy.bin");
+ throw new Error("expected multipart copy failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(AggregateError);
+ if (error instanceof AggregateError) {
+ const provider = error.errors.find((entry): entry is S3Error => entry instanceof S3Error);
+ expect(provider?.code).toBe("SlowDown");
+ expect(provider?.requestId).toBe("part-r1");
+ }
+ }
+ expect(aborted).toBe(true);
+ });
+
+ it("aborts multipart state when the streamed byte count does not match the declared size", async () => {
+ const requests: Request[] = [];
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ const url = new URL(request.url);
+ if (request.method === "POST" && url.searchParams.has("uploads")) {
+ return xml("size-upload");
+ }
+ if (request.method === "PUT" && url.searchParams.has("partNumber")) {
+ return new Response(null, { status: 200, headers: { etag: "\"part\"" } });
+ }
+ if (request.method === "DELETE" && url.searchParams.has("uploadId")) {
+ return new Response(null, { status: 204 });
+ }
+ return new Response(null, { status: 500 });
+ },
+ });
+
+ await expect(client.put(
+ "wrong-size.bin",
+ streamBytes([new Uint8Array([1])]),
+ { size: 2 },
+ )).rejects.toThrow(RangeError);
+
+ expect(requests.some((request) => request.method === "DELETE")).toBe(true);
+ expect(requests.some((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploadId"))).toBe(false);
+ });
+
+ it("keeps the object ceiling equal to the exact multipart part-count limit", () => {
+ expect(S3_LIMITS.maxObjectBytes).toBe(S3_LIMITS.maxPartBytes * S3_LIMITS.maxParts);
+ });
+
+ it("rejects multipart sizes outside the documented S3 part range", () => {
+ expect(() => createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ partSize: S3_LIMITS.minPartBytes - 1,
+ })).toThrow(RangeError);
+
+ expect(() => createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ partSize: S3_LIMITS.maxPartBytes + 1,
+ })).toThrow(RangeError);
+ });
+
+ it("sorts multipart parts and rejects duplicate part numbers before commit", async () => {
+ const requests: Request[] = [];
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ fetch: async (input, init) => {
+ requests.push(new Request(input, init));
+ return xml(""done"");
+ },
+ });
+
+ await client.completeUpload(
+ { key: "ordered.bin", id: "upload" },
+ [{ number: 2, etag: "\"b\"" }, { number: 1, etag: "\"a\"" }],
+ { expectedSize: 10 },
+ );
+ const body = await requests[0]!.text();
+ expect(body.indexOf("1")).toBeLessThan(body.indexOf("2"));
+ expect(requests[0]!.headers.get("x-amz-mp-object-size")).toBe("10");
+
+ await expect(client.completeUpload(
+ { key: "duplicate.bin", id: "upload" },
+ [{ number: 1, etag: "\"a\"" }, { number: 1, etag: "\"b\"" }],
+ )).rejects.toThrow(RangeError);
+ });
+
+ it("applies source conditions to multipart copy and destination conditions only at commit", async () => {
+ const requests: Request[] = [];
+ const size = S3_LIMITS.maxCopyBytes + 1;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ copyPartSize: 1024 * 1024 * 1024,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ const url = new URL(request.url);
+ if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) {
+ return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"source\"" } });
+ }
+ if (request.method === "POST" && url.searchParams.has("uploads")) {
+ return xml("u");
+ }
+ if (request.method === "PUT" && url.searchParams.has("partNumber")) {
+ return xml(""part"");
+ }
+ if (request.method === "POST" && url.searchParams.has("uploadId")) {
+ return xml(""done"");
+ }
+ return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"done\"" } });
+ },
+ });
+
+ await client.copy!("source.bin", "copy.bin", {
+ sourceIfMatch: "\"source\"",
+ sourceIfNoneMatch: "\"stale\"",
+ ifNoneMatch: "*",
+ });
+
+ const part = requests.find((request) => new URL(request.url).searchParams.has("partNumber"));
+ const complete = requests.find((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploadId"));
+ expect(part?.headers.get("x-amz-copy-source-if-match")).toBe("\"source\"");
+ expect(part?.headers.get("x-amz-copy-source-if-none-match")).toBe("\"stale\"");
+ expect(part?.headers.has("if-none-match")).toBe(false);
+ expect(complete?.headers.get("if-none-match")).toBe("*");
+ });
+
+ it("uses virtual-hosted addressing and canonical encoded query ordering", async () => {
+ const capture = new RequestCapture();
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket-name",
+ region: "us-east-1",
+ credentials,
+ addressing: "virtual",
+ now: () => new Date("2026-08-14T12:00:00.000Z"),
+ fetch: capture.fetch.bind(capture),
+ });
+
+ await client.request({
+ method: "GET",
+ key: "folder/a b.txt",
+ query: { z: ["2", "1"], "a b": "!*" },
+ });
+
+ expect(capture.latest?.url).toBe(
+ "https://bucket-name.storage.example/folder/a%20b.txt?a%20b=%21%2A&z=1&z=2",
+ );
+ expect(capture.latest?.headers.get("authorization")).toContain("SignedHeaders=host;x-amz-content-sha256;x-amz-date");
+ });
+
+ it("resolves temporary credentials for every request and signs the session token", async () => {
+ const source = new S3CredentialSource();
+ const capture = new RequestCapture();
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials: source.get.bind(source),
+ now: () => new Date("2026-08-14T12:00:00.000Z"),
+ fetch: capture.fetch.bind(capture),
+ });
+
+ await client.request({ method: "HEAD", key: "one" });
+ await client.request({ method: "HEAD", key: "two" });
+
+ expect(source.calls).toBe(2);
+ expect(capture.requests[0]?.headers.get("x-amz-security-token")).toBe("session-1");
+ expect(capture.requests[1]?.headers.get("x-amz-security-token")).toBe("session-2");
+ expect(capture.requests[0]?.headers.get("authorization")).toContain("x-amz-security-token");
+ });
+
+ it("uses UNSIGNED-PAYLOAD for an unmaterialized low-level stream", async () => {
+ const capture = new RequestCapture();
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials,
+ fetch: capture.fetch.bind(capture),
+ });
+
+ await client.request({
+ method: "PUT",
+ key: "stream.bin",
+ body: streamBytes([new Uint8Array([1, 2, 3])]),
+ });
+
+ expect(capture.latest?.headers.get("x-amz-content-sha256")).toBe("UNSIGNED-PAYLOAD");
+ });
+
+
+ it("hashes replayable low-level Web bodies instead of weakening them to UNSIGNED-PAYLOAD", async () => {
+ const bodies: BodyInit[] = [
+ new Uint8Array([1, 2, 3]).buffer,
+ new Uint8Array([1, 2, 3]),
+ new Blob([new Uint8Array([1, 2, 3])]),
+ ];
+
+ for (const body of bodies) {
+ const capture = new RequestCapture();
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials,
+ fetch: capture.fetch.bind(capture),
+ });
+
+ await client.request({ method: "PUT", key: "body.bin", body });
+ expect(capture.latest?.headers.get("x-amz-content-sha256")).toBe(
+ "039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81",
+ );
+ }
+ });
+
+ it("hashes URLSearchParams using the exact Fetch form body serialization", async () => {
+ const capture = new RequestCapture();
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials,
+ fetch: capture.fetch.bind(capture),
+ });
+
+ await client.request({
+ method: "POST",
+ key: "form",
+ body: new URLSearchParams({ a: "1", b: "two" }),
+ });
+
+ expect(capture.latest?.headers.get("x-amz-content-sha256")).toBe(
+ "c06685fc4150186a5cdd90d87b503c941ef9dc60c9617ac388cf15f193f5bef1",
+ );
+ });
+
+ it("rejects an impossible declared object size before it starts multipart work", async () => {
+ let fetches = 0;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials,
+ fetch: async () => {
+ fetches += 1;
+ return new Response(null, { status: 500 });
+ },
+ });
+
+ await expect(client.put(
+ "too-large.bin",
+ streamBytes([new Uint8Array([1])]),
+ { size: S3_LIMITS.maxObjectBytes + 1 },
+ )).rejects.toThrow(RangeError);
+ expect(fetches).toBe(0);
+ });
+
+ it("uses a separate bounded signal to abort multipart state after caller cancellation", async () => {
+ const controller = new AbortController();
+ let cleanupSignal: AbortSignal | undefined;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "us-east-1",
+ credentials,
+ abortTimeoutMs: 5_000,
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ const url = new URL(request.url);
+ if (request.method === "POST" && url.searchParams.has("uploads")) {
+ controller.abort(new DOMException("caller cancelled", "AbortError"));
+ return xml("cancelled");
+ }
+ if (request.method === "PUT" && url.searchParams.has("partNumber")) {
+ throw init?.signal instanceof AbortSignal && init.signal.aborted
+ ? init.signal.reason
+ : new Error("part request should receive caller cancellation");
+ }
+ if (request.method === "DELETE" && url.searchParams.has("uploadId")) {
+ cleanupSignal = init?.signal instanceof AbortSignal ? init.signal : undefined;
+ return new Response(null, { status: 204 });
+ }
+ return new Response(null, { status: 500 });
+ },
+ });
+
+ await expect(client.put(
+ "cancelled.bin",
+ streamBytes([new Uint8Array([1])]),
+ { signal: controller.signal },
+ )).rejects.toBeDefined();
+
+ expect(cleanupSignal).toBeDefined();
+ expect(cleanupSignal).not.toBe(controller.signal);
+ expect(cleanupSignal?.aborted).toBe(false);
+ });
+
+});
+
+describe("S3 request policy", () => {
+ it("retries replayable 503 responses, refreshes credentials, and records request metrics", async () => {
+ let attempts = 0;
+ const source = new S3CredentialSource();
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials: () => source.get(),
+ request: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ attempts += 1;
+ return new Response(null, { status: attempts === 1 ? 503 : 200 });
+ },
+ });
+
+ const response = await client.request({ method: "PUT", key: "retry.bin", body: new Uint8Array([1]) });
+
+ expect(response.status).toBe(200);
+ expect(attempts).toBe(2);
+ expect(source.calls).toBe(2);
+ expect(client.getMetrics().requests).toBe(2);
+ expect(client.getMetrics().retries).toBe(1);
+ });
+
+ it("retries a replayable Fetch transport failure and returns the next response", async () => {
+ let attempts = 0;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ request: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ attempts += 1;
+ if (attempts === 1) throw new TypeError("temporary network failure");
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ const response = await client.request({ method: "GET", key: "retry-network.bin" });
+
+ expect(response.status).toBe(200);
+ expect(attempts).toBe(2);
+ expect(client.getMetrics().retries).toBe(1);
+ expect(client.getMetrics().failures).toBe(0);
+ });
+
+ it("does not retry deterministic credential failures", async () => {
+ let credentialCalls = 0;
+ let fetches = 0;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials: () => {
+ credentialCalls += 1;
+ throw new TypeError("invalid credential source");
+ },
+ request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ fetches += 1;
+ return new Response(null, { status: 200 });
+ },
+ });
+
+ await expect(client.request({ method: "GET", key: "key" })).rejects.toThrow("invalid credential source");
+ expect(credentialCalls).toBe(1);
+ expect(fetches).toBe(0);
+ });
+
+ it("does not retry multipart initiation when the response is ambiguous", async () => {
+ let attempts = 0;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ attempts += 1;
+ return new Response("SlowDown", { status: 503 });
+ },
+ });
+
+ await expect(client.createUpload("ambiguous.bin")).rejects.toBeDefined();
+ expect(attempts).toBe(1);
+ });
+
+ it("lets a low-level caller disable retry for a replayable body", async () => {
+ let attempts = 0;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async () => {
+ attempts += 1;
+ return new Response(null, { status: 503 });
+ },
+ });
+
+ const response = await client.request({ method: "PUT", key: "once.bin", body: new Uint8Array([1]), retry: false });
+ expect(response.status).toBe(503);
+ expect(attempts).toBe(1);
+ });
+
+ it("does not retry a one-shot ReadableStream body", async () => {
+ let attempts = 0;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ fetch: async (_input, init) => {
+ attempts += 1;
+ expect(init?.redirect).toBe("manual");
+ return new Response(null, { status: 503 });
+ },
+ });
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array([1]));
+ controller.close();
+ },
+ });
+
+ const response = await client.request({ method: "PUT", key: "stream.bin", body });
+
+ expect(response.status).toBe(503);
+ expect(attempts).toBe(1);
+ });
+
+ it("surfaces redirects without following a signed request", async () => {
+ let targetHits = 0;
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ fetch: async (_input, init) => {
+ expect(init?.redirect).toBe("manual");
+ return new Response(null, { status: 307, headers: { location: "https://other.example/bucket/key" } });
+ },
+ });
+
+ const response = await client.request({ method: "GET", key: "key" });
+ if (response.url === "https://other.example/bucket/key") targetHits += 1;
+
+ expect(response.status).toBe(307);
+ expect(targetHits).toBe(0);
+ });
+
+ it("applies a per-attempt timeout without requiring the caller to race the promise", async () => {
+ const client = createS3Client({
+ endpoint: "https://storage.example",
+ bucket: "bucket",
+ region: "auto",
+ credentials,
+ request: { retries: 0, timeoutMs: 5 },
+ fetch: async (_input, init) => await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
+ }),
+ });
+
+ await expect(client.request({ method: "GET", key: "slow" })).rejects.toMatchObject({ name: "TimeoutError" });
+ });
+});
diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts
new file mode 100644
index 0000000..08b288c
--- /dev/null
+++ b/tests/sqlite.test.ts
@@ -0,0 +1,79 @@
+import { describe, it } from "node:test";
+import { expect } from "@std/expect";
+
+import { createFileSystem } from "../mod.ts";
+import { createSqliteAdapter, type SqliteStatementType } from "../src/adapter/sqlite.ts";
+
+/**
+ * Portable SQLite-shaped test double. Real Node SQLite execution lives in
+ * node.test.ts; this suite protects the runtime-neutral prepare/get/all/run
+ * translation shared by other SQLite wrappers.
+ */
+class MemorySqlite {
+ /** Logical SQLite rows keyed by the adapter record identity. */
+ readonly rows = new Map>();
+ /** Prepared SQL retained so tests can verify schema/upsert decisions. */
+ readonly sql: string[] = [];
+ /** Records whether adapter-owned disposal closed the database. */
+ closed = false;
+
+ /** Implements the small prepared-statement surface consumed by `createSqliteAdapter()`. */
+ prepare(sql: string): SqliteStatementType {
+ this.sql.push(sql);
+ if (sql.startsWith("CREATE TABLE")) return { all: () => [], get: () => undefined, run: () => undefined };
+ if (sql.startsWith("SELECT") && sql.includes("WHERE id")) {
+ return { all: () => [], get: (id) => this.rows.get(String(id)), run: () => undefined };
+ }
+ if (sql.startsWith("SELECT") && sql.includes("WHERE parent_path")) {
+ return {
+ all: (parent) => [...this.rows.values()].filter((row) => row.parent_path === parent),
+ get: () => undefined,
+ run: () => undefined,
+ };
+ }
+ if (sql.startsWith("INSERT")) {
+ return {
+ all: () => [],
+ get: () => undefined,
+ run: (...values) => {
+ const [id, path, parentPath, name, kind, data, size, lastModified, mediaType] = values;
+ this.rows.set(String(id), {
+ path,
+ parent_path: parentPath,
+ name,
+ kind,
+ data,
+ size,
+ last_modified: lastModified,
+ media_type: mediaType,
+ });
+ },
+ };
+ }
+ if (sql.startsWith("DELETE")) {
+ return { all: () => [], get: () => undefined, run: (id) => { this.rows.delete(String(id)); } };
+ }
+ throw new Error(`unexpected SQL: ${sql}`);
+ }
+
+ /** Marks the test database closed. */
+ close(): void {
+ this.closed = true;
+ }
+}
+
+describe("direct SQLite adapter", () => {
+ it("reuses the SQLite db0 record contract and explicit ownership", async () => {
+ const database = new MemorySqlite();
+ const adapter = await createSqliteAdapter(database, { disposeDatabase: true });
+ const fileSystem = createFileSystem(adapter, { coordination: "none", disposeAdapter: true });
+
+ await fileSystem.writeFile("/db/value.txt", "value", { parents: true });
+ expect(await fileSystem.readText("/db/value.txt")).toBe("value");
+ expect(database.sql.some((sql) => sql.startsWith("CREATE TABLE"))).toBe(true);
+ expect(database.sql.some((sql) => sql.includes("ON CONFLICT"))).toBe(true);
+
+ await fileSystem.close();
+ expect(database.closed).toBe(true);
+ });
+});
diff --git a/tests/stream.ts b/tests/stream.ts
new file mode 100644
index 0000000..f24de02
--- /dev/null
+++ b/tests/stream.ts
@@ -0,0 +1,38 @@
+/**
+ * Pull source used by storage tests that need deterministic byte chunks.
+ *
+ * The source keeps chunk ownership in one named object instead of hiding
+ * stream state inside an inline callback. Each pull publishes at most one
+ * chunk so multipart tests can choose the exact chunk sequence independently
+ * from the provider client's own re-chunking logic.
+ */
+class ChunkSource implements UnderlyingDefaultSource {
+ /** Chunks exposed to the test stream in source order. */
+ readonly #chunks: readonly Uint8Array[];
+ /** Index of the next source chunk. */
+ #index = 0;
+
+ /** Creates a finite byte source over caller-owned test chunks. */
+ constructor(chunks: readonly Uint8Array[]) {
+ this.#chunks = chunks;
+ }
+
+ /** Publishes one chunk or closes the stream after the final chunk. */
+ pull(controller: ReadableStreamDefaultController): void {
+ const chunk = this.#chunks[this.#index];
+ this.#index += 1;
+ if (chunk === undefined) controller.close();
+ else controller.enqueue(chunk);
+ }
+}
+
+/**
+ * Creates a deterministic finite byte stream for storage tests.
+ *
+ * The function does not merge the supplied chunks. This is useful when a test
+ * needs to prove that a client can accept source chunks whose sizes do not
+ * match its multipart or block-upload size.
+ */
+export function streamBytes(chunks: readonly Uint8Array[]): ReadableStream {
+ return new ReadableStream(new ChunkSource(chunks));
+}