mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-12 15:53:12 +00:00
test: establish storage adapter contracts
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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");
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -70,6 +70,20 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<boolean> {
|
||||
const fileHandle = await this.getFileHandle(p);
|
||||
if (fileHandle) return true;
|
||||
@@ -146,14 +160,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
}
|
||||
|
||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
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<FSAPIStat> {
|
||||
}
|
||||
|
||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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<File> {
|
||||
return new File([this.data], this.name, { lastModified: 1 });
|
||||
}
|
||||
|
||||
async createWritable(): Promise<FileSystemWritableFileStream> {
|
||||
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<string, MemoryDirectoryHandle | MemoryFileHandle>();
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
|
||||
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<FileSystemFileHandle> {
|
||||
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<void> {
|
||||
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));
|
||||
});
|
||||
}
|
||||
});
|
||||
+1
-1
Submodule src/lib updated: a0efb7274e...b62c00c1d6
Reference in New Issue
Block a user