import type { ReactElement } from "react"; import { createRoot } from "react-dom/client"; import { App } from "../../src/renderer/App"; import type { ElectronApi } from "../../src/shared/preload-api"; import "../../src/renderer/theme.css"; import "../../src/renderer/styles.css"; import { createVisualFixture, installVisualClock, waitForVisualFrames, type VisualScenario } from "./fixtures"; import { createVisualElectronApi } from "./mock-electron-api"; import { loadVisualCapture, prepareVisualCapture } from "./ui-driver"; interface VisualHarnessRoot { readonly innerText: string; textContent: string | null; readonly dataset: { visualError?: string; }; } interface VisualReadyMarker { visualReady?: string; visualScenario?: string; } interface VisualReadyOptions { marker: VisualReadyMarker; loadVisualState: () => Promise; requestFrame: (callback: FrameRequestCallback) => number; maxFrames?: number; } interface VisualRenderRoot { render: (element: ReactElement) => void; } export interface VisualHarnessRuntime { readonly search: string; readonly rootElement: VisualHarnessRoot | null; readonly marker: VisualReadyMarker; readonly requestFrame: (callback: FrameRequestCallback) => number; readonly maxFrames?: number; installClock: () => void; setElectronApi: (api: ElectronApi) => void; createRoot: (rootElement: VisualHarnessRoot) => VisualRenderRoot; } const visibleScenarioContent = { empty: ["Noch keine Downloads"], dense: ["Dokumentation Staffel 1", "Konzertmitschnitt 2026"], update: ["v9.9.9"] } satisfies Record; function readScenario(search: string): VisualScenario { const scenario = new URLSearchParams(search).get("scenario"); return scenario === "empty" || scenario === "update" ? scenario : "dense"; } function missingVisibleContent(scenario: VisualScenario, rootElement: VisualHarnessRoot): string[] { return visibleScenarioContent[scenario].filter((expected) => !rootElement.innerText.includes(expected)); } function waitForVisualFrame( requestFrame: (callback: FrameRequestCallback) => number ): Promise { return new Promise((resolve) => { requestFrame(() => resolve()); }); } export function renderVisualApp(render: (element: ReactElement) => void): void { render(); } export async function markVisualReady( scenario: VisualScenario, rootElement: VisualHarnessRoot, options: VisualReadyOptions ): Promise { delete options.marker.visualReady; delete rootElement.dataset.visualError; await options.loadVisualState(); await waitForVisualFrames(options.requestFrame); const maxFrames = options.maxFrames ?? 180; for (let frame = 0; frame <= maxFrames; frame += 1) { const missing = missingVisibleContent(scenario, rootElement); if (missing.length === 0) { options.marker.visualReady = "true"; return; } if (frame < maxFrames) { await waitForVisualFrame(options.requestFrame); } } const missing = missingVisibleContent(scenario, rootElement); throw new Error( `Visual-Harness-Szenario "${scenario}" ist nicht bereit: ${missing.map((value) => `${value} fehlt`).join(", ")}` ); } export function showVisualHarnessError( rootElement: VisualHarnessRoot, marker: VisualReadyMarker, error: unknown ): void { delete marker.visualReady; rootElement.dataset.visualError = "true"; rootElement.textContent = `Visual-Harness-Fehler: ${error instanceof Error ? error.message : String(error)}`; } function createBrowserVisualHarnessRuntime(): VisualHarnessRuntime { const rootElement = document.getElementById("root"); return { search: window.location.search, rootElement, marker: document.documentElement.dataset, requestFrame: window.requestAnimationFrame.bind(window), installClock(): void { installVisualClock(window); }, setElectronApi(api: ElectronApi): void { window.rd = api; }, createRoot(element: VisualHarnessRoot): VisualRenderRoot { if (rootElement === null || element !== rootElement) { throw new Error("Root element fehlt"); } return createRoot(rootElement); } }; } export async function startVisualHarness( runtime: VisualHarnessRuntime = createBrowserVisualHarnessRuntime() ): Promise { const rootElement = runtime.rootElement; if (!rootElement) { throw new Error("Root element fehlt"); } try { const captureName = new URLSearchParams(runtime.search).get("capture"); const capture = captureName ? await loadVisualCapture(captureName) : undefined; const scenario = capture?.scenario ?? readScenario(runtime.search); runtime.installClock(); const fixture = createVisualFixture(scenario); const api = createVisualElectronApi(fixture); runtime.setElectronApi(api); runtime.marker.visualScenario = scenario; const root = runtime.createRoot(rootElement); renderVisualApp((element) => root.render(element)); const readyOptions = { loadVisualState: async () => { await Promise.all([api.getSnapshot(), api.getHistory()]); }, requestFrame: runtime.requestFrame, maxFrames: runtime.maxFrames }; if (!capture) { await markVisualReady(scenario, rootElement, { marker: runtime.marker, ...readyOptions }); return; } delete runtime.marker.visualReady; await markVisualReady(scenario, rootElement, { marker: {}, ...readyOptions }); await prepareVisualCapture(capture, document); runtime.marker.visualReady = "true"; } catch (error) { showVisualHarnessError(rootElement, runtime.marker, error); } } if (typeof window !== "undefined" && typeof document !== "undefined") { startVisualHarness(); }