Extract the cleanup confirmation into a reusable production component, align its action group to the right, and place the opt-out directly below the buttons. Add an isolated visual fixture so the exact dialog can be reviewed without touching real queue data.
210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
import type { ReactElement } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { App } from "../../src/renderer/App";
|
|
import { DeleteConfirmationDialog } from "../../src/renderer/views/downloads/DeleteConfirmationDialog";
|
|
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<void>;
|
|
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<VisualScenario, readonly string[]>;
|
|
|
|
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<void> {
|
|
return new Promise((resolve) => {
|
|
requestFrame(() => resolve());
|
|
});
|
|
}
|
|
|
|
export function renderVisualApp(render: (element: ReactElement) => void): void {
|
|
render(<App />);
|
|
}
|
|
|
|
export function renderDeleteConfirmationDialog(render: (element: ReactElement) => void): void {
|
|
render(
|
|
<DeleteConfirmationDialog
|
|
dontAsk
|
|
parts={["2 Paket(e)"]}
|
|
totalRemaining={0}
|
|
onCancel={() => {}}
|
|
onConfirm={() => {}}
|
|
onDontAskChange={() => {}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export async function markVisualReady(
|
|
scenario: VisualScenario,
|
|
rootElement: VisualHarnessRoot,
|
|
options: VisualReadyOptions
|
|
): Promise<void> {
|
|
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<void> {
|
|
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);
|
|
if (new URLSearchParams(runtime.search).get("dialog") === "delete-confirmation") {
|
|
renderDeleteConfirmationDialog((element) => root.render(element));
|
|
await waitForVisualFrames(runtime.requestFrame);
|
|
runtime.marker.visualReady = "true";
|
|
return;
|
|
}
|
|
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();
|
|
}
|