| 1 | import { JSDOM } from "jsdom"; |
| 2 | import React from "react"; |
| 3 | import { act } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import { BrowserControlSettingsPage } from "../components/BrowserControlSettingsPage"; |
| 6 | import { LocaleProvider } from "../lib/i18n"; |
| 7 | import { installDesktopHostStub, type DesktopHostStubOptions } from "./desktopHostStub"; |
| 8 | |
| 9 | function ok(value: unknown, message: string) { |
| 10 | if (!value) throw new Error(message); |
| 11 | } |
| 12 | |
| 13 | function flush(): Promise<void> { |
| 14 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 15 | } |
| 16 | |
| 17 | async function waitFor(label: string, predicate: () => boolean) { |
| 18 | for (let attempt = 0; attempt < 40; attempt += 1) { |
| 19 | await act(async () => { |
| 20 | await flush(); |
| 21 | }); |
| 22 | if (predicate()) return; |
| 23 | } |
| 24 | throw new Error(`timed out waiting for ${label}: ${document.body?.textContent?.slice(0, 400) ?? ""}`); |
| 25 | } |
| 26 | |
| 27 | function installDom() { |
| 28 | const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { |
| 29 | pretendToBeVisual: true, |
| 30 | url: "http://localhost/", |
| 31 | }); |
| 32 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 33 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 34 | globalThis.document = dom.window.document; |
| 35 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 36 | globalThis.Node = dom.window.Node; |
| 37 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 38 | globalThis.HTMLButtonElement = dom.window.HTMLButtonElement; |
| 39 | globalThis.HTMLInputElement = dom.window.HTMLInputElement; |
| 40 | globalThis.Event = dom.window.Event; |
| 41 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 42 | globalThis.localStorage = dom.window.localStorage; |
| 43 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 44 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 45 | return dom; |
| 46 | } |
| 47 | |
| 48 | async function renderPage(options: DesktopHostStubOptions = {}) { |
| 49 | const stub = installDesktopHostStub({}, options); |
| 50 | const rootEl = document.getElementById("root"); |
| 51 | if (!rootEl) throw new Error("missing root"); |
| 52 | const root = createRoot(rootEl); |
| 53 | await act(async () => { |
| 54 | root.render(React.createElement(LocaleProvider, null, React.createElement(BrowserControlSettingsPage))); |
| 55 | await flush(); |
| 56 | }); |
| 57 | return { stub, root, rootEl }; |
| 58 | } |
| 59 | |
| 60 | function switchFor(rootEl: HTMLElement, label: string): HTMLInputElement { |
| 61 | const found = Array.from(rootEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).find( |
| 62 | (input) => input.getAttribute("aria-label") === label, |
| 63 | ); |
| 64 | ok(found, `missing switch for ${label}`); |
| 65 | return found as HTMLInputElement; |
| 66 | } |
| 67 | |
| 68 | function buttonFor(rootEl: HTMLElement, text: string): HTMLButtonElement { |
| 69 | const found = Array.from(rootEl.querySelectorAll("button")).find((button) => (button.textContent || "").includes(text)); |
| 70 | ok(found, `missing button ${text}: ${rootEl.textContent ?? ""}`); |
| 71 | return found as HTMLButtonElement; |
| 72 | } |
| 73 | |
| 74 | async function click(target: HTMLElement) { |
| 75 | await act(async () => { |
| 76 | target.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 77 | await flush(); |
| 78 | }); |
| 79 | } |
| 80 | |
| 81 | console.log("browser control settings page"); |
| 82 | |
| 83 | { |
| 84 | const calls: string[] = []; |
| 85 | installDom(); |
| 86 | // English labels keep the button-text assertions stable. |
| 87 | window.localStorage.setItem("reasonix-lang", "en"); |
| 88 | const { root, rootEl } = await renderPage({ browserControlCalls: calls }); |
| 89 | await waitFor("controls", () => rootEl.querySelectorAll('input[type="checkbox"]').length === 2); |
| 90 | |
| 91 | ok((rootEl.textContent || "").includes("Enable built-in browser control"), "control row must render"); |
| 92 | ok((rootEl.textContent || "").includes("Clear all browser data"), "data rows must render"); |
| 93 | |
| 94 | const control = switchFor(rootEl, "Enable built-in browser control"); |
| 95 | ok(control.checked, "control starts enabled"); |
| 96 | await click(control); |
| 97 | await waitFor("control toggle", () => calls.includes("setEnabled:false")); |
| 98 | ok((rootEl.textContent || "").includes("Built-in browser control disabled"), "toggle must confirm with a notice"); |
| 99 | ok(!switchFor(rootEl, "Enable built-in browser control").checked, "switch must reflect the new state"); |
| 100 | |
| 101 | const certificates = switchFor(rootEl, "Ignore certificate errors"); |
| 102 | ok(!certificates.checked, "certificate verification starts strict"); |
| 103 | await click(certificates); |
| 104 | await waitFor("certificate toggle", () => calls.includes("setIgnoreCertificateErrors:true")); |
| 105 | |
| 106 | await click(buttonFor(rootEl, "Clear cache")); |
| 107 | await waitFor("cache clear", () => calls.includes("clearCache")); |
| 108 | ok((rootEl.textContent || "").includes("Built-in browser cache cleared"), "cache clear must confirm"); |
| 109 | |
| 110 | await click(buttonFor(rootEl, "Import browser data")); |
| 111 | await waitFor("import", () => calls.includes("importChromeLogin")); |
| 112 | ok((rootEl.textContent || "").includes("Imported 12 cookies from Default"), "import must report what it copied"); |
| 113 | |
| 114 | // Clearing everything is destructive, so the first click only arms the confirm. |
| 115 | await click(buttonFor(rootEl, "Clear all")); |
| 116 | ok(!calls.includes("clearAllData"), "first click must not clear anything"); |
| 117 | await click(buttonFor(rootEl, "Confirm clear")); |
| 118 | await waitFor("clear all", () => calls.includes("clearAllData")); |
| 119 | ok((rootEl.textContent || "").includes("Built-in browser data cleared"), "clear all must confirm"); |
| 120 | |
| 121 | await act(async () => { |
| 122 | root.unmount(); |
| 123 | }); |
| 124 | ok(calls.includes("setEnabled:false"), `recorded calls: ${calls.join(",")}`); |
| 125 | } |
| 126 | |
| 127 | { |
| 128 | installDom(); |
| 129 | window.localStorage.setItem("reasonix-lang", "en"); |
| 130 | const { root, rootEl } = await renderPage({ chromeImportOutcome: { ok: false, reason: "safe-storage-denied" } }); |
| 131 | await waitFor("import button", () => rootEl.querySelectorAll("button").length > 0); |
| 132 | await click(buttonFor(rootEl, "Import browser data")); |
| 133 | await waitFor("import error", () => (rootEl.textContent || "").includes("Chrome Safe Storage")); |
| 134 | ok(rootEl.querySelector('[role="alert"]'), "a denied keychain prompt must surface as an alert"); |
| 135 | await act(async () => { |
| 136 | root.unmount(); |
| 137 | }); |
| 138 | } |
| 139 | |
| 140 | { |
| 141 | installDom(); |
| 142 | window.localStorage.setItem("reasonix-lang", "en"); |
| 143 | const rootEl = document.getElementById("root"); |
| 144 | if (!rootEl) throw new Error("missing root"); |
| 145 | const root = createRoot(rootEl); |
| 146 | await act(async () => { |
| 147 | root.render(React.createElement(LocaleProvider, null, React.createElement(BrowserControlSettingsPage))); |
| 148 | await flush(); |
| 149 | }); |
| 150 | // No Electron preload is installed here, which is the browser/Serve shell. |
| 151 | await waitFor("desktop-only notice", () => (rootEl.textContent || "").includes("desktop app")); |
| 152 | ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 0, "no browser control must render without the shell"); |
| 153 | await act(async () => { |
| 154 | root.unmount(); |
| 155 | }); |
| 156 | } |
| 157 |