



















@@ -55,16 +55,17 @@ const viewerPayload = JSON.stringify({
5555unsafeCSS: "",
5656},
5757langs: ["text"],
58-oldFile: { fileName: "a.ts", lang: "text", content: "old" },
59-newFile: { fileName: "a.ts", lang: "text", content: "new" },
58+oldFile: { name: "a.ts", lang: "text", contents: "old" },
59+newFile: { name: "a.ts", lang: "text", contents: "new" },
6060});
616162-function renderCard(): void {
62+function renderCard(payloadOverride?: string): void {
63+const payload = payloadOverride ?? viewerPayload;
6364document.body.insertAdjacentHTML(
6465"beforeend",
6566`<section class="oc-diff-card">
6667 <div data-openclaw-diff-host></div>
67- <script type="application/json" data-openclaw-diff-payload>${viewerPayload}</script>
68+ <script type="application/json" data-openclaw-diff-payload>${payload}</script>
6869 </section>`,
6970);
7071}
@@ -172,3 +173,291 @@ describe("hydrateViewer", () => {
172173warn.mockRestore();
173174});
174175});
176+177+describe("viewerState initialization", () => {
178+beforeEach(() => {
179+document.body.innerHTML = "";
180+delete document.documentElement.dataset.openclawDiffsError;
181+delete document.documentElement.dataset.openclawDiffsReady;
182+delete document.body.dataset.theme;
183+vi.clearAllMocks();
184+});
185+186+it("seeds viewerState from firstPayload options and syncs document theme", async () => {
187+const customPayload = JSON.stringify({
188+prerenderedHTML: "<div>diff</div>",
189+options: {
190+theme: { light: "pierre-light", dark: "pierre-dark" },
191+diffStyle: "split",
192+diffIndicators: "bars",
193+disableLineNumbers: false,
194+expandUnchanged: false,
195+themeType: "light",
196+backgroundEnabled: false,
197+overflow: "scroll",
198+unsafeCSS: "",
199+},
200+langs: ["text"],
201+oldFile: { name: "a.ts", lang: "text", contents: "old" },
202+newFile: { name: "a.ts", lang: "text", contents: "new" },
203+});
204+renderCard(customPayload);
205+const { hydrateViewer } = await import("./viewer-client.js");
206+207+await hydrateViewer();
208+209+expect(document.body.dataset.theme).toBe("light");
210+211+const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
212+expect(opts.diffStyle).toBe("split");
213+expect(opts.themeType).toBe("light");
214+expect(opts.overflow).toBe("scroll");
215+expect(opts.disableBackground).toBe(true);
216+});
217+218+it("defaults viewerState to dark/unified/wrap/background when firstPayload uses defaults", async () => {
219+renderCard();
220+const { hydrateViewer } = await import("./viewer-client.js");
221+222+await hydrateViewer();
223+224+expect(document.body.dataset.theme).toBe("dark");
225+226+const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
227+expect(opts.diffStyle).toBe("unified");
228+expect(opts.themeType).toBe("dark");
229+expect(opts.overflow).toBe("wrap");
230+expect(opts.disableBackground).toBe(false);
231+});
232+233+it("preloadHighlighter receives merged language set from all cards", async () => {
234+const payload1 = JSON.stringify({
235+prerenderedHTML: "<div>diff1</div>",
236+options: {
237+theme: { light: "pierre-light", dark: "pierre-dark" },
238+diffStyle: "unified",
239+diffIndicators: "bars",
240+disableLineNumbers: false,
241+expandUnchanged: false,
242+themeType: "dark",
243+backgroundEnabled: true,
244+overflow: "wrap",
245+unsafeCSS: "",
246+},
247+langs: ["typescript"],
248+oldFile: { name: "a.ts", lang: "typescript", contents: "old" },
249+newFile: { name: "a.ts", lang: "typescript", contents: "new" },
250+});
251+const payload2 = JSON.stringify({
252+prerenderedHTML: "<div>diff2</div>",
253+options: {
254+theme: { light: "pierre-light", dark: "pierre-dark" },
255+diffStyle: "unified",
256+diffIndicators: "bars",
257+disableLineNumbers: false,
258+expandUnchanged: false,
259+themeType: "dark",
260+backgroundEnabled: true,
261+overflow: "wrap",
262+unsafeCSS: "",
263+},
264+langs: ["python"],
265+oldFile: { name: "b.py", lang: "python", contents: "old" },
266+newFile: { name: "b.py", lang: "python", contents: "new" },
267+});
268+renderCard(payload1);
269+renderCard(payload2);
270+const { hydrateViewer } = await import("./viewer-client.js");
271+272+await hydrateViewer();
273+274+const preloadArg = (preloadHighlighterMock.mock.calls as unknown[][])[0]?.[0] as { langs: string[]; themes: string[] } | undefined;
275+expect(preloadArg).toBeDefined();
276+expect(preloadArg!.langs).toContain("typescript");
277+expect(preloadArg!.langs).toContain("python");
278+expect(preloadArg!.themes).toEqual(["pierre-light", "pierre-dark"]);
279+});
280+});
281+282+describe("toolbar button toggles", () => {
283+beforeEach(() => {
284+document.body.innerHTML = "";
285+delete document.body.dataset.theme;
286+vi.clearAllMocks();
287+});
288+289+it("layout toggle switches between unified and split", async () => {
290+renderCard();
291+const { hydrateViewer } = await import("./viewer-client.js");
292+await hydrateViewer();
293+294+const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
295+expect(opts1.diffStyle).toBe("unified");
296+297+const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
298+const toolbar = renderHeaderMetadata();
299+const buttons = toolbar.querySelectorAll("button");
300+301+buttons[0].click();
302+303+expect(fileDiffRerenderMock).toHaveBeenCalled();
304+305+const opts2 = fileDiffSetOptionsMock.mock.calls[fileDiffSetOptionsMock.mock.calls.length - 1]?.[0] as Record<string, unknown>;
306+expect(opts2.diffStyle).toBe("split");
307+});
308+309+it("theme toggle switches between dark and light", async () => {
310+renderCard();
311+const { hydrateViewer } = await import("./viewer-client.js");
312+await hydrateViewer();
313+314+const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
315+expect(opts1.themeType).toBe("dark");
316+317+const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
318+const toolbar = renderHeaderMetadata();
319+const buttons = toolbar.querySelectorAll("button");
320+321+buttons[3].click();
322+323+const lastOpts = fileDiffSetOptionsMock.mock.calls[fileDiffSetOptionsMock.mock.calls.length - 1]?.[0] as Record<string, unknown>;
324+expect(lastOpts.themeType).toBe("light");
325+expect(document.body.dataset.theme).toBe("light");
326+});
327+328+it("wrap toggle switches between wrap and scroll", async () => {
329+renderCard();
330+const { hydrateViewer } = await import("./viewer-client.js");
331+await hydrateViewer();
332+333+const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
334+expect(opts1.overflow).toBe("wrap");
335+336+const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
337+const toolbar = renderHeaderMetadata();
338+const buttons = toolbar.querySelectorAll("button");
339+340+buttons[1].click();
341+342+const lastOpts = fileDiffSetOptionsMock.mock.calls[fileDiffSetOptionsMock.mock.calls.length - 1]?.[0] as Record<string, unknown>;
343+expect(lastOpts.overflow).toBe("scroll");
344+});
345+346+it("background toggle inverts disableBackground", async () => {
347+renderCard();
348+const { hydrateViewer } = await import("./viewer-client.js");
349+await hydrateViewer();
350+351+const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
352+expect(opts1.disableBackground).toBe(false);
353+354+const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
355+const toolbar = renderHeaderMetadata();
356+const buttons = toolbar.querySelectorAll("button");
357+358+buttons[2].click();
359+360+const lastOpts = fileDiffSetOptionsMock.mock.calls[fileDiffSetOptionsMock.mock.calls.length - 1]?.[0] as Record<string, unknown>;
361+expect(lastOpts.disableBackground).toBe(true);
362+});
363+});
364+365+describe("ensureShadowRoot", () => {
366+beforeEach(() => {
367+document.body.innerHTML = "";
368+vi.clearAllMocks();
369+});
370+371+it("attaches shadow root from template and removes template element", async () => {
372+renderCard();
373+const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
374+const template = document.createElement("template");
375+template.setAttribute("shadowrootmode", "open");
376+template.innerHTML = "<div>shadow content</div>";
377+host.append(template);
378+379+const { hydrateViewer } = await import("./viewer-client.js");
380+await hydrateViewer();
381+382+expect(host.shadowRoot).toBeDefined();
383+expect(host.shadowRoot!.querySelector("div")?.textContent).toBe("shadow content");
384+expect(host.querySelector("template")).toBeNull();
385+});
386+387+it("skips shadow root attachment when no template is present", async () => {
388+renderCard();
389+const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
390+391+const { hydrateViewer } = await import("./viewer-client.js");
392+await hydrateViewer();
393+394+expect(host.shadowRoot).toBeNull();
395+expect(fileDiffHydrateMock).toHaveBeenCalled();
396+});
397+398+it("skips shadow root when already attached", async () => {
399+renderCard();
400+const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
401+host.attachShadow({ mode: "open" });
402+host.shadowRoot!.innerHTML = "<span>existing</span>";
403+404+const template = document.createElement("template");
405+template.setAttribute("shadowrootmode", "open");
406+template.innerHTML = "<div>new content</div>";
407+host.append(template);
408+409+const { hydrateViewer } = await import("./viewer-client.js");
410+await hydrateViewer();
411+412+expect(host.shadowRoot!.querySelector("span")?.textContent).toBe("existing");
413+expect(host.querySelector("template")).not.toBeNull();
414+});
415+});
416+417+describe("getHydrateProps branching", () => {
418+beforeEach(() => {
419+document.body.innerHTML = "";
420+vi.clearAllMocks();
421+});
422+423+it("passes fileDiff directly when payload has fileDiff", async () => {
424+const fileDiffPayload = JSON.stringify({
425+prerenderedHTML: "<div>diff</div>",
426+options: {
427+theme: { light: "pierre-light", dark: "pierre-dark" },
428+diffStyle: "unified",
429+diffIndicators: "bars",
430+disableLineNumbers: false,
431+expandUnchanged: false,
432+themeType: "dark",
433+backgroundEnabled: true,
434+overflow: "wrap",
435+unsafeCSS: "",
436+},
437+langs: ["text"],
438+fileDiff: { name: "patch.diff", lang: "text", hunks: [] },
439+});
440+renderCard(fileDiffPayload);
441+const { hydrateViewer } = await import("./viewer-client.js");
442+443+await hydrateViewer();
444+445+const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
446+expect(hydrateArg.fileDiff).toEqual({ name: "patch.diff", lang: "text", hunks: [] });
447+expect(hydrateArg.oldFile).toBeUndefined();
448+expect(hydrateArg.newFile).toBeUndefined();
449+});
450+451+it("passes oldFile and newFile when payload has them without fileDiff", async () => {
452+renderCard();
453+const { hydrateViewer } = await import("./viewer-client.js");
454+455+await hydrateViewer();
456+457+const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
458+expect(hydrateArg.fileDiff).toBeUndefined();
459+expect(hydrateArg.oldFile).toEqual({ name: "a.ts", lang: "text", contents: "old" });
460+expect(hydrateArg.newFile).toEqual({ name: "a.ts", lang: "text", contents: "new" });
461+});
462+463+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。