1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6import { htmlRenderTheme, HTML_RENDER_DEFAULT_FONTS } from "@t3tools/shared/htmlRender";
7import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes";
8import { EnvironmentId, ThreadId } from "@t3tools/contracts";
9
10import { HtmlRenderDialog } from "./HtmlRenderDialog";
11import { HtmlRenderFrame } from "./HtmlRenderFrame";
12
13const { refresh, showError, openLink, openChangeRequestLink } = vi.hoisted(() => ({
14 refresh: vi.fn(),
15 showError: vi.fn(),
16 openLink: vi.fn(),
17 openChangeRequestLink: vi.fn(),
18}));
19vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => openLink }));
20vi.mock("~/lib/openPullRequestLink", () => ({
21 useOpenChangeRequestLink: () => openChangeRequestLink,
22}));
23vi.mock("~/assets/assetUrls", () => ({
24 useAssetUrlState: () => ({
25 _tag: "Success",
26 url: "https://environment.test/original.html",
27 expiresAt: Date.now() + 60 * 60_000,
28 }),
29 useAssetUrlRefresh: () => refresh,
30}));
31vi.mock("../ui/toast", () => ({ toastManager: { add: showError } }));
32
33vi.mock("~/hooks/useHtmlRenderTheme", () => ({
34 useHtmlRenderTheme: () =>
35 htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS),
36}));
37
38let root: Root;
39let container: HTMLDivElement;
40const onClose = vi.fn();
41
42beforeEach(async () => {
43 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
44 vi.stubGlobal(
45 "ResizeObserver",
46 class {
47 observe() {}
48 disconnect() {}
49 unobserve() {}
50 },
51 );
52 refresh.mockReset();
53 openLink.mockReset().mockResolvedValue(undefined);
54 openChangeRequestLink.mockReset().mockReturnValue(false);
55 showError.mockClear();
56 onClose.mockClear();
57 container = document.createElement("div");
58 document.body.append(container);
59 root = createRoot(container);
60 await act(() =>
61 root.render(
62 <HtmlRenderDialog
63 src="https://environment.test/page.html"
64 title="Diagram"
65 onClose={onClose}
66 />,
67 ),
68 );
69});
70
71afterEach(async () => {
72 await act(() => root.unmount());
73 container.remove();
74 vi.unstubAllGlobals();
75});
76
77async function click(label: string) {
78 const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
79 expect(button).not.toBeNull();
80 await act(() => button!.click());
81}
82
83it("zooms the loaded page within bounds and resets without reloading it", async () => {
84 const frame = document.querySelector("iframe")!;
85 const src = frame.src;
86 for (let step = 0; step < 6; step += 1) await click("Zoom in");
87 expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]')!.disabled).toBe(true);
88 expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("2");
89 for (let step = 0; step < 8; step += 1) await click("Zoom out");
90 expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom out"]')!.disabled).toBe(true);
91 expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("0.5");
92 await click("Reset zoom");
93 expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("1");
94 expect(document.querySelector("iframe")).toBe(frame);
95 expect(frame.src).toBe(src);
96 expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
97});
98
99it("closes with the close button or Escape", async () => {
100 await click("Close");
101 expect(onClose).toHaveBeenCalledOnce();
102 onClose.mockClear();
103 await act(() => {
104 document.activeElement?.dispatchEvent(
105 new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
106 );
107 });
108 expect(onClose).toHaveBeenCalledOnce();
109});
110
111async function renderInline() {
112 await act(() =>
113 root.render(
114 <HtmlRenderFrame
115 environmentId={EnvironmentId.make("environment")}
116 htmlRender={{ attachmentId: "html", title: "Diagram", height: 400 }}
117 threadRef={{
118 environmentId: EnvironmentId.make("environment"),
119 threadId: ThreadId.make("thread"),
120 }}
121 />,
122 ),
123 );
124}
125
126it("offers only full screen and opens with a fresh URL while keeping the inline page loaded", async () => {
127 await renderInline();
128 expect(document.querySelector('[aria-label="Open in panel"]')).toBeNull();
129 const inlineFrame = document.querySelector("iframe")!;
130 const originalSrc = inlineFrame.src;
131 const expandButton = document.querySelector<HTMLButtonElement>(
132 '[aria-label="Open full screen"]',
133 )!;
134 expandButton.focus();
135 refresh.mockResolvedValue("https://environment.test/renewed.html");
136 await click("Open full screen");
137 expect(refresh).toHaveBeenCalledOnce();
138 expect(document.querySelector('[role="dialog"] iframe')?.getAttribute("src")).toContain(
139 "https://environment.test/renewed.html#",
140 );
141 expect(inlineFrame.src).toBe(originalSrc);
142 await click("Close");
143 expect(document.querySelector('[role="dialog"]')).toBeNull();
144 expect(document.querySelector("iframe")).toBe(inlineFrame);
145 expect(document.activeElement).toBe(expandButton);
146});
147
148it("routes links from the expanded page through its thread", async () => {
149 await renderInline();
150 refresh.mockResolvedValue("https://environment.test/renewed.html");
151 openChangeRequestLink.mockReturnValue(true);
152 await click("Open full screen");
153 const frame = document.querySelector<HTMLIFrameElement>('[role="dialog"] iframe')!;
154 frame.focus();
155 const url = "https://github.com/pingdotgg/t3code/pull/17561";
156 await act(() => {
157 window.dispatchEvent(
158 new MessageEvent("message", {
159 source: frame.contentWindow,
160 data: { jsonrpc: "2.0", id: "link", method: "ui/open-link", params: { url } },
161 }),
162 );
163 });
164 expect(openChangeRequestLink).toHaveBeenCalledWith(
165 expect.objectContaining({ metaKey: false, ctrlKey: false }),
166 url,
167 undefined,
168 EnvironmentId.make("environment"),
169 );
170 expect(openLink).not.toHaveBeenCalled();
171});
172
173it.each([null, new Error("Disconnected")])(
174 "reports an unavailable URL instead of opening a blank page: %s",
175 async (failure) => {
176 await renderInline();
177 if (failure === null) refresh.mockResolvedValue(null);
178 else refresh.mockRejectedValue(failure);
179 await click("Open full screen");
180 expect(document.querySelector('[role="dialog"]')).toBeNull();
181 expect(showError).toHaveBeenCalledWith(
182 expect.objectContaining({ type: "error", title: "Page unavailable" }),
183 );
184 },
185);