From 7ac17c9a0d7a46b844a8595a5622da092f001dab Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 10:52:57 +0100 Subject: [PATCH 01/92] fix(web): give Stryker's typescript-checker a program covering router.ts and workers/** tsconfig.json (the main app program) deliberately excludes src/rpc/router.ts and every src/workers/**/*.ts file, since they belong to tsconfig.worker.json's own DOM-vs-WebWorker lib split instead. Stryker's typescript-checker plugin requires every mutated file to belong to the one program its tsconfigFile resolves, so pointing it at tsconfig.json crashed outright the moment a mutant landed inside router.ts or the worker entry point ("no watcher is registered for it"). tsconfig.stryker.json is a checker-only program: the same include as tsconfig.json but without the router.ts/workers exclusions, with DOM and WebWorker unioned (skipLibCheck makes the pair compile together) so both halves of the app typecheck under the one program the checker needs. --- packages/web/stryker.config.ts | 2 ++ packages/web/tsconfig.stryker.json | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 packages/web/tsconfig.stryker.json diff --git a/packages/web/stryker.config.ts b/packages/web/stryker.config.ts index 223947c07..432e46c05 100644 --- a/packages/web/stryker.config.ts +++ b/packages/web/stryker.config.ts @@ -8,4 +8,6 @@ export default packageStrykerConfig({ "!src/**/*.test.tsx", ], vitestConfigFile: "vitest.mutation.config.ts", + // tsconfig.json (the main app program) deliberately excludes src/rpc/router.ts and every src/workers/**/*.ts file -- they belong to tsconfig.worker.json's own separate program instead (a real DOM-vs-WebWorker lib split, not an oversight). Stryker's typescript-checker plugin requires every mutated file to be part of the ONE program its tsconfigFile resolves, so pointing it at tsconfig.json crashes outright ("no watcher is registered for it") the moment it reaches a mutant inside router.ts or the worker entry point. Neither app-only nor worker-only tsconfig alone covers every file this package's mutate glob touches, and DOM+WebWorker together in one lib array cannot simply replace tsconfig.json's own lib (skipLibCheck happens to make the pair compile together here, but that's this checker config's own accommodation, not a reason to widen the real build's lib list). tsconfig.stryker.json is a checker-only program: same include as tsconfig.json but without the router.ts/workers exclusions, and DOM+WebWorker unioned so both halves of the app typecheck under the one program the checker actually needs. + tsconfigFile: "tsconfig.stryker.json", }); diff --git a/packages/web/tsconfig.stryker.json b/packages/web/tsconfig.stryker.json new file mode 100644 index 000000000..58e79812b --- /dev/null +++ b/packages/web/tsconfig.stryker.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable", "WebWorker"], + "types": ["vite/client", "wicg-file-system-access"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} From eec07fba2ed32f2bcda62bdae9609867420ed04e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 10:53:10 +0100 Subject: [PATCH 02/92] test(web): cover the filename-extension and relative-time helpers inferFormatFromFilename and relativeTime had no unit coverage at all despite being pure, easily-tested functions -- every extension/alias mapping, the lowercase-before-match step, the dotfile and no-extension edge cases, and each relativeTime unit boundary (minute/hour/day, floored not rounded) are now exercised directly. --- .../web/src/shared/extensionToFormat.test.ts | 82 +++++++++++++++++++ packages/web/src/shared/relativeTime.test.ts | 62 ++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/web/src/shared/extensionToFormat.test.ts create mode 100644 packages/web/src/shared/relativeTime.test.ts diff --git a/packages/web/src/shared/extensionToFormat.test.ts b/packages/web/src/shared/extensionToFormat.test.ts new file mode 100644 index 000000000..d47436955 --- /dev/null +++ b/packages/web/src/shared/extensionToFormat.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { inferFormatFromFilename } from "./extensionToFormat"; + +describe("inferFormatFromFilename", () => { + it("maps a plain extension to its format", () => { + expect(inferFormatFromFilename("report.pdf")).toBe("pdf"); + }); + + it("maps every template/macro-enabled OOXML variant to its base format", () => { + expect(inferFormatFromFilename("a.docx")).toBe("docx"); + expect(inferFormatFromFilename("a.dotx")).toBe("docx"); + expect(inferFormatFromFilename("a.docm")).toBe("docx"); + expect(inferFormatFromFilename("a.pptx")).toBe("pptx"); + expect(inferFormatFromFilename("a.potx")).toBe("pptx"); + expect(inferFormatFromFilename("a.pptm")).toBe("pptx"); + expect(inferFormatFromFilename("a.xlsx")).toBe("xlsx"); + expect(inferFormatFromFilename("a.xltx")).toBe("xlsx"); + expect(inferFormatFromFilename("a.xlsm")).toBe("xlsx"); + }); + + it("maps every OpenDocument variant, including the template spellings, to its base format", () => { + expect(inferFormatFromFilename("a.odt")).toBe("odt"); + expect(inferFormatFromFilename("a.ott")).toBe("odt"); + expect(inferFormatFromFilename("a.odp")).toBe("odp"); + expect(inferFormatFromFilename("a.otp")).toBe("odp"); + expect(inferFormatFromFilename("a.ods")).toBe("ods"); + expect(inferFormatFromFilename("a.ots")).toBe("ods"); + expect(inferFormatFromFilename("a.odg")).toBe("odg"); + expect(inferFormatFromFilename("a.otg")).toBe("odg"); + expect(inferFormatFromFilename("a.odf")).toBe("odf"); + expect(inferFormatFromFilename("a.otf")).toBe("odf"); + }); + + it("maps the remaining single-spelling formats", () => { + expect(inferFormatFromFilename("a.csv")).toBe("csv"); + expect(inferFormatFromFilename("a.svg")).toBe("svg"); + expect(inferFormatFromFilename("a.rtf")).toBe("rtf"); + expect(inferFormatFromFilename("a.doc")).toBe("doc"); + expect(inferFormatFromFilename("a.xls")).toBe("xls"); + expect(inferFormatFromFilename("a.ppt")).toBe("ppt"); + expect(inferFormatFromFilename("a.epub")).toBe("epub"); + }); + + it("maps both the 'markdown' and 'md' spellings to the same format", () => { + expect(inferFormatFromFilename("a.markdown")).toBe("markdown"); + expect(inferFormatFromFilename("a.md")).toBe("markdown"); + }); + + it("lowercases the extension before matching", () => { + expect(inferFormatFromFilename("REPORT.PDF")).toBe("pdf"); + expect(inferFormatFromFilename("Report.Docx")).toBe("docx"); + }); + + it("returns undefined for an unrecognised extension", () => { + expect(inferFormatFromFilename("archive.zip")).toBeUndefined(); + }); + + it("returns undefined for a filename with no extension at all", () => { + expect(inferFormatFromFilename("README")).toBeUndefined(); + }); + + it("returns undefined for a dotfile whose only dot is a leading one, not an extension separator", () => { + expect(inferFormatFromFilename(".gitignore")).toBeUndefined(); + }); + + it("resolves the extension from the final path segment, ignoring directory names that themselves contain a dot", () => { + expect(inferFormatFromFilename("a.b.dir/report.pdf")).toBe("pdf"); + }); + + it("resolves the final path segment across a backslash separator too", () => { + expect(inferFormatFromFilename("C:\\Users\\me\\report.docx")).toBe("docx"); + }); + + it("uses the last dot in the final segment when a filename itself contains more than one", () => { + expect(inferFormatFromFilename("archive.tar.pdf")).toBe("pdf"); + }); + + it("returns undefined for a path whose directory segment has a dot but whose final segment has none", () => { + expect(inferFormatFromFilename("a.dir/README")).toBeUndefined(); + }); +}); diff --git a/packages/web/src/shared/relativeTime.test.ts b/packages/web/src/shared/relativeTime.test.ts new file mode 100644 index 000000000..8d4d079ae --- /dev/null +++ b/packages/web/src/shared/relativeTime.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { relativeTime } from "./relativeTime"; + +const NOW = new Date("2026-01-01T12:00:00.000Z").getTime(); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("relativeTime", () => { + it("reports 'just now' for a timestamp in the same instant", () => { + expect(relativeTime(NOW)).toBe("just now"); + }); + + it("reports 'just now' for anything under a minute old", () => { + expect(relativeTime(NOW - 59_000)).toBe("just now"); + }); + + it("switches to minutes at exactly the one-minute boundary", () => { + expect(relativeTime(NOW - 60_000)).toBe("1m ago"); + }); + + it("floors a partial minute rather than rounding", () => { + expect(relativeTime(NOW - 119_000)).toBe("1m ago"); + }); + + it("reports minutes up to just under an hour", () => { + expect(relativeTime(NOW - 59 * 60_000)).toBe("59m ago"); + }); + + it("switches to hours at exactly the one-hour boundary", () => { + expect(relativeTime(NOW - 60 * 60_000)).toBe("1h ago"); + }); + + it("floors a partial hour rather than rounding", () => { + expect(relativeTime(NOW - (60 * 60_000 + 59 * 60_000))).toBe("1h ago"); + }); + + it("reports hours up to just under a day", () => { + expect(relativeTime(NOW - 23 * 60 * 60_000)).toBe("23h ago"); + }); + + it("switches to days at exactly the one-day boundary", () => { + expect(relativeTime(NOW - 24 * 60 * 60_000)).toBe("1d ago"); + }); + + it("floors a partial day rather than rounding", () => { + expect(relativeTime(NOW - (24 * 60 * 60_000 + 23 * 60 * 60_000))).toBe( + "1d ago", + ); + }); + + it("reports an arbitrarily large day count with no upper unit", () => { + expect(relativeTime(NOW - 10 * 24 * 60 * 60_000)).toBe("10d ago"); + }); +}); From 711f210a97f9eecd2599d30a6a4ae329dc2758a3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 10:53:21 +0100 Subject: [PATCH 03/92] fix(web): remove the unreachable '.bin' fallback in the native save-picker's accept extension String.split('.').pop() can never return undefined for any input, including a string with no '.' at all -- split always returns at least one element -- so the '?? "bin"' fallback in createNativeFileAccess's saveFile was dead code with no test able to reach it. Removed the guard and added full unit coverage for all three file-access adapters (createFileAccess's native/ fallback selection, the fallback picker's file-chosen/dismissed/accept-attribute paths and its Blob-URL download-anchor save, and the native picker's open/save flows including the AbortError-vs-real-failure branches and the accept-extension derivation this fix touches). --- .../fileAccess/createFileAccess.test.ts | 24 ++ .../fileAccess/fallbackFileAccess.test.ts | 122 +++++++++ .../fileAccess/nativeFileAccess.test.ts | 250 ++++++++++++++++++ .../adapters/fileAccess/nativeFileAccess.ts | 3 +- 4 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/adapters/fileAccess/createFileAccess.test.ts create mode 100644 packages/web/src/adapters/fileAccess/fallbackFileAccess.test.ts create mode 100644 packages/web/src/adapters/fileAccess/nativeFileAccess.test.ts diff --git a/packages/web/src/adapters/fileAccess/createFileAccess.test.ts b/packages/web/src/adapters/fileAccess/createFileAccess.test.ts new file mode 100644 index 000000000..0ba4acc10 --- /dev/null +++ b/packages/web/src/adapters/fileAccess/createFileAccess.test.ts @@ -0,0 +1,24 @@ +/// +/// +import { afterEach, describe, expect, it } from "vitest"; + +import { createFileAccess } from "./createFileAccess"; + +afterEach(() => { + Reflect.deleteProperty(window, "showOpenFilePicker"); +}); + +describe("createFileAccess", () => { + it("returns the native adapter when the browser exposes showOpenFilePicker", () => { + window.showOpenFilePicker = (): Promise<[FileSystemFileHandle]> => + Promise.reject(new Error("not used by this test")); + const access = createFileAccess(); + expect(access.supportsNativePicker()).toBe(true); + }); + + it("returns the fallback adapter when the browser has no showOpenFilePicker", () => { + Reflect.deleteProperty(window, "showOpenFilePicker"); + const access = createFileAccess(); + expect(access.supportsNativePicker()).toBe(false); + }); +}); diff --git a/packages/web/src/adapters/fileAccess/fallbackFileAccess.test.ts b/packages/web/src/adapters/fileAccess/fallbackFileAccess.test.ts new file mode 100644 index 000000000..09d91031e --- /dev/null +++ b/packages/web/src/adapters/fileAccess/fallbackFileAccess.test.ts @@ -0,0 +1,122 @@ +/// +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createFallbackFileAccess } from "./fallbackFileAccess"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// The adapter only ever reads input.files?.[0] -- a numeric-indexed, length-and-item object is all FileList's real interface requires for that, so this builds one directly rather than via object-spreading a File[] (which TypeScript flags as overwriting length/index properties it considers already declared by the array's own structural type). +function fileList(files: File[]): FileList { + const list: FileList = { + length: files.length, + item: (index: number) => files[index] ?? null, + [Symbol.iterator]: () => files[Symbol.iterator](), + }; + files.forEach((file, index) => { + list[index] = file; + }); + return list; +} + +// The adapter drives the picker via input.click(), which a real browser resolves only after the user interacts -- here we intercept the click itself to synthesize the OS picker's outcome (a chosen file, or none) before dispatching the 'change' listener the code awaits. +function stubPickedFiles(files: File[]): void { + vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( + this: HTMLInputElement, + ) { + Object.defineProperty(this, "files", { + value: fileList(files), + configurable: true, + }); + this.dispatchEvent(new Event("change")); + }); +} + +describe("createFallbackFileAccess", () => { + it("reports no native picker support", () => { + expect(createFallbackFileAccess().supportsNativePicker()).toBe(false); + }); + + it("resolves the opened file's bytes and name when a file is chosen", async () => { + const file = new File([new Uint8Array([1, 2, 3])], "report.pdf", { + type: "application/pdf", + }); + stubPickedFiles([file]); + const opened = await createFallbackFileAccess().openFile({}); + expect(opened?.name).toBe("report.pdf"); + expect(Array.from(opened?.bytes ?? [])).toEqual([1, 2, 3]); + expect(opened?.handle).toBeUndefined(); + }); + + it("resolves undefined when the picker is dismissed with no file chosen", async () => { + stubPickedFiles([]); + const opened = await createFallbackFileAccess().openFile({}); + expect(opened).toBeUndefined(); + }); + + it("flattens and joins an accept map's extension groups into the input's accept attribute", async () => { + let capturedAccept = ""; + vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( + this: HTMLInputElement, + ) { + capturedAccept = this.accept; + Object.defineProperty(this, "files", { + value: fileList([]), + configurable: true, + }); + this.dispatchEvent(new Event("change")); + }); + await createFallbackFileAccess().openFile({ + accept: { + "application/pdf": [".pdf"], + "text/markdown": [".md", ".markdown"], + }, + }); + expect(capturedAccept).toBe(".pdf,.md,.markdown"); + }); + + it("leaves the input's accept attribute empty when no accept option is given", async () => { + let capturedAccept = "not set"; + vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( + this: HTMLInputElement, + ) { + capturedAccept = this.accept; + Object.defineProperty(this, "files", { + value: fileList([]), + configurable: true, + }); + this.dispatchEvent(new Event("change")); + }); + await createFallbackFileAccess().openFile({}); + expect(capturedAccept).toBe(""); + }); + + it("saves via a Blob-URL download anchor and revokes the object URL afterwards", async () => { + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:mock-url"); + const revokeObjectURLSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}); + let clickedHref = ""; + let clickedDownload = ""; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + clickedHref = this.href; + clickedDownload = this.download; + }); + + const result = await createFallbackFileAccess().saveFile( + new Uint8Array([1, 2, 3]), + { suggestedName: "out.pdf", mimeType: "application/pdf" }, + ); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + expect(clickedHref).toBe("blob:mock-url"); + expect(clickedDownload).toBe("out.pdf"); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url"); + expect(result).toEqual({}); + }); +}); diff --git a/packages/web/src/adapters/fileAccess/nativeFileAccess.test.ts b/packages/web/src/adapters/fileAccess/nativeFileAccess.test.ts new file mode 100644 index 000000000..e69d2ca3d --- /dev/null +++ b/packages/web/src/adapters/fileAccess/nativeFileAccess.test.ts @@ -0,0 +1,250 @@ +/// +/// +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createNativeFileAccess } from "./nativeFileAccess"; + +afterEach(() => { + vi.restoreAllMocks(); + Reflect.deleteProperty(window, "showOpenFilePicker"); + Reflect.deleteProperty(window, "showSaveFilePicker"); +}); + +// A fully-typed FileSystemFileHandle double: every member the real interface requires, not just the two (getFile/createWritable) this adapter actually calls, so the double stays sound without casting away the type it stands in for. +function stubHandle( + bytes: Uint8Array, + name: string, +): FileSystemFileHandle { + const file = new File([bytes], name); + return { + kind: "file", + name, + isFile: true, + isDirectory: false, + isSameEntry: () => Promise.resolve(false), + queryPermission: () => Promise.resolve("granted"), + requestPermission: () => Promise.resolve("granted"), + getFile: () => Promise.resolve(file), + createWritable: () => + Promise.reject(new Error("createWritable not stubbed on this handle")), + }; +} + +// Likewise a fully-typed FileSystemWritableFileStream double (write/close are spies the save tests assert on; the rest of WritableStream's own required surface is inert filler this adapter never touches). +function stubWritable() { + const write = vi.fn(() => Promise.resolve()); + const close = vi.fn(() => Promise.resolve()); + const stream: FileSystemWritableFileStream = { + locked: false, + write, + close, + abort: () => Promise.resolve(), + getWriter: () => { + throw new Error("getWriter is not implemented in this test double"); + }, + seek: () => Promise.resolve(), + truncate: () => Promise.resolve(), + }; + return { stream, write, close }; +} + +describe("createNativeFileAccess", () => { + it("reports native picker support", () => { + expect(createNativeFileAccess().supportsNativePicker()).toBe(true); + }); + + describe("openFile", () => { + it("resolves the chosen file's bytes, name, and handle", async () => { + const handle = stubHandle(new Uint8Array([9, 8, 7]), "a.docx"); + const showOpenFilePicker = vi.fn().mockResolvedValue([handle]); + window.showOpenFilePicker = showOpenFilePicker; + + const opened = await createNativeFileAccess().openFile({}); + expect(opened?.name).toBe("a.docx"); + expect(Array.from(opened?.bytes ?? [])).toEqual([9, 8, 7]); + expect(opened?.handle).toBe(handle); + }); + + it("passes a Document-described types array built from the given accept option", async () => { + const handle = stubHandle(new Uint8Array([1]), "a.pdf"); + const showOpenFilePicker = vi.fn().mockResolvedValue([handle]); + window.showOpenFilePicker = showOpenFilePicker; + const accept = { "application/pdf": [".pdf"] } as Record< + MIMEType, + FileExtension[] + >; + + await createNativeFileAccess().openFile({ accept }); + + expect(showOpenFilePicker).toHaveBeenCalledWith({ + types: [{ description: "Document", accept }], + multiple: false, + }); + }); + + it("passes undefined types when no accept option is given", async () => { + const handle = stubHandle(new Uint8Array([1]), "a.pdf"); + const showOpenFilePicker = vi.fn().mockResolvedValue([handle]); + window.showOpenFilePicker = showOpenFilePicker; + + await createNativeFileAccess().openFile({}); + + expect(showOpenFilePicker).toHaveBeenCalledWith({ + types: undefined, + multiple: false, + }); + }); + + it("resolves undefined when the user aborts the native picker", async () => { + const showOpenFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("cancelled", "AbortError")); + window.showOpenFilePicker = showOpenFilePicker; + + const opened = await createNativeFileAccess().openFile({}); + expect(opened).toBeUndefined(); + }); + + it("rethrows a picker failure that is not an AbortError", async () => { + const showOpenFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("denied", "SecurityError")); + window.showOpenFilePicker = showOpenFilePicker; + + await expect(createNativeFileAccess().openFile({})).rejects.toThrow( + "denied", + ); + }); + + it("rethrows a non-DOMException failure from the picker", async () => { + const showOpenFilePicker = vi.fn().mockRejectedValue(new Error("boom")); + window.showOpenFilePicker = showOpenFilePicker; + + await expect(createNativeFileAccess().openFile({})).rejects.toThrow( + "boom", + ); + }); + + it("resolves undefined when the picker resolves an empty handle list", async () => { + const showOpenFilePicker = vi.fn().mockResolvedValue([]); + window.showOpenFilePicker = showOpenFilePicker; + + const opened = await createNativeFileAccess().openFile({}); + expect(opened).toBeUndefined(); + }); + }); + + describe("saveFile", () => { + it("writes bytes through a writable stream and resolves the handle", async () => { + const { stream, write, close } = stubWritable(); + const handle = stubHandle(new Uint8Array([1]), "out.pdf"); + handle.createWritable = () => Promise.resolve(stream); + const showSaveFilePicker = vi.fn().mockResolvedValue(handle); + window.showSaveFilePicker = showSaveFilePicker; + + const bytes = new Uint8Array([1, 2, 3]); + const result = await createNativeFileAccess().saveFile(bytes, { + suggestedName: "out.pdf", + mimeType: "application/pdf", + }); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: "out.pdf", + types: [ + { + description: "Document", + accept: { "application/pdf": [".pdf"] }, + }, + ], + }); + expect(write).toHaveBeenCalledWith(bytes); + expect(close).toHaveBeenCalledTimes(1); + expect(result).toEqual({ handle }); + }); + + it("derives the accept extension from the suggested name's own extension", async () => { + const { stream } = stubWritable(); + const handle = stubHandle(new Uint8Array([1]), "archive.tar.docx"); + handle.createWritable = () => Promise.resolve(stream); + const showSaveFilePicker = vi.fn().mockResolvedValue(handle); + window.showSaveFilePicker = showSaveFilePicker; + + await createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "archive.tar.docx", + mimeType: "application/vnd.openxmlformats", + }); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: "archive.tar.docx", + types: [ + { + description: "Document", + accept: { "application/vnd.openxmlformats": [".docx"] }, + }, + ], + }); + }); + + it("uses the whole suggested name as the accept extension when it carries no dot at all", async () => { + const { stream } = stubWritable(); + const handle = stubHandle(new Uint8Array([1]), "noextension"); + handle.createWritable = () => Promise.resolve(stream); + const showSaveFilePicker = vi.fn().mockResolvedValue(handle); + window.showSaveFilePicker = showSaveFilePicker; + + await createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "noextension", + mimeType: "application/octet-stream", + }); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: "noextension", + types: [ + { + description: "Document", + accept: { "application/octet-stream": [".noextension"] }, + }, + ], + }); + }); + + it("resolves an empty result when the user aborts the save picker", async () => { + const showSaveFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("cancelled", "AbortError")); + window.showSaveFilePicker = showSaveFilePicker; + + const result = await createNativeFileAccess().saveFile( + new Uint8Array([1]), + { suggestedName: "a.pdf", mimeType: "application/pdf" }, + ); + expect(result).toEqual({}); + }); + + it("rethrows a save-picker failure that is not an AbortError", async () => { + const showSaveFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("denied", "SecurityError")); + window.showSaveFilePicker = showSaveFilePicker; + + await expect( + createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "a.pdf", + mimeType: "application/pdf", + }), + ).rejects.toThrow("denied"); + }); + + it("rethrows a non-DOMException failure from the save picker", async () => { + const showSaveFilePicker = vi.fn().mockRejectedValue(new Error("boom")); + window.showSaveFilePicker = showSaveFilePicker; + + await expect( + createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "a.pdf", + mimeType: "application/pdf", + }), + ).rejects.toThrow("boom"); + }); + }); +}); diff --git a/packages/web/src/adapters/fileAccess/nativeFileAccess.ts b/packages/web/src/adapters/fileAccess/nativeFileAccess.ts index 7a954bf8c..6aaa5f59d 100644 --- a/packages/web/src/adapters/fileAccess/nativeFileAccess.ts +++ b/packages/web/src/adapters/fileAccess/nativeFileAccess.ts @@ -38,7 +38,8 @@ export function createNativeFileAccess(): FileAccessPort { description: "Document", accept: { [options.mimeType]: [ - `.${options.suggestedName.split(".").pop() ?? "bin"}`, + // String.split on any input, including one with no '.' at all, always returns at least one element, so pop() here can never be undefined -- there is no genuinely-extension-less case to fall back for. + `.${options.suggestedName.split(".").pop()}`, ], }, }, From 54693fce545a7d2766945511ba72ed8a396f63cb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 10:53:51 +0100 Subject: [PATCH 04/92] test(web): cover the IndexedDB-backed recent-files store Neither src/db/dexie.ts nor src/hooks/useRecentFiles.ts had any unit coverage -- jsdom implements no IndexedDB of its own, so nothing could construct the Dexie instance at module load without one. Adds fake-indexeddb (installed globally in the unit project's test setup, ahead of any test's own import of the db module) and exercises the database's own table schema plus recordRecentFile's 20-entry FIFO eviction and removeRecentFile. --- packages/web/package.json | 1 + packages/web/src/db/dexie.test.ts | 91 +++++++++++++++++++ packages/web/src/hooks/useRecentFiles.test.ts | 68 ++++++++++++++ packages/web/src/test/setup.ts | 3 + pnpm-lock.yaml | 9 ++ 5 files changed, 172 insertions(+) create mode 100644 packages/web/src/db/dexie.test.ts create mode 100644 packages/web/src/hooks/useRecentFiles.test.ts diff --git a/packages/web/package.json b/packages/web/package.json index f47af632c..96ec625d0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -80,6 +80,7 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", + "fake-indexeddb": "6.2.5", "globals": "17.9.0", "husky": "9.1.7", "jiti": "2.7.0", diff --git a/packages/web/src/db/dexie.test.ts b/packages/web/src/db/dexie.test.ts new file mode 100644 index 000000000..d6434892b --- /dev/null +++ b/packages/web/src/db/dexie.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { db } from "./dexie"; + +beforeEach(async () => { + await db.recentFiles.clear(); + await db.preferences.clear(); + await db.customFonts.clear(); + await db.editorSessions.clear(); +}); + +afterEach(async () => { + await db.recentFiles.clear(); + await db.preferences.clear(); + await db.customFonts.clear(); + await db.editorSessions.clear(); +}); + +describe("DocumentsDatabase", () => { + it("names the database exadev-documents", () => { + expect(db.name).toBe("exadev-documents"); + }); + + it("stores and retrieves a recent-file record with an auto-assigned id", async () => { + const id = await db.recentFiles.add({ + format: "docx", + name: "a.docx", + sizeBytes: 42, + lastOpenedAt: 1, + }); + if (id === undefined) throw new Error("expected an auto-assigned id"); + const record = await db.recentFiles.get(id); + expect(record?.name).toBe("a.docx"); + expect(record?.sizeBytes).toBe(42); + }); + + it("indexes recentFiles by format and lastOpenedAt for ordered/filtered queries", async () => { + await db.recentFiles.bulkAdd([ + { format: "docx", name: "a", sizeBytes: 1, lastOpenedAt: 10 }, + { format: "pdf", name: "b", sizeBytes: 1, lastOpenedAt: 20 }, + { format: "docx", name: "c", sizeBytes: 1, lastOpenedAt: 30 }, + ]); + const byFormat = await db.recentFiles + .where("format") + .equals("docx") + .toArray(); + expect(byFormat.map((record) => record.name).sort()).toEqual(["a", "c"]); + + const ordered = await db.recentFiles.orderBy("lastOpenedAt").toArray(); + expect(ordered.map((record) => record.name)).toEqual(["a", "b", "c"]); + }); + + it("stores a preference record keyed by its own key column", async () => { + await db.preferences.put({ key: "theme", value: "dark" }); + const record = await db.preferences.get("theme"); + expect(record?.value).toBe("dark"); + }); + + it("stores and indexes custom-font records by family", async () => { + const bytes = new Blob([new Uint8Array([1, 2, 3])]); + await db.customFonts.add({ + family: "Custom Sans", + bold: false, + italic: false, + bytes, + }); + const found = await db.customFonts + .where("family") + .equals("Custom Sans") + .first(); + expect(found?.bold).toBe(false); + expect(found?.italic).toBe(false); + }); + + it("stores and indexes editor-session records by sessionId, format, lastSnapshotAt, and cleanlyClosed", async () => { + await db.editorSessions.add({ + sessionId: "s1", + format: "markdown", + originalName: "a.md", + lastSnapshotAt: 5, + sizeBytes: 10, + cleanlyClosed: false, + }); + const found = await db.editorSessions + .where("sessionId") + .equals("s1") + .first(); + expect(found?.originalName).toBe("a.md"); + expect(found?.cleanlyClosed).toBe(false); + }); +}); diff --git a/packages/web/src/hooks/useRecentFiles.test.ts b/packages/web/src/hooks/useRecentFiles.test.ts new file mode 100644 index 000000000..ff848d76b --- /dev/null +++ b/packages/web/src/hooks/useRecentFiles.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { db } from "../db/dexie"; +import { recordRecentFile, removeRecentFile } from "./useRecentFiles"; + +beforeEach(async () => { + await db.recentFiles.clear(); +}); + +afterEach(async () => { + await db.recentFiles.clear(); +}); + +describe("recordRecentFile", () => { + it("stamps the entry with the current time and stores it", async () => { + await recordRecentFile({ format: "docx", name: "a.docx", sizeBytes: 10 }); + const all = await db.recentFiles.toArray(); + expect(all).toHaveLength(1); + expect(all[0]?.name).toBe("a.docx"); + expect(typeof all[0]?.lastOpenedAt).toBe("number"); + }); + + it("evicts the single stalest entry once the table exceeds its 20-entry limit", async () => { + for (let i = 0; i < 20; i++) { + await recordRecentFile({ + format: "docx", + name: `file-${i}`, + sizeBytes: 1, + }); + } + expect(await db.recentFiles.count()).toBe(20); + + await recordRecentFile({ format: "docx", name: "file-20", sizeBytes: 1 }); + + const remaining = await db.recentFiles.count(); + expect(remaining).toBe(20); + const names = (await db.recentFiles.toArray()).map((r) => r.name); + expect(names).not.toContain("file-0"); + expect(names).toContain("file-20"); + }); + + it("does not evict anything while at or under the limit", async () => { + for (let i = 0; i < 20; i++) { + await recordRecentFile({ + format: "docx", + name: `file-${i}`, + sizeBytes: 1, + }); + } + const names = (await db.recentFiles.toArray()).map((r) => r.name); + expect(names).toHaveLength(20); + expect(names).toContain("file-0"); + }); +}); + +describe("removeRecentFile", () => { + it("deletes the record with the given id", async () => { + const id = await db.recentFiles.add({ + format: "docx", + name: "a.docx", + sizeBytes: 1, + lastOpenedAt: 1, + }); + if (id === undefined) throw new Error("expected an auto-assigned id"); + await removeRecentFile(id); + expect(await db.recentFiles.get(id)).toBeUndefined(); + }); +}); diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts index fd7a3d0af..91ceff14b 100644 --- a/packages/web/src/test/setup.ts +++ b/packages/web/src/test/setup.ts @@ -1,5 +1,8 @@ /// +// jsdom implements no IndexedDB of its own, and src/db/dexie.ts constructs its Dexie instance at module scope -- every test that imports it (directly, or transitively via a hook) needs a real IndexedDB implementation already installed globally before that import runs, not just within the one test file that happens to exercise it. +import "fake-indexeddb/auto"; + declare global { // React's own opt-in flag (no ambient type ships for it) -- see the assignment below for what it does. eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 857732fec..2729dc63a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1645,6 +1645,9 @@ importers: eslint-plugin-react-refresh: specifier: 0.5.3 version: 0.5.3(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2)) + fake-indexeddb: + specifier: 6.2.5 + version: 6.2.5 globals: specifier: 17.9.0 version: 17.9.0 @@ -5825,6 +5828,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -12910,6 +12917,8 @@ snapshots: expect-type@1.4.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} From da62909a7c219b02ff3cd331d11707dccf0553e5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 10:54:40 +0100 Subject: [PATCH 05/92] test(web): cover every RPC-client-wrapping hook and the worker document converter None of src/hooks/**'s useMutation/useQuery wrappers around getRpcClient(), nor workerDocumentConverter's own convertViaWorker, had any unit coverage. Adds a small, dependency-free render harness (mounting a hook inside a real jsdom tree via react-dom/client and a fresh QueryClientProvider, the same approach src/ui/contentBlocks.test.tsx already established for component-level tests) and a fully-typed mock RPC client fixture (one vi.fn() per router procedure), then uses both to exercise useConversions, useDocumentFormats, useReadMetadata, useWriteMetadata, useExtractSourceFonts, useReadContent, useRestoreContent, useReadOdb, useOdmRender, useConvert, the five useEditorSession mutations, usePdfObjectUrl's blob-URL lifecycle, and convertViaWorker's own field-narrowing of its RPC call. --- .../workerDocumentConverter.test.ts | 55 +++++++++ .../web/src/hooks/useContentDump.test.tsx | 59 ++++++++++ .../web/src/hooks/useConversions.test.tsx | 47 ++++++++ packages/web/src/hooks/useConvert.test.tsx | 38 ++++++ .../web/src/hooks/useEditorSession.test.tsx | 109 ++++++++++++++++++ packages/web/src/hooks/useFonts.test.tsx | 29 +++++ packages/web/src/hooks/useMetadata.test.tsx | 53 +++++++++ .../web/src/hooks/useOdbInventory.test.tsx | 45 ++++++++ packages/web/src/hooks/useOdmRender.test.tsx | 30 +++++ .../web/src/hooks/usePdfObjectUrl.test.tsx | 97 ++++++++++++++++ packages/web/src/test/mockRpcClient.ts | 43 +++++++ packages/web/src/test/renderHook.tsx | 43 +++++++ 12 files changed, 648 insertions(+) create mode 100644 packages/web/src/adapters/documentConverter/workerDocumentConverter.test.ts create mode 100644 packages/web/src/hooks/useContentDump.test.tsx create mode 100644 packages/web/src/hooks/useConversions.test.tsx create mode 100644 packages/web/src/hooks/useConvert.test.tsx create mode 100644 packages/web/src/hooks/useEditorSession.test.tsx create mode 100644 packages/web/src/hooks/useFonts.test.tsx create mode 100644 packages/web/src/hooks/useMetadata.test.tsx create mode 100644 packages/web/src/hooks/useOdbInventory.test.tsx create mode 100644 packages/web/src/hooks/useOdmRender.test.tsx create mode 100644 packages/web/src/hooks/usePdfObjectUrl.test.tsx create mode 100644 packages/web/src/test/mockRpcClient.ts create mode 100644 packages/web/src/test/renderHook.tsx diff --git a/packages/web/src/adapters/documentConverter/workerDocumentConverter.test.ts b/packages/web/src/adapters/documentConverter/workerDocumentConverter.test.ts new file mode 100644 index 000000000..096f88235 --- /dev/null +++ b/packages/web/src/adapters/documentConverter/workerDocumentConverter.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../../test/mockRpcClient"; + +vi.mock("../../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../../rpc/client"; +import { convertViaWorker } from "./workerDocumentConverter"; + +describe("convertViaWorker", () => { + it("calls the RPC client's convert with only the source, targetFormat, and bytes fields", async () => { + const client = createMockRpcClient(); + const output = { + document: { format: "pdf" as const, bytes: new Uint8Array([9]) }, + diagnostics: [], + content: undefined, + }; + vi.mocked(client.convert).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const controller = new AbortController(); + const result = await convertViaWorker({ + source: "docx", + targetFormat: "pdf", + bytes: new Uint8Array([1, 2]), + signal: controller.signal, + }); + + expect(client.convert).toHaveBeenCalledWith( + { source: "docx", targetFormat: "pdf", bytes: new Uint8Array([1, 2]) }, + { signal: controller.signal }, + ); + expect(result).toEqual(output); + }); + + it("passes an undefined signal through unchanged when the caller supplies none", async () => { + const client = createMockRpcClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf" as const, bytes: new Uint8Array() }, + diagnostics: [], + content: undefined, + }); + vi.mocked(getRpcClient).mockReturnValue(client); + + await convertViaWorker({ + source: "docx", + targetFormat: "pdf", + bytes: new Uint8Array([1]), + }); + + expect(client.convert).toHaveBeenCalledWith(expect.anything(), { + signal: undefined, + }); + }); +}); diff --git a/packages/web/src/hooks/useContentDump.test.tsx b/packages/web/src/hooks/useContentDump.test.tsx new file mode 100644 index 000000000..fb493d96d --- /dev/null +++ b/packages/web/src/hooks/useContentDump.test.tsx @@ -0,0 +1,59 @@ +import { assembleTree } from "document-schema.js"; +import type { ContentDocument } from "documents.js"; +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useReadContent, useRestoreContent } from "./useContentDump"; + +describe("useReadContent", () => { + it("calls content.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const content: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + const output = { + content, + // Stamped by hand rather than via documents.js's own documentTreeWithSchema helper: UI code (this file lives under src/hooks/) may not import documents.js's runtime functions directly (see eslint.config.ts's import-boundary rule), and the schema's own $schema field is a plain string, not a pinned literal, so a hand-applied stamp is exactly as valid a fixture as the real helper's output. + package: { ...assembleTree(content), $schema: "test-schema" }, + }; + vi.mocked(client.content.read).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useReadContent(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.content.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); + +describe("useRestoreContent", () => { + it("calls content.restore with the given input and resolves its bytes", async () => { + const client = createMockRpcClient(); + const output = { bytes: new Uint8Array([1, 2]) }; + vi.mocked(client.content.restore).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useRestoreContent(), + ); + const input = { format: "docx" as const, package: {} }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.content.restore).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useConversions.test.tsx b/packages/web/src/hooks/useConversions.test.tsx new file mode 100644 index 000000000..cbe700077 --- /dev/null +++ b/packages/web/src/hooks/useConversions.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useConversions, useDocumentFormats } from "./useConversions"; + +describe("useConversions", () => { + it("fetches the conversion pair list via formats.listConversions", async () => { + const client = createMockRpcClient(); + const pairs: { source: "docx"; target: "pdf" }[] = [ + { source: "docx", target: "pdf" }, + ]; + vi.mocked(client.formats.listConversions).mockResolvedValue(pairs); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useConversions(), + ); + await vi.waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual(pairs); + expect(client.formats.listConversions).toHaveBeenCalledTimes(1); + unmount(); + }); +}); + +describe("useDocumentFormats", () => { + it("fetches the document format list via formats.list", async () => { + const client = createMockRpcClient(); + vi.mocked(client.formats.list).mockResolvedValue(["docx", "pdf"]); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useDocumentFormats(), + ); + await vi.waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual(["docx", "pdf"]); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useConvert.test.tsx b/packages/web/src/hooks/useConvert.test.tsx new file mode 100644 index 000000000..d8fafea4b --- /dev/null +++ b/packages/web/src/hooks/useConvert.test.tsx @@ -0,0 +1,38 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useConvert } from "./useConvert"; + +describe("useConvert", () => { + it("drives convertViaWorker's convert call and resolves its result", async () => { + const client = createMockRpcClient(); + const output = { + document: { format: "pdf" as const, bytes: new Uint8Array([1]) }, + diagnostics: [], + content: undefined, + }; + vi.mocked(client.convert).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => useConvert()); + const input = { + source: "docx" as const, + targetFormat: "pdf" as const, + bytes: new Uint8Array([1]), + }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.convert).toHaveBeenCalledWith( + { source: "docx", targetFormat: "pdf", bytes: input.bytes }, + { signal: undefined }, + ); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useEditorSession.test.tsx b/packages/web/src/hooks/useEditorSession.test.tsx new file mode 100644 index 000000000..1c7afe903 --- /dev/null +++ b/packages/web/src/hooks/useEditorSession.test.tsx @@ -0,0 +1,109 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { + useAddParagraph, + useOpenEditor, + useRemoveParagraph, + useSaveEditor, + useSetParagraphText, +} from "./useEditorSession"; + +const snapshot = { id: 1, paragraphs: ["a", "b"] }; + +describe("useOpenEditor", () => { + it("calls editor.open with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useOpenEditor(), + ); + const input = { format: "markdown" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.open).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useSetParagraphText", () => { + it("calls editor.setParagraphText with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.setParagraphText).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useSetParagraphText(), + ); + const input = { id: 1, index: 0, text: "edited" }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.setParagraphText).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useAddParagraph", () => { + it("calls editor.addParagraph with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.addParagraph).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useAddParagraph(), + ); + const input = { id: 1, text: "new" }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.addParagraph).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useRemoveParagraph", () => { + it("calls editor.removeParagraph with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.removeParagraph).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useRemoveParagraph(), + ); + const input = { id: 1, index: 0 }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.removeParagraph).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useSaveEditor", () => { + it("calls editor.save with the given input and resolves its bytes", async () => { + const client = createMockRpcClient(); + const output = { bytes: new Uint8Array([1, 2]) }; + vi.mocked(client.editor.save).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useSaveEditor(), + ); + const input = { id: 1 }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.save).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useFonts.test.tsx b/packages/web/src/hooks/useFonts.test.tsx new file mode 100644 index 000000000..48ea9aee4 --- /dev/null +++ b/packages/web/src/hooks/useFonts.test.tsx @@ -0,0 +1,29 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useExtractSourceFonts } from "./useFonts"; + +describe("useExtractSourceFonts", () => { + it("calls fonts.extractSourceFonts with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const fonts = [{ family: "Times New Roman", bold: false, italic: false }]; + vi.mocked(client.fonts.extractSourceFonts).mockResolvedValue(fonts); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useExtractSourceFonts(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.fonts.extractSourceFonts).toHaveBeenCalledWith(input); + expect(resolved).toEqual(fonts); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useMetadata.test.tsx b/packages/web/src/hooks/useMetadata.test.tsx new file mode 100644 index 000000000..f6050ddef --- /dev/null +++ b/packages/web/src/hooks/useMetadata.test.tsx @@ -0,0 +1,53 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useReadMetadata, useWriteMetadata } from "./useMetadata"; + +describe("useReadMetadata", () => { + it("calls metadata.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const metadata = { title: "a title" }; + vi.mocked(client.metadata.read).mockResolvedValue(metadata); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useReadMetadata(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.metadata.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(metadata); + unmount(); + }); +}); + +describe("useWriteMetadata", () => { + it("calls metadata.write with the given input and resolves its bytes", async () => { + const client = createMockRpcClient(); + const bytes = new Uint8Array([9, 9]); + vi.mocked(client.metadata.write).mockResolvedValue(bytes); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useWriteMetadata(), + ); + const input = { + sourceFormat: "docx" as const, + targetFormat: "docx" as const, + bytes: new Uint8Array([1]), + overrides: {}, + }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.metadata.write).toHaveBeenCalledWith(input); + expect(resolved).toBe(bytes); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useOdbInventory.test.tsx b/packages/web/src/hooks/useOdbInventory.test.tsx new file mode 100644 index 000000000..11331144e --- /dev/null +++ b/packages/web/src/hooks/useOdbInventory.test.tsx @@ -0,0 +1,45 @@ +import type { ContentDocument } from "documents.js"; +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useReadOdb } from "./useOdbInventory"; + +describe("useReadOdb", () => { + it("calls odb.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const content: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + const output = { + inventory: { + tables: [] as string[], + queries: [] as { + name: string; + command: string; + escapeProcessing?: boolean; + }[], + forms: [] as { name: string; href: string; asTemplate?: boolean }[], + reports: [] as { name: string; href: string; asTemplate?: boolean }[], + }, + content, + }; + vi.mocked(client.odb.read).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => useReadOdb()); + const input = { bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.odb.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useOdmRender.test.tsx b/packages/web/src/hooks/useOdmRender.test.tsx new file mode 100644 index 000000000..0f987973e --- /dev/null +++ b/packages/web/src/hooks/useOdmRender.test.tsx @@ -0,0 +1,30 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useOdmRender } from "./useOdmRender"; + +describe("useOdmRender", () => { + it("calls odm.render with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const output = { ok: true as const, pdf: new Uint8Array([1, 2]) }; + vi.mocked(client.odm.render).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => useOdmRender()); + const input = { + master: new Uint8Array([1]), + chapters: [{ href: "ch1.odt", bytes: new Uint8Array([2]) }], + }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.odm.render).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/usePdfObjectUrl.test.tsx b/packages/web/src/hooks/usePdfObjectUrl.test.tsx new file mode 100644 index 000000000..348ea09be --- /dev/null +++ b/packages/web/src/hooks/usePdfObjectUrl.test.tsx @@ -0,0 +1,97 @@ +/// +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { usePdfObjectUrl } from "./usePdfObjectUrl"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// A tiny harness scoped to one test: unlike a module-level mutable variable (which the react-hooks/immutability lint rule correctly flags as a cross-render hazard), a `result` object created fresh inside this factory function is owned by the single call site that renders into it, the same pattern src/test/renderHook.tsx already establishes for the react-query hooks. +function mountPdfObjectUrl(initialBytes: Uint8Array | undefined) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const result: { current: string | undefined } = { current: undefined }; + + function Harness({ bytes }: { bytes: Uint8Array | undefined }) { + result.current = usePdfObjectUrl(bytes); + return null; + } + + function rerender(bytes: Uint8Array | undefined) { + act(() => { + root.render(); + }); + } + + rerender(initialBytes); + + return { + result, + rerender, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("usePdfObjectUrl", () => { + it("returns undefined when bytes is undefined", () => { + const { result, unmount } = mountPdfObjectUrl(undefined); + expect(result.current).toBeUndefined(); + unmount(); + }); + + it("creates a blob: object URL from the given bytes as application/pdf", () => { + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:one"); + const { result, unmount } = mountPdfObjectUrl(new Uint8Array([1, 2, 3])); + expect(result.current).toBe("blob:one"); + const [blob] = createObjectURLSpy.mock.calls[0] ?? []; + expect((blob as Blob).type).toBe("application/pdf"); + unmount(); + }); + + it("revokes the previous object URL and creates a fresh one when bytes changes", () => { + const revokeObjectURLSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}); + vi.spyOn(URL, "createObjectURL") + .mockReturnValueOnce("blob:one") + .mockReturnValueOnce("blob:two"); + + const { result, rerender, unmount } = mountPdfObjectUrl( + new Uint8Array([1]), + ); + expect(result.current).toBe("blob:one"); + + rerender(new Uint8Array([2])); + expect(result.current).toBe("blob:two"); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:one"); + unmount(); + }); + + it("revokes the object URL and returns undefined once bytes goes back to undefined", () => { + const revokeObjectURLSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:one"); + + const { result, rerender, unmount } = mountPdfObjectUrl( + new Uint8Array([1]), + ); + expect(result.current).toBe("blob:one"); + + rerender(undefined); + expect(result.current).toBeUndefined(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:one"); + unmount(); + }); +}); diff --git a/packages/web/src/test/mockRpcClient.ts b/packages/web/src/test/mockRpcClient.ts new file mode 100644 index 000000000..4edf69530 --- /dev/null +++ b/packages/web/src/test/mockRpcClient.ts @@ -0,0 +1,43 @@ +import type { RouterClient } from "@orpc/server"; +import { vi } from "vitest"; + +import type { AppRouter } from "../rpc/router"; + +// A full structural stand-in for getRpcClient()'s return value, one vi.fn() per procedure the real router (src/rpc/router.ts) exposes -- every hook test replaces getRpcClient with a function returning one of these, then overrides only the procedure(s) it actually calls via mockResolvedValue/mockImplementation. vi.fn()'s own type can't be verified against oRPC's generic per-procedure call signature (input, plus an options object carrying signal), so the return is cast at the one point where the object's shape is assembled, not scattered per test. +export function createMockRpcClient(): RouterClient { + return { + formats: { + list: vi.fn(), + listConversions: vi.fn(), + }, + convert: vi.fn(), + content: { + read: vi.fn(), + restore: vi.fn(), + }, + odb: { + read: vi.fn(), + }, + metadata: { + read: vi.fn(), + write: vi.fn(), + }, + fonts: { + describe: vi.fn(), + extractSourceFonts: vi.fn(), + }, + pdf: { + inspect: vi.fn(), + }, + odm: { + render: vi.fn(), + }, + editor: { + open: vi.fn(), + setParagraphText: vi.fn(), + addParagraph: vi.fn(), + removeParagraph: vi.fn(), + save: vi.fn(), + }, + }; +} diff --git a/packages/web/src/test/renderHook.tsx b/packages/web/src/test/renderHook.tsx new file mode 100644 index 000000000..645806bd1 --- /dev/null +++ b/packages/web/src/test/renderHook.tsx @@ -0,0 +1,43 @@ +/// +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// A minimal, dependency-free stand-in for @testing-library/react's renderHook: mounts the given hook inside a real jsdom tree (via react-dom/client, the same approach src/ui/contentBlocks.test.tsx already established for component-level tests) wrapped in a fresh QueryClientProvider, since every hook under src/hooks/** is a useMutation/useQuery/useLiveQuery consumer that throws without one. `result.current` is updated on every render, so awaiting a mutate call and then reading it back observes the hook's latest state. +export interface RenderedHook { + result: { current: T }; + unmount: () => void; +} + +export function renderHookWithQueryClient( + useHookFn: () => T, +): RenderedHook { + const queryClient = new QueryClient(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + const result = {} as { current: T }; + + function Harness() { + result.current = useHookFn(); + return null; + } + + act(() => { + root.render( + + + , + ); + }); + + return { + result, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} From 5081a3632f0a258431e5e28ad7dcd71f7f130bf9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 10:59:53 +0100 Subject: [PATCH 06/92] test(web): cover the structural PDF/content inspection hooks contentInspectResult, useReadContent, useInspectPdfBytes, and useInspectDocument (src/hooks/ useInspect.ts) had no coverage -- exercises the pure content-backed result builder, the content.read/pdf.inspect RPC calls, and useInspectDocument's own branch between inspecting PDF bytes directly versus converting a non-PDF source to PDF first and carrying the conversion's own diagnostics through. --- packages/web/src/hooks/useInspect.test.tsx | 160 +++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 packages/web/src/hooks/useInspect.test.tsx diff --git a/packages/web/src/hooks/useInspect.test.tsx b/packages/web/src/hooks/useInspect.test.tsx new file mode 100644 index 000000000..0707dc492 --- /dev/null +++ b/packages/web/src/hooks/useInspect.test.tsx @@ -0,0 +1,160 @@ +import { assembleTree } from "document-schema.js"; +import type { ContentDocument } from "documents.js"; +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { + contentInspectResult, + useInspectDocument, + useInspectPdfBytes, + useReadContent, +} from "./useInspect"; + +const wordprocessing: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 595, heightPt: 842 }, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + blocks: [{ kind: "paragraph", runs: [{ text: "x" }] }], + }, + ], +}; + +describe("contentInspectResult", () => { + it("builds a content-backed InspectResult with no diagnostics and the variant-aware summary", () => { + const pkg = { ...assembleTree(wordprocessing), $schema: "test-schema" }; + const result = contentInspectResult({ + content: wordprocessing, + package: pkg, + }); + + expect(result).toEqual({ + backing: "content", + diagnostics: [], + summary: ["1 section", "1 block"], + package: pkg, + }); + }); +}); + +describe("useReadContent", () => { + it("calls content.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const pkg = { ...assembleTree(wordprocessing), $schema: "test-schema" }; + const output = { content: wordprocessing, package: pkg }; + vi.mocked(client.content.read).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useReadContent(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.content.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); + +const pdfInspectOutput = { + pageCount: 2, + itemKindCounts: { text: 3 }, + metadata: { title: "a title" }, + layout: { formatVersion: 1 as const, metadata: {}, pages: [], images: {} }, +}; + +describe("useInspectPdfBytes", () => { + it("calls pdf.inspect with the given bytes and tags the result as pdf-backed with no diagnostics", async () => { + const client = createMockRpcClient(); + vi.mocked(client.pdf.inspect).mockResolvedValue(pdfInspectOutput); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useInspectPdfBytes(), + ); + const bytes = new Uint8Array([1, 2]); + const resolved = await act(() => result.current.mutateAsync(bytes)); + + expect(client.pdf.inspect).toHaveBeenCalledWith({ bytes }); + expect(resolved).toEqual({ + backing: "pdf", + ...pdfInspectOutput, + diagnostics: [], + }); + unmount(); + }); +}); + +describe("useInspectDocument", () => { + it("inspects PDF bytes directly, without converting first, when the source format is already pdf", async () => { + const client = createMockRpcClient(); + vi.mocked(client.pdf.inspect).mockResolvedValue(pdfInspectOutput); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useInspectDocument(), + ); + const bytes = new Uint8Array([1]); + const resolved = await act(() => + result.current.mutateAsync({ format: "pdf", bytes }), + ); + + expect(client.convert).not.toHaveBeenCalled(); + expect(client.pdf.inspect).toHaveBeenCalledWith({ bytes }); + expect(resolved).toEqual({ + backing: "pdf", + ...pdfInspectOutput, + diagnostics: [], + }); + unmount(); + }); + + it("converts a non-pdf source to pdf first, then inspects the converted bytes and surfaces the conversion's own diagnostics", async () => { + const client = createMockRpcClient(); + const convertedBytes = new Uint8Array([9, 9]); + const diagnostics = [ + { + severity: "warning" as const, + code: "font-sub", + message: "substituted a font", + }, + ]; + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf" as const, bytes: convertedBytes }, + diagnostics, + content: undefined, + }); + vi.mocked(client.pdf.inspect).mockResolvedValue(pdfInspectOutput); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useInspectDocument(), + ); + const sourceBytes = new Uint8Array([1]); + const resolved = await act(() => + result.current.mutateAsync({ format: "docx", bytes: sourceBytes }), + ); + + expect(client.convert).toHaveBeenCalledWith({ + source: "docx", + targetFormat: "pdf", + bytes: sourceBytes, + }); + expect(client.pdf.inspect).toHaveBeenCalledWith({ bytes: convertedBytes }); + expect(resolved).toEqual({ + backing: "pdf", + ...pdfInspectOutput, + diagnostics, + }); + unmount(); + }); +}); From a4471f1f3a46380f3f43298a113a28c2dcce42f9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 11:06:48 +0100 Subject: [PATCH 07/92] test(web): exercise the router's own oRPC procedures end to end router.test.ts already covered normalizeContentForSource and the editor-session helper functions directly, but none of router.ts's actual exported procedures (formats.list/listConversions, convert, content.read/restore, metadata.read/write, fonts.extractSourceFonts, pdf.inspect, and the full editor.open/setParagraphText/addParagraph/removeParagraph/save lifecycle including its unknown-session-id error path) had ever been called through oRPC's own dispatch. Uses @orpc/ server's call() to invoke each procedure directly against real markdown/docx fixtures. Forced onto vitest's node environment: jsdom's own TextEncoder constructs its Uint8Array in a different realm than the bare Uint8Array a z.instanceof(Uint8Array) input schema checks against under jsdom, which otherwise rejects every real byte payload as "expected Uint8Array, received Uint8Array". --- .../web/src/rpc/router.procedures.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 packages/web/src/rpc/router.procedures.test.ts diff --git a/packages/web/src/rpc/router.procedures.test.ts b/packages/web/src/rpc/router.procedures.test.ts new file mode 100644 index 000000000..fcf2f3918 --- /dev/null +++ b/packages/web/src/rpc/router.procedures.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment node +// jsdom's own TextEncoder (patched in from Node's util module by the unit project's jsdom environment) constructs its Uint8Array in a different realm than the one bare `Uint8Array` resolves to inside that same environment, so a router.ts procedure's z.instanceof(Uint8Array) input schema rejects it as "expected Uint8Array, received Uint8Array" -- confirmed directly by comparing `bytes instanceof Uint8Array` (false under jsdom, true under node) for the identical TextEncoder().encode() call. router.ts itself is pure Node-executable document logic with no DOM dependency, so forcing this one file onto vitest's node environment sidesteps the realm split entirely rather than working around it per call site. +import { call } from "@orpc/server"; +import { createDocx } from "documents.js"; +import { describe, expect, it } from "vitest"; + +import { router } from "./router"; + +const MARKDOWN_BYTES = new TextEncoder().encode("# Title\n\nBody text.\n"); + +describe("formats.list / formats.listConversions", () => { + it("lists every supported document format", async () => { + const formats = await call(router.formats.list, undefined); + expect(formats).toContain("docx"); + expect(formats).toContain("pdf"); + expect(formats).toContain("markdown"); + }); + + it("lists at least one real conversion pair", async () => { + const pairs = await call(router.formats.listConversions, undefined); + expect(pairs.length).toBeGreaterThan(0); + expect(pairs[0]).toHaveProperty("source"); + expect(pairs[0]).toHaveProperty("target"); + }); +}); + +describe("convert", () => { + it("converts markdown to pdf and includes the flattened content alongside the bytes", async () => { + const result = await call(router.convert, { + source: "markdown", + targetFormat: "pdf", + bytes: MARKDOWN_BYTES, + }); + expect(result.document.format).toBe("pdf"); + expect(result.document.bytes.byteLength).toBeGreaterThan(0); + expect(result.content?.kind).toBe("wordprocessing"); + }); +}); + +describe("content.read / content.restore", () => { + it("reads markdown content and round-trips it through restore back to markdown bytes", async () => { + const read = await call(router.content.read, { + format: "markdown", + bytes: MARKDOWN_BYTES, + }); + expect(read.content.kind).toBe("wordprocessing"); + expect(read.package.$schema).toBeTruthy(); + + const restored = await call(router.content.restore, { + format: "markdown", + package: read.package, + }); + const restoredText = new TextDecoder().decode(restored.bytes); + expect(restoredText).toContain("Title"); + // markdown-codec's writer escapes a trailing '.' (ambiguous with an ordered-list marker), so the round-tripped bytes read "Body text\." rather than the original "Body text." -- checked without the punctuation, which the escaping doesn't touch. + expect(restoredText).toContain("Body text"); + }); + + it("reads docx content via the OPC package path", async () => { + const editor = createDocx(); + editor.body.appendParagraph().appendRun({ text: "hello docx" }); + const read = await call(router.content.read, { + format: "docx", + bytes: editor.toBytes(), + }); + expect(read.content.kind).toBe("wordprocessing"); + }); +}); + +describe("metadata.read / metadata.write", () => { + it("writes metadata overrides onto a docx document, then reads them back", async () => { + const editor = createDocx(); + editor.body.appendParagraph().appendRun({ text: "hello docx" }); + const written = await call(router.metadata.write, { + sourceFormat: "docx", + targetFormat: "docx", + bytes: editor.toBytes(), + overrides: { title: "A New Title", author: "Ada" }, + }); + const read = await call(router.metadata.read, { + format: "docx", + bytes: written, + }); + expect(read.title).toBe("A New Title"); + expect(read.author).toBe("Ada"); + }); +}); + +describe("fonts.describe / fonts.extractSourceFonts", () => { + it("extracts the source fonts embedded in a docx document (none, for a document with no embedded font faces)", async () => { + const editor = createDocx(); + editor.body.appendParagraph().appendRun({ text: "hello docx" }); + const fonts = await call(router.fonts.extractSourceFonts, { + format: "docx", + bytes: editor.toBytes(), + }); + expect(fonts).toEqual([]); + }); +}); + +describe("pdf.inspect", () => { + it("inspects a converted PDF's page count and item-kind breakdown", async () => { + const converted = await call(router.convert, { + source: "markdown", + targetFormat: "pdf", + bytes: MARKDOWN_BYTES, + }); + const inspected = await call(router.pdf.inspect, { + bytes: converted.document.bytes, + }); + expect(inspected.pageCount).toBeGreaterThan(0); + expect(inspected.layout.pages.length).toBe(inspected.pageCount); + // The sanitized layout's images map never carries the unbounded base64 payload key. + for (const asset of Object.values(inspected.layout.images)) { + expect(asset).not.toHaveProperty("base64"); + expect(asset.byteLength).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe("editor.* lifecycle", () => { + it("opens a markdown session, edits/adds/removes a paragraph, and saves real markdown bytes", async () => { + const opened = await call(router.editor.open, { + format: "markdown", + bytes: MARKDOWN_BYTES, + }); + expect(opened.paragraphs).toEqual(["Title", "Body text."]); + + const edited = await call(router.editor.setParagraphText, { + id: opened.id, + index: 1, + text: "Edited body.", + }); + expect(edited.paragraphs).toEqual(["Title", "Edited body."]); + + const added = await call(router.editor.addParagraph, { + id: opened.id, + text: "Third paragraph.", + }); + expect(added.paragraphs).toEqual([ + "Title", + "Edited body.", + "Third paragraph.", + ]); + + const removed = await call(router.editor.removeParagraph, { + id: opened.id, + index: 0, + }); + expect(removed.paragraphs).toEqual(["Edited body.", "Third paragraph."]); + + const saved = await call(router.editor.save, { id: opened.id }); + const savedText = new TextDecoder().decode(saved.bytes); + // markdown-codec's writer escapes a trailing '.', so checked without the punctuation (see the identical note on the content.restore round-trip test above). + expect(savedText).toContain("Edited body"); + expect(savedText).toContain("Third paragraph"); + expect(savedText).not.toContain("Title"); + }); + + it("rejects removeParagraph for an index with no paragraph there", async () => { + const opened = await call(router.editor.open, { + format: "markdown", + bytes: MARKDOWN_BYTES, + }); + await expect( + call(router.editor.removeParagraph, { id: opened.id, index: 99 }), + ).rejects.toThrow("no paragraph at index 99"); + }); + + it("rejects any mutation against an id from a session that never existed", async () => { + await expect( + call(router.editor.setParagraphText, { + id: 999_999, + index: 0, + text: "x", + }), + ).rejects.toThrow("no editor session with that id"); + }); +}); From e31adfdb591393defc0132aae9dd7fb4f8295b5f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 13:40:24 +0100 Subject: [PATCH 08/92] test(web): cover DiagnosticsPanel's collapse-threshold branching Adds a Mantine-aware mount harness (mountWithMantine) and a real DiagnosticsPanel test suite, asserting on the Spoiler wrapper's own class marker rather than its "Show N more" label text: jsdom has no layout engine, so Spoiler's internal measured-height-vs-maxHeight comparison can never observe a real overflow and the label never renders regardless of item count. Stubs window.matchMedia and ResizeObserver in the shared jsdom test setup, guarded on `typeof window` since router.procedures.test.ts forces a node environment for the same file. Both APIs are called unconditionally by MantineProvider/Spoiler on mount, so any test that mounts a Mantine component needs them regardless of what it actually exercises. Restates vitest.mutation.config.ts's own setupFiles key, dropped by the same object-literal override that already restates environment: "jsdom", since fake-indexeddb needs to install before dexie.ts's module-scope Dexie construction runs. --- packages/web/src/test/mountComponent.tsx | 37 ++++++++ packages/web/src/test/setup.ts | 62 +++++++++++++ packages/web/src/ui/DiagnosticsPanel.test.tsx | 92 +++++++++++++++++++ packages/web/vitest.mutation.config.ts | 3 +- 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/test/mountComponent.tsx create mode 100644 packages/web/src/ui/DiagnosticsPanel.test.tsx diff --git a/packages/web/src/test/mountComponent.tsx b/packages/web/src/test/mountComponent.tsx new file mode 100644 index 000000000..868269ccb --- /dev/null +++ b/packages/web/src/test/mountComponent.tsx @@ -0,0 +1,37 @@ +/// +import { MantineProvider } from "@mantine/core"; +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +// Any component using a Mantine primitive (Stack, List, Badge, Tree, ...) throws "MantineProvider was not found in component tree" unless one wraps it, even in a plain react-dom/client mount with no other Mantine feature exercised -- this is the shared harness for exactly that case, mirroring src/ui/contentBlocks.test.tsx's own bare react-dom/client pattern (no @testing-library dependency) but with the one extra wrapper Mantine components need. +export interface MountedComponent { + container: HTMLDivElement; + rerender: (node: ReactNode) => void; + unmount: () => void; +} + +export function mountWithMantine(node: ReactNode): MountedComponent { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + + function rerender(next: ReactNode) { + act(() => { + root.render({next}); + }); + } + + rerender(node); + + return { + container, + rerender, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts index 91ceff14b..2a851765a 100644 --- a/packages/web/src/test/setup.ts +++ b/packages/web/src/test/setup.ts @@ -10,3 +10,65 @@ declare global { // React's act() (imported from "react" itself since React 19) only wraps state updates/effects synchronously when it knows it's running inside a test harness -- without this flag it warns "not configured to support act(...)" on every render and the flush act() exists to guarantee is no longer guaranteed. Set once, here, rather than per test file, since every jsdom-environment test that mounts a real component (contentBlocks.test.tsx today, any future one) needs it. globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom ships neither API. MantineProvider's color-scheme hook calls window.matchMedia unconditionally on mount (to detect the OS "prefers-color-scheme: dark" query), and several Mantine components (Spoiler among them) measure their own content box via ResizeObserver -- both run the moment any Mantine component mounts, not only in a test that exercises colour-scheme switching or resizing itself, so every jsdom-environment test that mounts one needs both stubbed up front rather than per test file. Guarded on `typeof window`: this setup file loads for every unit test regardless of environment, and router.procedures.test.ts's own `@vitest-environment node` pragma (see that file's own top-of-file comment for why) means `window` genuinely does not exist while this file runs there. +if (typeof window !== "undefined") { + class StubResizeObserver implements ResizeObserver { + // No-op stubs, not placeholders for missing logic: nothing under test measures a real resize, so there is genuinely nothing for any of the three to do beyond satisfying the interface Mantine's hooks construct against. + observe(): void { + return; + } + unobserve(): void { + return; + } + disconnect(): void { + return; + } + } + globalThis.ResizeObserver = StubResizeObserver; + + type ChangeListener = (event: MediaQueryListEvent) => void; + + const asChangeListener = ( + listener: EventListenerOrEventListenerObject | null, + ): ChangeListener | undefined => { + if (typeof listener === "function") return listener; + return undefined; + }; + + window.matchMedia = (query: string): MediaQueryList => { + const listeners = new Set(); + return { + matches: false, + media: query, + onchange: null, + addEventListener: ( + _type: string, + listener: EventListenerOrEventListenerObject | null, + ) => { + const changeListener = asChangeListener(listener); + if (changeListener !== undefined) listeners.add(changeListener); + }, + removeEventListener: ( + _type: string, + listener: EventListenerOrEventListenerObject | null, + ) => { + const changeListener = asChangeListener(listener); + if (changeListener !== undefined) listeners.delete(changeListener); + }, + // The legacy pre-EventTarget MediaQueryList API: still the type's own required members even though nothing in this package's dependency tree calls them today. + addListener: () => { + return; + }, + removeListener: () => { + return; + }, + dispatchEvent: (event) => { + for (const listener of listeners) { + listener(event as MediaQueryListEvent); + } + return true; + }, + }; + }; +} diff --git a/packages/web/src/ui/DiagnosticsPanel.test.tsx b/packages/web/src/ui/DiagnosticsPanel.test.tsx new file mode 100644 index 000000000..078d59070 --- /dev/null +++ b/packages/web/src/ui/DiagnosticsPanel.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { Diagnostic } from "../shared/diagnostics"; +import { mountWithMantine } from "../test/mountComponent"; +import { DiagnosticsPanel } from "./DiagnosticsPanel"; + +let unmount: (() => void) | undefined; + +afterEach(() => { + unmount?.(); + unmount = undefined; +}); + +function renderPanel(diagnostics: readonly Diagnostic[]): string { + const mounted = mountWithMantine( + , + ); + unmount = mounted.unmount; + return mounted.container.innerHTML; +} + +function diagnostic(overrides: Partial = {}): Diagnostic { + return { severity: "info", code: "x", message: "a message", ...overrides }; +} + +// jsdom renders every element at zero size (it has no layout engine), so Spoiler's own measured-height-vs-maxHeight comparison can never observe a real overflow and its "Show N more" control never appears regardless of item count -- the wrapper element Mantine's Box always renders for a ``, present whether or not the control itself is showing, is the only DOM signal jsdom can give for "this content was wrapped in a Spoiler", so it is what these tests check for instead. +const SPOILER_WRAPPER_CLASS = "mantine-Spoiler-root"; +// MantineProvider injects its own