From 2b7230ae4f75679a81b5717ffbd0ea37a1efbf07 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Sat, 15 Aug 2026 04:04:52 -0400 Subject: [PATCH] tests: add comprehensive integration tests for S3 and Azure providers - Implemented integration tests for S3 and Azure object storage providers in `provider.test.ts`. - Created Docker Compose configuration for local testing of S3 and Azure services in `compose.yml`. - Developed detailed S3 client tests in `s3.test.ts` to validate request signing, error handling, and multipart uploads. - Added SQLite adapter tests in `sqlite.test.ts` to ensure correct database interactions. - Introduced a utility for deterministic byte streams in `stream.ts` to facilitate testing with controlled byte chunks. Signed-off-by: Okiki Ojo --- tests/adapters.test.ts | 563 -------------------- tests/azure.test.ts | 608 +++++++++++++++++++++ tests/browser/adapters.spec.ts | 17 + tests/browser/dedicated-worker.ts | 30 -- tests/browser/fixtures/app.ts | 428 +++++++++++++++ tests/browser/fixtures/dedicated.ts | 49 ++ tests/browser/fixtures/frame.html | 5 + tests/browser/fixtures/index.html | 5 + tests/browser/fixtures/service.ts | 32 ++ tests/browser/fixtures/shared.ts | 32 ++ tests/browser/iframe.html | 16 - tests/browser/iframe.spec.ts | 72 +++ tests/browser/iframe.ts | 12 - tests/browser/index.html | 13 - tests/browser/index.ts | 99 ---- tests/browser/opfs.spec.ts | 66 +++ tests/browser/playwright.config.ts | 39 ++ tests/browser/service-worker.spec.ts | 31 ++ tests/browser/service-worker.ts | 28 - tests/browser/shared-worker.ts | 27 - tests/browser/worker.spec.ts | 36 ++ tests/bun.test.ts | 34 ++ tests/chunk.test.ts | 35 ++ tests/consumer.window.ts | 16 - tests/consumer.worker.ts | 16 - tests/deno-kv-partition.test.ts | 306 +++++++++++ tests/deno-kv.test.ts | 30 ++ tests/deno.test.ts | 31 ++ tests/ecosystems.test.ts | 355 +++++++++++++ tests/filesystem.test.ts | 263 ++++++++++ tests/http.ts | 22 + tests/memory.test.ts | 59 +-- tests/node.test.ts | 102 ++++ tests/object.test.ts | 276 ++++++++++ tests/path.test.ts | 46 +- tests/provider.test.ts | 137 +++++ tests/provider/compose.yml | 27 + tests/s3.test.ts | 758 +++++++++++++++++++++++++++ tests/sqlite.test.ts | 79 +++ tests/stream.ts | 38 ++ 40 files changed, 3965 insertions(+), 873 deletions(-) delete mode 100644 tests/adapters.test.ts create mode 100644 tests/azure.test.ts create mode 100644 tests/browser/adapters.spec.ts delete mode 100644 tests/browser/dedicated-worker.ts create mode 100644 tests/browser/fixtures/app.ts create mode 100644 tests/browser/fixtures/dedicated.ts create mode 100644 tests/browser/fixtures/frame.html create mode 100644 tests/browser/fixtures/index.html create mode 100644 tests/browser/fixtures/service.ts create mode 100644 tests/browser/fixtures/shared.ts delete mode 100644 tests/browser/iframe.html create mode 100644 tests/browser/iframe.spec.ts delete mode 100644 tests/browser/iframe.ts delete mode 100644 tests/browser/index.html delete mode 100644 tests/browser/index.ts create mode 100644 tests/browser/opfs.spec.ts create mode 100644 tests/browser/playwright.config.ts create mode 100644 tests/browser/service-worker.spec.ts delete mode 100644 tests/browser/service-worker.ts delete mode 100644 tests/browser/shared-worker.ts create mode 100644 tests/browser/worker.spec.ts create mode 100644 tests/bun.test.ts create mode 100644 tests/chunk.test.ts delete mode 100644 tests/consumer.window.ts delete mode 100644 tests/consumer.worker.ts create mode 100644 tests/deno-kv-partition.test.ts create mode 100644 tests/deno-kv.test.ts create mode 100644 tests/deno.test.ts create mode 100644 tests/ecosystems.test.ts create mode 100644 tests/filesystem.test.ts create mode 100644 tests/http.ts create mode 100644 tests/node.test.ts create mode 100644 tests/object.test.ts create mode 100644 tests/provider.test.ts create mode 100644 tests/provider/compose.yml create mode 100644 tests/s3.test.ts create mode 100644 tests/sqlite.test.ts create mode 100644 tests/stream.ts 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)); +} -- 2.51.2