| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import { act } from "react"; |
| 4 | import { createRoot, type Root } from "react-dom/client"; |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 6 | import { htmlRenderTheme, HTML_RENDER_DEFAULT_FONTS } from "@t3tools/shared/htmlRender"; |
| 7 | import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; |
| 8 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 9 | |
| 10 | import { HtmlRenderDialog } from "./HtmlRenderDialog"; |
| 11 | import { HtmlRenderFrame } from "./HtmlRenderFrame"; |
| 12 | |
| 13 | const { refresh, showError, openLink, openChangeRequestLink } = vi.hoisted(() => ({ |
| 14 | refresh: vi.fn(), |
| 15 | showError: vi.fn(), |
| 16 | openLink: vi.fn(), |
| 17 | openChangeRequestLink: vi.fn(), |
| 18 | })); |
| 19 | vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => openLink })); |
| 20 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 21 | useOpenChangeRequestLink: () => openChangeRequestLink, |
| 22 | })); |
| 23 | vi.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 | })); |
| 31 | vi.mock("../ui/toast", () => ({ toastManager: { add: showError } })); |
| 32 | |
| 33 | vi.mock("~/hooks/useHtmlRenderTheme", () => ({ |
| 34 | useHtmlRenderTheme: () => |
| 35 | htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS), |
| 36 | })); |
| 37 | |
| 38 | let root: Root; |
| 39 | let container: HTMLDivElement; |
| 40 | const onClose = vi.fn(); |
| 41 | |
| 42 | beforeEach(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 | |
| 71 | afterEach(async () => { |
| 72 | await act(() => root.unmount()); |
| 73 | container.remove(); |
| 74 | vi.unstubAllGlobals(); |
| 75 | }); |
| 76 | |
| 77 | async 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 | |
| 83 | it("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 | |
| 99 | it("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 | |
| 111 | async 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 | |
| 126 | it("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 | |
| 148 | it("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 | |
| 173 | it.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 | ); |