/* @vitest-environment jsdom */ import { readFileSync } from "node:fs"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const disableAutoStartKey = Symbol.for("openclaw.diffs.disableAutoStart"); (globalThis as typeof globalThis & Record)[disableAutoStartKey] = true; const VIEWER_CLIENT_SRC = readFileSync( path.join(process.cwd(), "extensions/diffs/src/viewer-client.ts"), "utf8", ); const XSS_PATTERNS = ["onerror", " ({ fileDiffHydrateMock: vi.fn(), fileDiffRerenderMock: vi.fn(), fileDiffSetOptionsMock: vi.fn(), preloadHighlighterMock: vi.fn(async () => undefined), })); vi.mock("@pierre/diffs", () => ({ FileDiff: class { hydrate(params: unknown) { return fileDiffHydrateMock(params); } rerender() { return fileDiffRerenderMock(); } setOptions(params: unknown) { return fileDiffSetOptionsMock(params); } }, preloadHighlighter: preloadHighlighterMock, })); const viewerPayload = JSON.stringify({ prerenderedHTML: "
diff
", options: { theme: { light: "pierre-light", dark: "pierre-dark" }, diffStyle: "unified", diffIndicators: "bars", disableLineNumbers: false, expandUnchanged: false, themeType: "dark", backgroundEnabled: true, overflow: "wrap", unsafeCSS: "", }, langs: ["text"], oldFile: { name: "a.ts", lang: "text", contents: "old" }, newFile: { name: "a.ts", lang: "text", contents: "new" }, }); function renderCard(payloadOverride?: string): void { const payload = payloadOverride ?? viewerPayload; document.body.insertAdjacentHTML( "beforeend", `
`, ); } describe("createToolbarButton icon safety", () => { it("toolbarIconSvg map exists and has exactly 8 icon names", () => { const requiredNames = [ "split", "unified", "wrap-on", "wrap-off", "background-on", "background-off", "theme-dark", "theme-light", ] as const; for (const name of requiredNames) { expect( VIEWER_CLIENT_SRC.includes(name + ":") || VIEWER_CLIENT_SRC.includes(`"${name}"`), `icon "${name}" should exist in toolbarIconSvg`, ).toBe(true); } }); it("no iconMarkup: string parameter exists", () => { expect(VIEWER_CLIENT_SRC.includes("iconMarkup: string")).toBe(false); }); it("innerHTML reads only from toolbarIconSvg lookup", () => { expect(VIEWER_CLIENT_SRC.includes("button.innerHTML = toolbarIconSvg[params.icon]")).toBe(true); }); it("SVG strings in toolbarIconSvg contain no XSS patterns", () => { for (const pattern of XSS_PATTERNS) { expect(VIEWER_CLIENT_SRC.includes(pattern), `source must not contain "${pattern}"`).toBe( false, ); } }); it("old icon functions are removed", () => { const removedFunctions = [ "function splitIcon(", "function unifiedIcon(", "function wrapIcon(", "function backgroundIcon(", "function themeIcon(", ]; for (const fn of removedFunctions) { expect(VIEWER_CLIENT_SRC.includes(fn), `"${fn}" should be removed`).toBe(false); } }); }); describe("hydrateViewer", () => { beforeEach(() => { document.body.innerHTML = ""; delete document.documentElement.dataset.openclawDiffsError; delete document.documentElement.dataset.openclawDiffsReady; vi.clearAllMocks(); }); it("continues hydrating later cards when one card throws", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); renderCard(); renderCard(); fileDiffHydrateMock.mockImplementationOnce(() => { throw new Error("broken card"); }); const { controllers, hydrateViewer } = await import("./viewer-client.js"); controllers.splice(0); await hydrateViewer(); expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2); expect(controllers).toHaveLength(1); expect(warn).toHaveBeenCalledWith( "Skipping diff card that failed to hydrate", expect.any(Error), ); expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined(); warn.mockRestore(); }); it("does not retain controllers when initial state application throws", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); renderCard(); renderCard(); fileDiffSetOptionsMock.mockImplementationOnce(() => { throw new Error("broken options"); }); const { controllers, hydrateViewer } = await import("./viewer-client.js"); controllers.splice(0); await hydrateViewer(); expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2); expect(fileDiffSetOptionsMock).toHaveBeenCalledTimes(2); expect(controllers).toHaveLength(1); expect(warn).toHaveBeenCalledWith( "Skipping diff card that failed to hydrate", expect.any(Error), ); expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined(); warn.mockRestore(); }); it("replaces stale controllers when hydrating the current cards again", async () => { renderCard(); const { controllers, hydrateViewer } = await import("./viewer-client.js"); controllers.splice(0); await hydrateViewer(); expect(controllers).toHaveLength(1); const firstController = controllers[0]; document.body.innerHTML = ""; renderCard(); await hydrateViewer(); expect(controllers).toHaveLength(1); expect(controllers[0]).not.toBe(firstController); expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2); }); }); describe("viewerState initialization", () => { beforeEach(() => { document.body.innerHTML = ""; delete document.documentElement.dataset.openclawDiffsError; delete document.documentElement.dataset.openclawDiffsReady; delete document.body.dataset.theme; vi.clearAllMocks(); }); it("seeds viewerState from firstPayload options and syncs document theme", async () => { const customPayload = JSON.stringify({ prerenderedHTML: "
diff
", options: { theme: { light: "pierre-light", dark: "pierre-dark" }, diffStyle: "split", diffIndicators: "bars", disableLineNumbers: false, expandUnchanged: false, themeType: "light", backgroundEnabled: false, overflow: "scroll", unsafeCSS: "", }, langs: ["text"], oldFile: { name: "a.ts", lang: "text", contents: "old" }, newFile: { name: "a.ts", lang: "text", contents: "new" }, }); renderCard(customPayload); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); expect(document.body.dataset.theme).toBe("light"); const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record; expect(opts.diffStyle).toBe("split"); expect(opts.themeType).toBe("light"); expect(opts.overflow).toBe("scroll"); expect(opts.disableBackground).toBe(true); }); it("defaults viewerState to dark/unified/wrap/background when firstPayload uses defaults", async () => { renderCard(); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); expect(document.body.dataset.theme).toBe("dark"); const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record; expect(opts.diffStyle).toBe("unified"); expect(opts.themeType).toBe("dark"); expect(opts.overflow).toBe("wrap"); expect(opts.disableBackground).toBe(false); }); it("preloadHighlighter receives merged language set from all cards", async () => { const payload1 = JSON.stringify({ prerenderedHTML: "
diff1
", options: { theme: { light: "pierre-light", dark: "pierre-dark" }, diffStyle: "unified", diffIndicators: "bars", disableLineNumbers: false, expandUnchanged: false, themeType: "dark", backgroundEnabled: true, overflow: "wrap", unsafeCSS: "", }, langs: ["typescript"], oldFile: { name: "a.ts", lang: "typescript", contents: "old" }, newFile: { name: "a.ts", lang: "typescript", contents: "new" }, }); const payload2 = JSON.stringify({ prerenderedHTML: "
diff2
", options: { theme: { light: "pierre-light", dark: "pierre-dark" }, diffStyle: "unified", diffIndicators: "bars", disableLineNumbers: false, expandUnchanged: false, themeType: "dark", backgroundEnabled: true, overflow: "wrap", unsafeCSS: "", }, langs: ["python"], oldFile: { name: "b.py", lang: "python", contents: "old" }, newFile: { name: "b.py", lang: "python", contents: "new" }, }); renderCard(payload1); renderCard(payload2); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); const preloadArg = (preloadHighlighterMock.mock.calls as unknown[][])[0]?.[0] as | { langs: string[]; themes: string[] } | undefined; expect(preloadArg).toBeDefined(); expect(preloadArg!.langs).toContain("typescript"); expect(preloadArg!.langs).toContain("python"); expect(preloadArg!.themes).toEqual(["pierre-light", "pierre-dark"]); }); }); describe("toolbar button toggles", () => { beforeEach(() => { document.body.innerHTML = ""; delete document.body.dataset.theme; vi.clearAllMocks(); }); it("layout toggle switches between unified and split", async () => { renderCard(); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record; expect(opts1.diffStyle).toBe("unified"); const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement; const toolbar = renderHeaderMetadata(); const buttons = toolbar.querySelectorAll("button"); buttons[0].click(); expect(fileDiffRerenderMock).toHaveBeenCalled(); const opts2 = fileDiffSetOptionsMock.mock.calls[ fileDiffSetOptionsMock.mock.calls.length - 1 ]?.[0] as Record; expect(opts2.diffStyle).toBe("split"); }); it("theme toggle switches between dark and light", async () => { renderCard(); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record; expect(opts1.themeType).toBe("dark"); const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement; const toolbar = renderHeaderMetadata(); const buttons = toolbar.querySelectorAll("button"); buttons[3].click(); const lastOpts = fileDiffSetOptionsMock.mock.calls[ fileDiffSetOptionsMock.mock.calls.length - 1 ]?.[0] as Record; expect(lastOpts.themeType).toBe("light"); expect(document.body.dataset.theme).toBe("light"); }); it("wrap toggle switches between wrap and scroll", async () => { renderCard(); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record; expect(opts1.overflow).toBe("wrap"); const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement; const toolbar = renderHeaderMetadata(); const buttons = toolbar.querySelectorAll("button"); buttons[1].click(); const lastOpts = fileDiffSetOptionsMock.mock.calls[ fileDiffSetOptionsMock.mock.calls.length - 1 ]?.[0] as Record; expect(lastOpts.overflow).toBe("scroll"); }); it("background toggle inverts disableBackground", async () => { renderCard(); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record; expect(opts1.disableBackground).toBe(false); const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement; const toolbar = renderHeaderMetadata(); const buttons = toolbar.querySelectorAll("button"); buttons[2].click(); const lastOpts = fileDiffSetOptionsMock.mock.calls[ fileDiffSetOptionsMock.mock.calls.length - 1 ]?.[0] as Record; expect(lastOpts.disableBackground).toBe(true); }); }); describe("ensureShadowRoot", () => { beforeEach(() => { document.body.innerHTML = ""; vi.clearAllMocks(); }); it("attaches shadow root from template and removes template element", async () => { renderCard(); const host = document.querySelector("[data-openclaw-diff-host]")!; const template = document.createElement("template"); template.setAttribute("shadowrootmode", "open"); template.innerHTML = "
shadow content
"; host.append(template); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); expect(host.shadowRoot).toBeDefined(); expect(host.shadowRoot!.querySelector("div")?.textContent).toBe("shadow content"); expect(host.querySelector("template")).toBeNull(); }); it("skips shadow root attachment when no template is present", async () => { renderCard(); const host = document.querySelector("[data-openclaw-diff-host]")!; const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); expect(host.shadowRoot).toBeNull(); expect(fileDiffHydrateMock).toHaveBeenCalled(); }); it("skips shadow root when already attached", async () => { renderCard(); const host = document.querySelector("[data-openclaw-diff-host]")!; host.attachShadow({ mode: "open" }); host.shadowRoot!.innerHTML = "existing"; const template = document.createElement("template"); template.setAttribute("shadowrootmode", "open"); template.innerHTML = "
new content
"; host.append(template); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); expect(host.shadowRoot!.querySelector("span")?.textContent).toBe("existing"); expect(host.querySelector("template")).not.toBeNull(); }); }); describe("getHydrateProps branching", () => { beforeEach(() => { document.body.innerHTML = ""; vi.clearAllMocks(); }); it("passes fileDiff directly when payload has fileDiff", async () => { const fileDiffPayload = JSON.stringify({ prerenderedHTML: "
diff
", options: { theme: { light: "pierre-light", dark: "pierre-dark" }, diffStyle: "unified", diffIndicators: "bars", disableLineNumbers: false, expandUnchanged: false, themeType: "dark", backgroundEnabled: true, overflow: "wrap", unsafeCSS: "", }, langs: ["text"], fileDiff: { name: "patch.diff", lang: "text", hunks: [] }, }); renderCard(fileDiffPayload); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record; expect(hydrateArg.fileDiff).toEqual({ name: "patch.diff", lang: "text", hunks: [] }); expect(hydrateArg.oldFile).toBeUndefined(); expect(hydrateArg.newFile).toBeUndefined(); }); it("passes oldFile and newFile when payload has them without fileDiff", async () => { renderCard(); const { hydrateViewer } = await import("./viewer-client.js"); await hydrateViewer(); const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record; expect(hydrateArg.fileDiff).toBeUndefined(); expect(hydrateArg.oldFile).toEqual({ name: "a.ts", lang: "text", contents: "old" }); expect(hydrateArg.newFile).toEqual({ name: "a.ts", lang: "text", contents: "new" }); }); });