diff --git a/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts b/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts index a1b0977..c3ad462 100644 --- a/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts +++ b/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts @@ -2,9 +2,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { storageAdapterContractCases } from "@/apps/storageAdapterContract"; import { NodeStorageAdapter } from "./NodeStorageAdapter"; -describe("NodeStorageAdapter binary I/O", () => { +describe("NodeStorageAdapter", () => { const tempDirs: string[] = []; async function createAdapter() { @@ -17,6 +18,12 @@ describe("NodeStorageAdapter binary I/O", () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); + for (const contractCase of storageAdapterContractCases) { + it(contractCase.name, async () => { + await contractCase.run(await createAdapter()); + }); + } + it("writes and reads binary data without corruption", async () => { const adapter = await createAdapter(); const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]); @@ -37,4 +44,7 @@ describe("NodeStorageAdapter binary I/O", () => { expect(result.byteLength).toBe(expected.byteLength); expect(Array.from(new Uint8Array(result))).toEqual([0x10, 0x20, 0x30]); }); + + it.todo("rejects paths that escape the configured root"); + it.todo("rejects removing the configured root through an empty path"); }); diff --git a/src/apps/storageAdapterContract.ts b/src/apps/storageAdapterContract.ts new file mode 100644 index 0000000..21880ea --- /dev/null +++ b/src/apps/storageAdapterContract.ts @@ -0,0 +1,77 @@ +import type { IStorageAdapter } from "@lib/serviceModules/adapters"; + +/** One platform-neutral storage adapter contract case. */ +export interface StorageAdapterContractCase { + readonly name: string; + run(adapter: IStorageAdapter): Promise; +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`); + } +} + +/** Passing baseline shared by Node, FSAPI, and future storage adapters. */ +export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [ + { + name: "reports missing paths consistently", + async run(adapter) { + assertEqual(await adapter.exists("missing.txt"), false, "missing path should not exist"); + assertEqual(await adapter.stat("missing.txt"), null, "missing stat should be null"); + assertEqual(await adapter.trystat("missing.txt"), null, "missing trystat should be null"); + }, + }, + { + name: "creates parent directories for nested text writes", + async run(adapter) { + await adapter.write("notes/nested/note.md", "hello"); + assertEqual(await adapter.read("notes/nested/note.md"), "hello", "text should round-trip"); + assert(await adapter.exists("notes/nested/note.md"), "written text path should exist"); + assertEqual((await adapter.stat("notes/nested/note.md"))?.type, "file", "written path should be a file"); + }, + }, + { + name: "round-trips exact binary bytes", + async run(adapter) { + const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]); + await adapter.writeBinary("binary/blob.bin", expected.buffer.slice(0)); + const result = await adapter.readBinary("binary/blob.bin"); + assertEqual([...new Uint8Array(result)], [...expected], "binary data should round-trip exactly"); + assertEqual(result.byteLength, expected.byteLength, "binary result should have the exact visible length"); + }, + }, + { + name: "creates and extends text through append", + async run(adapter) { + await adapter.append("logs/events.log", "first"); + await adapter.append("logs/events.log", ":second"); + assertEqual(await adapter.read("logs/events.log"), "first:second", "append should create then extend text"); + }, + }, + { + name: "lists direct files and folders", + async run(adapter) { + await adapter.mkdir("listing/folder"); + await adapter.write("listing/file.txt", "content"); + const listed = await adapter.list("listing"); + assertEqual([...listed.files].sort(), ["listing/file.txt"], "list should contain the direct file"); + assertEqual([...listed.folders].sort(), ["listing/folder"], "list should contain the direct folder"); + }, + }, + { + name: "removes files and directory trees", + async run(adapter) { + await adapter.write("remove/file.txt", "content"); + await adapter.write("remove/folder/nested.txt", "content"); + await adapter.remove("remove/file.txt"); + assertEqual(await adapter.exists("remove/file.txt"), false, "file should be removed"); + await adapter.remove("remove/folder"); + assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed"); + }, + }, +]; diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.ts index 4cd79d6..f48a20d 100644 --- a/src/apps/webapp/adapters/FSAPIStorageAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.ts @@ -70,6 +70,20 @@ export class FSAPIStorageAdapter implements IStorageAdapter { } } + /** Resolve a writable file path after creating its parent directories. */ + private async resolveWritablePath(p: string): Promise<{ + dirHandle: FileSystemDirectoryHandle; + fileName: string; + } | null> { + const parts = p.split("/").filter((part) => part !== ""); + if (parts.length === 0) return null; + const fileName = parts.pop()!; + const parentPath = parts.join("/"); + await this.mkdir(parentPath); + const dirHandle = await this.getDirectoryHandle(parentPath); + return dirHandle ? { dirHandle, fileName } : null; + } + async exists(p: string): Promise { const fileHandle = await this.getFileHandle(p); if (fileHandle) return true; @@ -146,14 +160,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter { } async write(p: string, data: string, options?: UXDataWriteOptions): Promise { - const resolved = await this.resolvePath(p); + const resolved = await this.resolveWritablePath(p); if (!resolved) { throw new Error(`Invalid path: ${p}`); } - // Ensure parent directory exists - await this.mkdir(p.split("/").slice(0, -1).join("/")); - const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(data); @@ -161,14 +172,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter { } async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { - const resolved = await this.resolvePath(p); + const resolved = await this.resolveWritablePath(p); if (!resolved) { throw new Error(`Invalid path: ${p}`); } - // Ensure parent directory exists - await this.mkdir(p.split("/").slice(0, -1).join("/")); - const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(data); diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts new file mode 100644 index 0000000..f741820 --- /dev/null +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts @@ -0,0 +1,81 @@ +import { describe, it } from "vitest"; +import { storageAdapterContractCases } from "@/apps/storageAdapterContract"; +import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter"; + +class MemoryFileHandle { + readonly kind = "file"; + private data = new Uint8Array(); + + constructor(readonly name: string) {} + + async getFile(): Promise { + return new File([this.data], this.name, { lastModified: 1 }); + } + + async createWritable(): Promise { + const handle = this; + return { + async write(data: FileSystemWriteChunkType) { + if (typeof data === "string") { + handle.data = new TextEncoder().encode(data); + } else if (data instanceof ArrayBuffer) { + handle.data = new Uint8Array(data.slice(0)); + } else if (ArrayBuffer.isView(data)) { + handle.data = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)); + } else { + throw new TypeError("Unsupported in-memory write type"); + } + }, + async close() {}, + } as FileSystemWritableFileStream; + } +} + +class MemoryDirectoryHandle { + readonly kind = "directory"; + private readonly children = new Map(); + + constructor(readonly name: string) {} + + async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise { + const existing = this.children.get(name); + if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle; + if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError"); + const directory = new MemoryDirectoryHandle(name); + this.children.set(name, directory); + return directory as unknown as FileSystemDirectoryHandle; + } + + async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise { + const existing = this.children.get(name); + if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle; + if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError"); + const file = new MemoryFileHandle(name); + this.children.set(name, file); + return file as unknown as FileSystemFileHandle; + } + + async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise { + const existing = this.children.get(name); + if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError"); + if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) { + throw new DOMException("Directory is not empty", "InvalidModificationError"); + } + this.children.delete(name); + } + + async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> { + for (const [name, entry] of this.children) { + yield [name, entry as unknown as FileSystemHandle]; + } + } +} + +describe("FSAPIStorageAdapter", () => { + for (const contractCase of storageAdapterContractCases) { + it(contractCase.name, async () => { + const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle; + await contractCase.run(new FSAPIStorageAdapter(root)); + }); + } +}); diff --git a/src/lib b/src/lib index a0efb72..b62c00c 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit a0efb7274e15c4ae0f0e0740670a1ad2699031e9 +Subproject commit b62c00c1d679853c0a5620a79455cf016ddfe981