Share application adapter and diagnostic helpers

This commit is contained in:
vorotamoroz committed 2026-10-29 15:04:09 +00:00
1 parent 4027417bf5
commit b9dcb0d4c7
26 files changed
+937 -305

No files matched your search

@@ -8,6 +8,7 @@ import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
/**
* Complete file system adapter implementation for FileSystem API
@@ -85,28 +86,12 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
*/
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
const pathStr = this.normalisePath(p);
const exact = await this.getAbstractFileByPath(pathStr);
if (exact) {
return exact;
}
// TODO: Refactor: Very, Very heavy.
const lowerPath = pathStr.toLowerCase();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
await this.scanDirectory();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
return null;
return await findCaseInsensitiveFile(
pathStr,
() => this.getAbstractFileByPath(pathStr),
this.fileCache,
() => this.scanDirectory()
);
}
async getFiles(): Promise<FSAPIFile[]> {
@@ -110,6 +110,83 @@ describe("FileSystemAccessStorageAdapter", () => {
}
});
describe("FSAPIVaultAdapter creation", () => {
it.each(["text", "binary"] as const)(
"creates nested %s files with exact content and stored metadata",
async (kind) => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const adapter = new FSAPIVaultAdapter(root);
const data = new Uint8Array([0, 255, 1, 128, 42]).buffer;
const path = "/nested//folder/file.dat";
const file =
kind === "text" ? await adapter.create(path, "日本語😀") : await adapter.createBinary(path, data);
const expected = kind === "text" ? new TextEncoder().encode("日本語😀") : new Uint8Array(data);
const nested = await root.getDirectoryHandle("nested");
const folder = await nested.getDirectoryHandle("folder");
const stored = await folder.getFileHandle("file.dat");
expect(file.path).toBe(path);
expect(file.handle).toBe(stored);
expect(new Uint8Array(await adapter.readBinary(file))).toEqual(expected);
expect(file.stat).toEqual({ size: expected.byteLength, mtime: 1, ctime: 1, type: "file" });
}
);
it.each(["text", "binary"] as const)("waits for the %s writer to close before reading metadata", async (kind) => {
let releaseClose!: () => void;
const closed = new Promise<void>((resolve) => {
releaseClose = resolve;
});
const events: string[] = [];
const getFile = vi.fn(async () => {
events.push("metadata");
return new File(["result"], "file.dat", { lastModified: 123 });
});
const close = vi.fn(async () => {
events.push("close-start");
await closed;
events.push("closed");
});
const write = vi.fn(async () => {
events.push("write");
});
const fileHandle = { createWritable: vi.fn(async () => ({ write, close })), getFile };
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
const adapter = new FSAPIVaultAdapter(root);
const data = new Uint8Array([0, 255]).buffer;
const pending = kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", data);
try {
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(getFile).not.toHaveBeenCalled();
} finally {
releaseClose();
}
const file = await pending;
expect(events).toEqual(["write", "close-start", "closed", "metadata"]);
expect(write).toHaveBeenCalledWith(kind === "text" ? "text" : data);
expect(file.stat).toEqual({ size: 6, ctime: 123, mtime: 123, type: "file" });
});
it.each(["text", "binary"] as const)("propagates a %s write failure without returning metadata", async (kind) => {
const failure = new Error("fixture write failed");
const getFile = vi.fn();
const fileHandle = {
createWritable: vi.fn(async () => ({
write: vi.fn(async () => {
throw failure;
}),
close: vi.fn(),
})),
getFile,
};
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
const adapter = new FSAPIVaultAdapter(root);
const pending =
kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", new ArrayBuffer(2));
await expect(pending).rejects.toBe(failure);
expect(getFile).not.toHaveBeenCalled();
});
});
describe("FSAPIVaultAdapter.rename", () => {
it("moves the file through a temporary copy while preserving content", async () => {
const memoryRoot = new MemoryDirectoryHandle("root");
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
});
describe("FSAPIFileSystemAdapter path case", () => {
it("returns an exact path without scanning the directory", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("Calculus.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
path: "Calculus.md",
});
expect(scanDirectory).not.toHaveBeenCalled();
});
it("returns the stored path case from a case-insensitive file system", async () => {
const memoryRoot = new MemoryDirectoryHandle("root", true);
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
@@ -155,6 +246,48 @@ describe("FSAPIFileSystemAdapter path case", () => {
);
});
it("returns a cached case-insensitive match without rescanning", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("Calculus.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
await adapter.scanDirectory();
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
path: "Calculus.md",
});
expect(scanDirectory).not.toHaveBeenCalled();
});
it("rescans once and returns a newly cached case-insensitive match", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("Calculus.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
path: "Calculus.md",
});
expect(scanDirectory).toHaveBeenCalledOnce();
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
});
it("returns null after one scan when no cached path matches", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("other.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
expect(scanDirectory).toHaveBeenCalledOnce();
});
it("reports scan failures through the injected Webapp log", async () => {
const root = {
name: "root",
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
"fsapi-scan"
);
});
it("returns null and reports scan failure during case-insensitive lookup", async () => {
const root = {
name: "root",
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
throw new Error("scan failed");
},
} as unknown as FileSystemDirectoryHandle;
const addLog = vi.fn();
const adapter = new FSAPIFileSystemAdapter(root, addLog);
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
expect(scanDirectory).toHaveBeenCalledOnce();
expect(addLog).toHaveBeenCalledWith(
"Error scanning directory '.': Error: scan failed",
LOG_LEVEL_NOTICE,
"fsapi-scan"
);
});
});
+5 -28
View File
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
}
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];
// Navigate to parent directory, creating as needed
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
}
// Create the file
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
// Get file metadata
const fileObject = await fileHandle.getFile();
return {
path: p as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: fileHandle,
};
return await this.createFile(p, data);
}
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
return await this.createFile(p, data);
}
private async createFile(p: string, data: string | ArrayBuffer): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];