feat: deliver the redesigned desktop workspace
Rebuild downloads, link collection, settings, history, and statistics around a responsive desktop shell with compact account and queue tables, contextual navigation, persistent update affordances, unified overlays, and accessible keyboard interactions. Add safe history-folder reveal IPC, responsive 2560/1920/1366/1120 coverage, deterministic visual fixtures, focused component regressions, and release-tree exclusions for internal working files. Bump the public application version to 2.0.13.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
[
|
||||
{
|
||||
"name": "app-navigation-current",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "history",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "history" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "downloads-dense",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "downloads",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "downloads" },
|
||||
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "downloads-sidebar-status" },
|
||||
{ "type": "visible", "region": "downloads-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "collector-dense",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "collector",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
|
||||
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
|
||||
{ "type": "click", "role": "button", "name": "Übernehmen" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "collector" },
|
||||
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
|
||||
{ "type": "absent", "region": "collector-empty-state" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "settings-dense",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "settings",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Accounts" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "settings" },
|
||||
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "settings-sidebar" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "history-dense",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "history",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "history" },
|
||||
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "history-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "statistics-dense",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "statistics",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "statistics" },
|
||||
{ "type": "nonempty", "region": "statistics-kpis" },
|
||||
{ "type": "visible", "region": "statistics-chart" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "avatar-menu",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "settings",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Kontomenü" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "settings" },
|
||||
{ "type": "visible", "role": "menu", "name": "Kontomenü" },
|
||||
{ "type": "nonempty", "role": "menu", "name": "Kontomenü" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "avatar-update-tooltip",
|
||||
"scenario": "update",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "settings",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Später" },
|
||||
{ "type": "wait-absent", "role": "dialog", "name": "Update installieren" },
|
||||
{ "type": "click", "role": "button", "name": "Kontomenü" },
|
||||
{ "type": "hover", "role": "button", "name": "Update verfügbar" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "absent", "role": "dialog", "name": "Update installieren" },
|
||||
{ "type": "visible", "role": "button", "name": "Update verfügbar" },
|
||||
{ "type": "visible", "role": "menu", "name": "Kontomenü" },
|
||||
{ "type": "visible", "role": "tooltip", "name": "Update verfügbar" },
|
||||
{ "type": "layer-above", "role": "tooltip", "name": "Update verfügbar", "referenceRole": "menu", "referenceName": "Kontomenü" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "context-downloads",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "downloads",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "downloads" },
|
||||
{ "type": "visible", "region": "downloads-sidebar" },
|
||||
{ "type": "visible", "region": "downloads-toolbar" },
|
||||
{ "type": "visible", "region": "downloads-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "context-collector",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "collector",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "collector" },
|
||||
{ "type": "visible", "region": "collector-sidebar" },
|
||||
{ "type": "visible", "region": "collector-toolbar" },
|
||||
{ "type": "absent", "region": "downloads-toolbar" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "context-settings",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "settings",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "settings" },
|
||||
{ "type": "visible", "region": "settings-sidebar" },
|
||||
{ "type": "absent", "region": "table-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "context-history",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "history",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "history" },
|
||||
{ "type": "visible", "region": "history-sidebar" },
|
||||
{ "type": "visible", "region": "history-toolbar" },
|
||||
{ "type": "visible", "region": "history-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "context-statistics",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "statistics",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "statistics" },
|
||||
{ "type": "visible", "region": "statistics-sidebar" },
|
||||
{ "type": "absent", "region": "downloads-toolbar" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "info-closed",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "downloads",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "visible", "role": "button", "name": "Informationen" },
|
||||
{ "type": "absent", "role": "region", "name": "Informationen zu Downloads" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "info-open",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "downloads",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Informationen" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "visible", "role": "region", "name": "Informationen zu Downloads" },
|
||||
{ "type": "nonempty", "role": "region", "name": "Informationen zu Downloads" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "info-absent",
|
||||
"scenario": "empty",
|
||||
"viewport": { "width": 2560, "height": 1369 },
|
||||
"activeView": "settings",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "settings" },
|
||||
{ "type": "absent", "role": "button", "name": "Informationen" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-downloads-1920",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"activeView": "downloads",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "downloads" },
|
||||
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "downloads-sidebar-status" },
|
||||
{ "type": "visible", "region": "downloads-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-collector-1366",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1366, "height": 768 },
|
||||
"activeView": "collector",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
|
||||
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
|
||||
{ "type": "click", "role": "button", "name": "Übernehmen" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "collector" },
|
||||
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
|
||||
{ "type": "absent", "region": "collector-empty-state" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-settings-1120",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1120, "height": 760 },
|
||||
"activeView": "settings",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Seitenleiste ausklappen" },
|
||||
{ "type": "click", "role": "button", "name": "Accounts" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "settings" },
|
||||
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "settings-sidebar" },
|
||||
{ "type": "absent", "region": "table-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-downloads-1366",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1366, "height": 768 },
|
||||
"activeView": "downloads",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "downloads" },
|
||||
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "downloads-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-downloads-1120",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1120, "height": 760 },
|
||||
"activeView": "downloads",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "downloads" },
|
||||
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "downloads-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-collector-1920",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"activeView": "collector",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
|
||||
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
|
||||
{ "type": "click", "role": "button", "name": "Übernehmen" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "collector" },
|
||||
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
|
||||
{ "type": "absent", "region": "collector-empty-state" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-collector-1120",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1120, "height": 760 },
|
||||
"activeView": "collector",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
|
||||
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
|
||||
{ "type": "click", "role": "button", "name": "Übernehmen" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "collector" },
|
||||
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
|
||||
{ "type": "absent", "region": "collector-empty-state" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-settings-1920",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"activeView": "settings",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Accounts" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "settings" },
|
||||
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "settings-sidebar" },
|
||||
{ "type": "absent", "region": "table-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-settings-1366",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1366, "height": 768 },
|
||||
"activeView": "settings",
|
||||
"interactions": [
|
||||
{ "type": "click", "role": "button", "name": "Seitenleiste ausklappen" },
|
||||
{ "type": "click", "role": "button", "name": "Accounts" }
|
||||
],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "settings" },
|
||||
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "settings-sidebar" },
|
||||
{ "type": "absent", "region": "table-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-history-1920",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"activeView": "history",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "history" },
|
||||
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "history-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-history-1366",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1366, "height": 768 },
|
||||
"activeView": "history",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "history" },
|
||||
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "history-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-history-1120",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1120, "height": 760 },
|
||||
"activeView": "history",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "history" },
|
||||
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
|
||||
{ "type": "visible", "region": "history-pagination" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-statistics-1920",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"activeView": "statistics",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "statistics" },
|
||||
{ "type": "nonempty", "region": "statistics-kpis" },
|
||||
{ "type": "visible", "region": "statistics-chart" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-statistics-1366",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1366, "height": 768 },
|
||||
"activeView": "statistics",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "statistics" },
|
||||
{ "type": "nonempty", "region": "statistics-kpis" },
|
||||
{ "type": "visible", "region": "statistics-chart" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "responsive-statistics-1120",
|
||||
"scenario": "dense",
|
||||
"viewport": { "width": 1120, "height": 760 },
|
||||
"activeView": "statistics",
|
||||
"interactions": [],
|
||||
"assertions": [
|
||||
{ "type": "active-view", "value": "statistics" },
|
||||
{ "type": "nonempty", "region": "statistics-kpis" },
|
||||
{ "type": "visible", "region": "statistics-chart" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Visual Driver Test</title>
|
||||
<style>
|
||||
* {
|
||||
transition: none !important;
|
||||
}
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 24px;
|
||||
}
|
||||
nav,
|
||||
main,
|
||||
[role="dialog"],
|
||||
[role="menu"],
|
||||
[role="tooltip"] {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
main,
|
||||
[role="dialog"],
|
||||
[role="menu"],
|
||||
[role="tooltip"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
[role="dialog"] {
|
||||
position: fixed;
|
||||
inset: 80px;
|
||||
z-index: 30;
|
||||
background: white;
|
||||
border: 1px solid black;
|
||||
}
|
||||
[role="menu"] {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 24px;
|
||||
z-index: 40;
|
||||
background: white;
|
||||
border: 1px solid black;
|
||||
}
|
||||
[role="tooltip"] {
|
||||
position: fixed;
|
||||
top: 140px;
|
||||
right: 24px;
|
||||
z-index: 50;
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/driver-test.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
loadVisualCapture,
|
||||
prepareVisualCapture,
|
||||
type MainViewId
|
||||
} from "./ui-driver";
|
||||
|
||||
const views: Array<{ id: MainViewId; name: string }> = [
|
||||
{ id: "downloads", name: "Downloads" },
|
||||
{ id: "collector", name: "Linksammler" },
|
||||
{ id: "settings", name: "Einstellungen" },
|
||||
{ id: "history", name: "Verlauf" },
|
||||
{ id: "statistics", name: "Statistiken" }
|
||||
];
|
||||
|
||||
function DriverTestApp({ updateAvailable }: { updateAvailable: boolean }) {
|
||||
const [activeView, setActiveView] = useState<MainViewId>("downloads");
|
||||
const [collectorOpen, setCollectorOpen] = useState(false);
|
||||
const [collectorValue, setCollectorValue] = useState("");
|
||||
const [collectorRows, setCollectorRows] = useState<string[]>([]);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = useState(updateAvailable);
|
||||
const [updateTooltipOpen, setUpdateTooltipOpen] = useState(false);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav aria-label="Hauptnavigation">
|
||||
{views.map((view) => (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
aria-current={activeView === view.id ? "page" : undefined}
|
||||
onClick={() => setActiveView(view.id)}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<main data-visual-active-view={activeView}>
|
||||
{activeView === "downloads" && (
|
||||
<>
|
||||
<div data-visual-region="downloads-sidebar">Download-Seitenleiste</div>
|
||||
<div data-visual-region="downloads-sidebar-status">3 Pakete aktiv</div>
|
||||
<div data-visual-region="downloads-toolbar">Download-Werkzeuge</div>
|
||||
<div data-visual-region="downloads-table-body">
|
||||
<div role="row">Dokumentation Staffel 1</div>
|
||||
</div>
|
||||
<div data-visual-region="downloads-pagination">Seite 1 von 1</div>
|
||||
<button type="button" onClick={() => setInfoOpen((open) => !open)}>Informationen</button>
|
||||
{infoOpen && <section aria-label="Informationen zu Downloads">Drei Downloads sind sichtbar.</section>}
|
||||
</>
|
||||
)}
|
||||
{activeView === "collector" && (
|
||||
<>
|
||||
<div data-visual-region="collector-sidebar">Linksammler-Seitenleiste</div>
|
||||
<div data-visual-region="collector-toolbar">Linksammler-Werkzeuge</div>
|
||||
<button type="button" onClick={() => setCollectorOpen(true)}>Links hinzufügen</button>
|
||||
{collectorRows.length === 0 && <div data-visual-region="collector-empty-state">Keine Links</div>}
|
||||
<div data-visual-region="collector-table-body">
|
||||
{collectorRows.map((link) => <div role="row" key={link}>{link}</div>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{activeView === "settings" && (
|
||||
<>
|
||||
<div data-visual-region="settings-sidebar">Einstellungs-Seitenleiste</div>
|
||||
<button type="button">Accounts</button>
|
||||
<div data-visual-region="accounts-table-body"><div role="row">Visual Account</div></div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateDialogOpen}
|
||||
onClick={() => setAccountMenuOpen((open) => !open)}
|
||||
>
|
||||
Kontomenü
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{activeView === "history" && (
|
||||
<>
|
||||
<div data-visual-region="history-sidebar">Verlauf-Seitenleiste</div>
|
||||
<div data-visual-region="history-toolbar">Verlauf-Werkzeuge</div>
|
||||
<div data-visual-region="history-table-body"><div role="row">Naturfilm Sammlung</div></div>
|
||||
<div data-visual-region="history-pagination">Seite 1 von 1</div>
|
||||
</>
|
||||
)}
|
||||
{activeView === "statistics" && (
|
||||
<>
|
||||
<div data-visual-region="statistics-sidebar">Statistik-Seitenleiste</div>
|
||||
<div data-visual-region="statistics-kpis">919,82 GB</div>
|
||||
<div data-visual-region="statistics-chart">Download-Verlauf</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
{collectorOpen && (
|
||||
<div role="dialog" aria-label="Links hinzufügen">
|
||||
<textarea aria-label="Links" value={collectorValue} onChange={(event) => setCollectorValue(event.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCollectorRows(collectorValue.split(/\r?\n/).map((value) => value.trim()).filter(Boolean));
|
||||
setCollectorOpen(false);
|
||||
}}
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{updateDialogOpen && (
|
||||
<div role="dialog" aria-label="Update installieren">
|
||||
<strong>Update installieren</strong>
|
||||
<button type="button" onClick={() => setUpdateDialogOpen(false)}>Später</button>
|
||||
</div>
|
||||
)}
|
||||
{accountMenuOpen && (
|
||||
<div role="menu" aria-label="Kontomenü">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Update verfügbar"
|
||||
onMouseEnter={() => setUpdateTooltipOpen(true)}
|
||||
onFocus={() => setUpdateTooltipOpen(true)}
|
||||
>
|
||||
Update verfügbar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{updateTooltipOpen && <div role="tooltip" aria-label="Update verfügbar">Version 9.9.9 verfügbar</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function startDriverTest(): Promise<void> {
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element fehlt");
|
||||
}
|
||||
const captureName = new URLSearchParams(window.location.search).get("capture");
|
||||
if (!captureName) {
|
||||
throw new Error("Capture fehlt");
|
||||
}
|
||||
try {
|
||||
const capture = await loadVisualCapture(captureName);
|
||||
createRoot(rootElement).render(<DriverTestApp updateAvailable={capture.scenario === "update"} />);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
||||
await prepareVisualCapture(capture, document);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
||||
document.documentElement.dataset.visualReady = "true";
|
||||
} catch (error) {
|
||||
delete document.documentElement.dataset.visualReady;
|
||||
rootElement.dataset.visualError = "true";
|
||||
rootElement.textContent = `Visual-Harness-Fehler: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
void startDriverTest();
|
||||
@@ -0,0 +1,585 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
HistoryEntry,
|
||||
RemoteDiagnosticsInfo,
|
||||
SupportTraceConfig,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult
|
||||
} from "../../src/shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts";
|
||||
|
||||
export const VISUAL_SCENARIOS = ["empty", "dense", "update"] as const;
|
||||
|
||||
export type VisualScenario = (typeof VISUAL_SCENARIOS)[number];
|
||||
|
||||
export interface VisualFixture {
|
||||
snapshot: UiSnapshot;
|
||||
history: HistoryEntry[];
|
||||
update: UpdateCheckResult;
|
||||
traceConfig: SupportTraceConfig;
|
||||
remoteDiagnostics: RemoteDiagnosticsInfo;
|
||||
}
|
||||
|
||||
export interface VisualClockTarget {
|
||||
setInterval: (handler: TimerHandler, timeout?: number, ...arguments_: unknown[]) => number;
|
||||
}
|
||||
|
||||
export const VISUAL_NOW_MS = 1786312800000;
|
||||
|
||||
export function installVisualClock(target: VisualClockTarget): () => void {
|
||||
const originalDateNow = Date.now;
|
||||
const originalSetInterval = target.setInterval;
|
||||
Date.now = () => VISUAL_NOW_MS;
|
||||
target.setInterval = () => 0;
|
||||
return () => {
|
||||
Date.now = originalDateNow;
|
||||
target.setInterval = originalSetInterval;
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForVisualFrames(
|
||||
requestFrame: (callback: FrameRequestCallback) => number
|
||||
): Promise<void> {
|
||||
const waitForFrame = (): Promise<void> => new Promise((resolve) => {
|
||||
requestFrame(() => resolve());
|
||||
});
|
||||
await waitForFrame();
|
||||
await waitForFrame();
|
||||
}
|
||||
|
||||
function createSettings(): AppSettings {
|
||||
const megaLogin = "visual@example.test";
|
||||
const debridLinkApiKeys = "visual-debrid-link-key-1\nvisual-debrid-link-key-2";
|
||||
const megaAccountId = getMegaDebridAccountId(megaLogin);
|
||||
const debridLinkKeys = parseDebridLinkApiKeys(debridLinkApiKeys);
|
||||
return {
|
||||
token: "visual-real-debrid-token",
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin,
|
||||
megaPassword: "visual-password",
|
||||
megaCredentials: `${megaLogin}:visual-password`,
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridPreferApi: true,
|
||||
bestToken: "visual-best-debrid-token",
|
||||
bestDebridUseWebLogin: false,
|
||||
allDebridToken: "visual-all-debrid-token",
|
||||
allDebridUseWebLogin: false,
|
||||
ddownloadLogin: "visual-ddownload",
|
||||
ddownloadPassword: "visual-password",
|
||||
oneFichierApiKey: "visual-onefichier-key",
|
||||
debridLinkApiKeys,
|
||||
debridLinkDisabledKeyIds: [],
|
||||
linkSnappyLogin: "visual-linksnappy",
|
||||
linkSnappyPassword: "visual-password",
|
||||
archivePasswordList: "visual-archive-password",
|
||||
rememberToken: true,
|
||||
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid", "alldebrid", "debridlink"],
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "megadebrid-api",
|
||||
providerTertiary: "bestdebrid",
|
||||
autoProviderFallback: true,
|
||||
outputDir: "C:\\Visual\\Downloads",
|
||||
packageName: "",
|
||||
autoExtract: true,
|
||||
autoRename4sf4sj: true,
|
||||
keepGermanAudioOnly: false,
|
||||
germanAudioMode: "tag",
|
||||
extractDir: "C:\\Visual\\Extracted",
|
||||
collectMkvToLibrary: true,
|
||||
mkvLibraryDir: "C:\\Visual\\Library",
|
||||
createExtractSubfolder: true,
|
||||
hybridExtract: true,
|
||||
cleanupMode: "none",
|
||||
extractConflictMode: "overwrite",
|
||||
removeLinkFilesAfterExtract: true,
|
||||
removeSamplesAfterExtract: true,
|
||||
enableIntegrityCheck: true,
|
||||
autoResumeOnStart: true,
|
||||
autoReconnect: true,
|
||||
reconnectWaitSeconds: 45,
|
||||
completedCleanupPolicy: "never",
|
||||
maxParallel: 4,
|
||||
maxParallelExtract: 2,
|
||||
retryLimit: 3,
|
||||
speedLimitEnabled: false,
|
||||
speedLimitKbps: 0,
|
||||
speedLimitMode: "global",
|
||||
updateRepo: "Sucukdeluxe/multi-debrid-downloader",
|
||||
autoUpdateCheck: true,
|
||||
clipboardWatch: true,
|
||||
minimizeToTray: false,
|
||||
theme: "dark",
|
||||
collapseNewPackages: false,
|
||||
historyRetentionMode: "permanent",
|
||||
historyMaxEntries: 500,
|
||||
historyMaxAgeDays: 0,
|
||||
accountListShowDetailedDebridLinkKeys: true,
|
||||
autoSortPackagesByProgress: false,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: false,
|
||||
confirmDeleteSelection: true,
|
||||
backupIncludeDownloads: false,
|
||||
backupIncludeRemoteDiagnostics: false,
|
||||
notifyUrl: "https://example.test/visual-webhook",
|
||||
notifyMention: "@visual",
|
||||
notifyOnPackageCompleted: true,
|
||||
notifyOnPackageFailed: true,
|
||||
notifyOnRunFinished: true,
|
||||
totalDownloadedAllTime: 987654321000,
|
||||
totalCompletedFilesAllTime: 842,
|
||||
totalRuntimeAllTimeMs: 172800000,
|
||||
bandwidthSchedules: [
|
||||
{
|
||||
id: "visual-schedule-night",
|
||||
startHour: 22,
|
||||
endHour: 6,
|
||||
speedLimitKbps: 12288,
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
extractCpuPriority: "middle",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: ["onefichier"],
|
||||
hosterRouting: {
|
||||
"rapidgator.net": "realdebrid",
|
||||
"ddownload.com": "debridlink"
|
||||
},
|
||||
providerDailyLimitBytes: {
|
||||
realdebrid: 1099511627776,
|
||||
debridlink: 536870912000
|
||||
},
|
||||
providerDailyUsageBytes: {
|
||||
realdebrid: 214748364800,
|
||||
debridlink: 107374182400
|
||||
},
|
||||
providerTotalUsageBytes: {
|
||||
realdebrid: 8796093022208,
|
||||
debridlink: 2199023255552
|
||||
},
|
||||
debridLinkApiKeyDailyLimitBytes: {
|
||||
[debridLinkKeys[0].id]: 268435456000,
|
||||
[debridLinkKeys[1].id]: 268435456000
|
||||
},
|
||||
debridLinkApiKeyDailyUsageBytes: {
|
||||
[debridLinkKeys[0].id]: 53687091200,
|
||||
[debridLinkKeys[1].id]: 26843545600
|
||||
},
|
||||
debridLinkApiKeyTotalUsageBytes: {
|
||||
[debridLinkKeys[0].id]: 1099511627776,
|
||||
[debridLinkKeys[1].id]: 549755813888
|
||||
},
|
||||
megaDebridDisabledAccountIds: [],
|
||||
megaDebridAccountDailyLimitBytes: {
|
||||
[megaAccountId]: 322122547200
|
||||
},
|
||||
megaDebridAccountDailyUsageBytes: {
|
||||
[megaAccountId]: 64424509440
|
||||
},
|
||||
megaDebridAccountTotalUsageBytes: {
|
||||
[megaAccountId]: 1649267441664
|
||||
},
|
||||
debridAccountStatuses: {
|
||||
[megaAccountId]: {
|
||||
accountId: megaAccountId,
|
||||
provider: "megadebrid",
|
||||
label: "Mega-Debrid Hauptkonto",
|
||||
maskedLogin: "v***@example.test",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: 1798761600000,
|
||||
email: "visual@example.test",
|
||||
message: "Premium aktiv",
|
||||
checkedAt: 1786312800000
|
||||
},
|
||||
[debridLinkKeys[0].id]: {
|
||||
accountId: debridLinkKeys[0].id,
|
||||
provider: "debridlink",
|
||||
label: "Debrid-Link Key 1",
|
||||
maskedLogin: debridLinkKeys[0].masked,
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "API-Key aktiv",
|
||||
checkedAt: 1786312800000
|
||||
},
|
||||
[debridLinkKeys[1].id]: {
|
||||
accountId: debridLinkKeys[1].id,
|
||||
provider: "debridlink",
|
||||
label: "Debrid-Link Key 2",
|
||||
maskedLogin: debridLinkKeys[1].masked,
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "API-Key aktiv",
|
||||
checkedAt: 1786312800000
|
||||
}
|
||||
},
|
||||
providerDailyUsageDay: "2026-08-10",
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptySnapshot(): UiSnapshot {
|
||||
return {
|
||||
settings: createSettings(),
|
||||
session: {
|
||||
version: 1,
|
||||
packageOrder: [],
|
||||
packages: {},
|
||||
items: {},
|
||||
runStartedAt: 0,
|
||||
totalDownloadedBytes: 0,
|
||||
summaryText: "Keine Downloads in der Warteschlange",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: false,
|
||||
updatedAt: 1786312800000
|
||||
},
|
||||
summary: null,
|
||||
stats: {
|
||||
totalDownloaded: 0,
|
||||
totalDownloadedAllTime: 987654321000,
|
||||
totalFiles: 0,
|
||||
totalFilesSession: 0,
|
||||
totalFilesAllTime: 842,
|
||||
totalPackages: 0,
|
||||
sessionStartedAt: 1786309200000,
|
||||
appSessionStartedAt: 1786309200000,
|
||||
sessionRuntimeMs: 3600000,
|
||||
totalRuntimeMs: 172800000,
|
||||
runtimeMeasuredAt: 1786312800000
|
||||
},
|
||||
speedText: "0 B/s",
|
||||
etaText: "--:--",
|
||||
canStart: false,
|
||||
canStop: false,
|
||||
canPause: false,
|
||||
clipboardActive: true,
|
||||
reconnectSeconds: 0,
|
||||
packageSpeedBps: {},
|
||||
payloadKind: "full",
|
||||
removedItemIds: [],
|
||||
removedPackageIds: [],
|
||||
rotationEvents: []
|
||||
};
|
||||
}
|
||||
|
||||
function createDenseSnapshot(): UiSnapshot {
|
||||
const snapshot = createEmptySnapshot();
|
||||
const debridLinkKeys = parseDebridLinkApiKeys(snapshot.settings.debridLinkApiKeys);
|
||||
snapshot.session = {
|
||||
version: 1,
|
||||
packageOrder: ["visual-package-active", "visual-package-complete", "visual-package-failed"],
|
||||
packages: {
|
||||
"visual-package-active": {
|
||||
id: "visual-package-active",
|
||||
name: "Dokumentation Staffel 1",
|
||||
outputDir: "C:\\Visual\\Downloads\\Dokumentation Staffel 1",
|
||||
extractDir: "C:\\Visual\\Extracted\\Dokumentation Staffel 1",
|
||||
status: "downloading",
|
||||
itemIds: ["visual-item-active-1", "visual-item-active-2"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "high",
|
||||
postProcessLabel: "Automatisch entpacken",
|
||||
downloadStartedAt: 1786311000000,
|
||||
createdAt: 1786310400000,
|
||||
updatedAt: 1786312800000
|
||||
},
|
||||
"visual-package-complete": {
|
||||
id: "visual-package-complete",
|
||||
name: "Konzertmitschnitt 2026",
|
||||
outputDir: "C:\\Visual\\Downloads\\Konzertmitschnitt 2026",
|
||||
extractDir: "C:\\Visual\\Extracted\\Konzertmitschnitt 2026",
|
||||
status: "completed",
|
||||
itemIds: ["visual-item-complete-1"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
postProcessLabel: "Entpackt",
|
||||
downloadStartedAt: 1786307400000,
|
||||
downloadCompletedAt: 1786309200000,
|
||||
createdAt: 1786306800000,
|
||||
updatedAt: 1786309200000
|
||||
},
|
||||
"visual-package-failed": {
|
||||
id: "visual-package-failed",
|
||||
name: "Archiv mit Wiederholung",
|
||||
outputDir: "C:\\Visual\\Downloads\\Archiv mit Wiederholung",
|
||||
extractDir: "C:\\Visual\\Extracted\\Archiv mit Wiederholung",
|
||||
status: "failed",
|
||||
itemIds: ["visual-item-failed-1"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "low",
|
||||
postProcessLabel: "Wartet auf Wiederholung",
|
||||
downloadStartedAt: 1786310100000,
|
||||
createdAt: 1786309800000,
|
||||
updatedAt: 1786312500000
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"visual-item-active-1": {
|
||||
id: "visual-item-active-1",
|
||||
packageId: "visual-package-active",
|
||||
url: "https://rapidgator.net/file/visual-active-1",
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid",
|
||||
providerAccountId: "visual-rd-account",
|
||||
providerAccountLabel: "Real-Debrid Hauptkonto",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 12582912,
|
||||
downloadedBytes: 3221225472,
|
||||
totalBytes: 8589934592,
|
||||
progressPercent: 37.5,
|
||||
fileName: "dokumentation.s01e01.2160p.mkv",
|
||||
targetPath: "C:\\Visual\\Downloads\\Dokumentation Staffel 1\\dokumentation.s01e01.2160p.mkv",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft",
|
||||
createdAt: 1786310400000,
|
||||
updatedAt: 1786312800000,
|
||||
onlineStatus: "online"
|
||||
},
|
||||
"visual-item-active-2": {
|
||||
id: "visual-item-active-2",
|
||||
packageId: "visual-package-active",
|
||||
url: "https://ddownload.com/visual-active-2",
|
||||
provider: "debridlink",
|
||||
providerLabel: "Debrid-Link",
|
||||
providerAccountId: debridLinkKeys[0].id,
|
||||
providerAccountLabel: "Debrid-Link Key 1",
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 7516192768,
|
||||
progressPercent: 0,
|
||||
fileName: "dokumentation.s01e02.2160p.mkv",
|
||||
targetPath: "C:\\Visual\\Downloads\\Dokumentation Staffel 1\\dokumentation.s01e02.2160p.mkv",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "In Warteschlange",
|
||||
createdAt: 1786310460000,
|
||||
updatedAt: 1786312800000,
|
||||
onlineStatus: "online"
|
||||
},
|
||||
"visual-item-complete-1": {
|
||||
id: "visual-item-complete-1",
|
||||
packageId: "visual-package-complete",
|
||||
url: "https://rapidgator.net/file/visual-complete-1",
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid",
|
||||
providerAccountId: "visual-rd-account",
|
||||
providerAccountLabel: "Real-Debrid Hauptkonto",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 12884901888,
|
||||
totalBytes: 12884901888,
|
||||
progressPercent: 100,
|
||||
fileName: "konzertmitschnitt.2026.mkv",
|
||||
targetPath: "C:\\Visual\\Downloads\\Konzertmitschnitt 2026\\konzertmitschnitt.2026.mkv",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Abgeschlossen",
|
||||
createdAt: 1786306800000,
|
||||
updatedAt: 1786309200000,
|
||||
onlineStatus: "online"
|
||||
},
|
||||
"visual-item-failed-1": {
|
||||
id: "visual-item-failed-1",
|
||||
packageId: "visual-package-failed",
|
||||
url: "https://example.test/offline/visual-failed-1",
|
||||
provider: "bestdebrid",
|
||||
providerLabel: "BestDebrid",
|
||||
providerAccountId: "visual-best-account",
|
||||
providerAccountLabel: "BestDebrid Hauptkonto",
|
||||
status: "failed",
|
||||
retries: 3,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 536870912,
|
||||
totalBytes: 4294967296,
|
||||
progressPercent: 12.5,
|
||||
fileName: "archiv.part01.rar",
|
||||
targetPath: "C:\\Visual\\Downloads\\Archiv mit Wiederholung\\archiv.part01.rar",
|
||||
resumable: false,
|
||||
attempts: 4,
|
||||
lastError: "Hoster vorübergehend nicht verfügbar",
|
||||
fullStatus: "Fehlgeschlagen nach 4 Versuchen",
|
||||
createdAt: 1786309800000,
|
||||
updatedAt: 1786312500000,
|
||||
onlineStatus: "offline"
|
||||
}
|
||||
},
|
||||
runStartedAt: 1786311000000,
|
||||
totalDownloadedBytes: 16642998272,
|
||||
summaryText: "1 aktiv, 1 wartet, 1 abgeschlossen, 1 fehlgeschlagen",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: true,
|
||||
updatedAt: 1786312800000
|
||||
};
|
||||
snapshot.summary = {
|
||||
total: 4,
|
||||
success: 1,
|
||||
failed: 1,
|
||||
cancelled: 0,
|
||||
extracted: 1,
|
||||
durationSeconds: 5400,
|
||||
averageSpeedBps: 9437184
|
||||
};
|
||||
snapshot.stats = {
|
||||
totalDownloaded: 16642998272,
|
||||
totalDownloadedAllTime: 987654321000,
|
||||
totalFiles: 4,
|
||||
totalFilesSession: 4,
|
||||
totalFilesAllTime: 842,
|
||||
totalPackages: 3,
|
||||
sessionStartedAt: 1786309200000,
|
||||
appSessionStartedAt: 1786309200000,
|
||||
sessionRuntimeMs: 3600000,
|
||||
totalRuntimeMs: 172800000,
|
||||
runtimeMeasuredAt: 1786312800000
|
||||
};
|
||||
snapshot.speedText = "12,0 MB/s";
|
||||
snapshot.etaText = "00:17:24";
|
||||
snapshot.canStart = true;
|
||||
snapshot.canStop = true;
|
||||
snapshot.canPause = true;
|
||||
snapshot.packageSpeedBps = {
|
||||
"visual-package-active": 12582912,
|
||||
"visual-package-complete": 0,
|
||||
"visual-package-failed": 0
|
||||
};
|
||||
snapshot.rotationEvents = [
|
||||
{
|
||||
id: "visual-rotation-event-1",
|
||||
at: 1786312200000,
|
||||
level: "WARN",
|
||||
provider: "Debrid-Link",
|
||||
accountLabel: "Debrid-Link Key 2",
|
||||
event: "Account gewechselt",
|
||||
reason: "Tageslimit erreicht",
|
||||
category: "quota",
|
||||
cooldownSec: 3600,
|
||||
next: "Debrid-Link Key 1"
|
||||
}
|
||||
];
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function createDenseHistory(): HistoryEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "visual-history-1",
|
||||
name: "Naturfilm Sammlung",
|
||||
totalBytes: 25769803776,
|
||||
downloadedBytes: 25769803776,
|
||||
fileCount: 6,
|
||||
provider: "realdebrid",
|
||||
completedAt: 1786226400000,
|
||||
durationSeconds: 1842,
|
||||
status: "completed",
|
||||
outputDir: "C:\\Visual\\Downloads\\Naturfilm Sammlung",
|
||||
urls: [
|
||||
"https://rapidgator.net/file/visual-history-1a",
|
||||
"https://rapidgator.net/file/visual-history-1b"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "visual-history-2",
|
||||
name: "Gelöschtes Testpaket",
|
||||
totalBytes: 4294967296,
|
||||
downloadedBytes: 4294967296,
|
||||
fileCount: 1,
|
||||
provider: "debridlink",
|
||||
completedAt: 1786140000000,
|
||||
durationSeconds: 722,
|
||||
status: "deleted",
|
||||
outputDir: "C:\\Visual\\Downloads\\Gelöschtes Testpaket",
|
||||
urls: ["https://ddownload.com/visual-history-2"]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function createUpdate(updateAvailable: boolean): UpdateCheckResult {
|
||||
return updateAvailable
|
||||
? {
|
||||
updateAvailable: true,
|
||||
currentVersion: "2.0.12",
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.test/Multi-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetDigest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
releaseNotes: "Neue kompakte Desktop-Oberfläche\nVerbesserte Accountübersicht\nPräzisere Statusanzeigen"
|
||||
}
|
||||
: {
|
||||
updateAvailable: false,
|
||||
currentVersion: "2.0.12",
|
||||
latestVersion: "2.0.12",
|
||||
latestTag: "v2.0.12",
|
||||
releaseUrl: "https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/tag/v2.0.12",
|
||||
releaseNotes: "Aktuelle Version"
|
||||
};
|
||||
}
|
||||
|
||||
function createTraceConfig(): SupportTraceConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
includeMainLog: true,
|
||||
includeAudit: true,
|
||||
logDebugRequests: false,
|
||||
autoDisableAt: null,
|
||||
updatedAt: "2026-08-10T12:00:00.000Z"
|
||||
};
|
||||
}
|
||||
|
||||
function createRemoteDiagnostics(): RemoteDiagnosticsInfo {
|
||||
return {
|
||||
status: {
|
||||
running: false,
|
||||
host: "127.0.0.1",
|
||||
port: 7843,
|
||||
hasToken: true,
|
||||
localOnly: true,
|
||||
allowlistCount: 1
|
||||
},
|
||||
code: "VISUAL-CODE",
|
||||
publicHost: "visual.example.test",
|
||||
name: "Visual Harness",
|
||||
allowlist: ["127.0.0.1"],
|
||||
suggestedHosts: ["visual.example.test"]
|
||||
};
|
||||
}
|
||||
|
||||
export function createVisualFixture(scenario: VisualScenario): VisualFixture {
|
||||
if (scenario === "empty") {
|
||||
return {
|
||||
snapshot: createEmptySnapshot(),
|
||||
history: [],
|
||||
update: createUpdate(false),
|
||||
traceConfig: createTraceConfig(),
|
||||
remoteDiagnostics: createRemoteDiagnostics()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: createDenseSnapshot(),
|
||||
history: createDenseHistory(),
|
||||
update: createUpdate(scenario === "update"),
|
||||
traceConfig: createTraceConfig(),
|
||||
remoteDiagnostics: createRemoteDiagnostics()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Debrid Downloader Visual Harness</title>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
caret-color: transparent !important;
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,189 @@
|
||||
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<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 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);
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import type { ElectronApi } from "../../src/shared/preload-api";
|
||||
import type { AppSettings, HistoryEntry } from "../../src/shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys";
|
||||
import type { VisualFixture } from "./fixtures";
|
||||
|
||||
const stableNoopUnsubscribe = (): void => {};
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
export function createVisualElectronApi(
|
||||
fixture: VisualFixture,
|
||||
search = typeof window === "undefined" ? "" : window.location.search
|
||||
): ElectronApi {
|
||||
const historyState = new URLSearchParams(search).get("history-state");
|
||||
let historyRequestCount = 0;
|
||||
const updateSettings = (settings: Partial<AppSettings>): AppSettings => {
|
||||
Object.assign(fixture.snapshot.settings, settings);
|
||||
return clone(fixture.snapshot.settings);
|
||||
};
|
||||
|
||||
return {
|
||||
getSnapshot: async () => clone(fixture.snapshot),
|
||||
getVersion: async () => "2.0.12",
|
||||
checkUpdates: async () => clone(fixture.update),
|
||||
installUpdate: async () => ({ started: true, message: "Visual update gestartet" }),
|
||||
openExternal: async () => true,
|
||||
updateSettings: async (settings) => updateSettings(settings),
|
||||
resetProviderDailyUsage: async (provider) => {
|
||||
fixture.snapshot.settings.providerDailyUsageBytes[provider] = 0;
|
||||
return clone(fixture.snapshot.settings);
|
||||
},
|
||||
resetDebridLinkApiKeyDailyUsage: async (keyId) => {
|
||||
fixture.snapshot.settings.debridLinkApiKeyDailyUsageBytes[keyId] = 0;
|
||||
return clone(fixture.snapshot.settings);
|
||||
},
|
||||
addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }),
|
||||
addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }),
|
||||
getStartConflicts: async () => [],
|
||||
resolveStartConflict: async (_packageId, policy) => ({
|
||||
skipped: policy === "skip",
|
||||
overwritten: policy === "overwrite"
|
||||
}),
|
||||
clearAll: async () => {
|
||||
fixture.snapshot.session.packageOrder = [];
|
||||
fixture.snapshot.session.packages = {};
|
||||
fixture.snapshot.session.items = {};
|
||||
fixture.snapshot.session.running = false;
|
||||
fixture.snapshot.session.paused = false;
|
||||
fixture.snapshot.session.totalDownloadedBytes = 0;
|
||||
fixture.snapshot.packageSpeedBps = {};
|
||||
fixture.snapshot.canStart = false;
|
||||
fixture.snapshot.canStop = false;
|
||||
fixture.snapshot.canPause = false;
|
||||
},
|
||||
start: async () => {
|
||||
fixture.snapshot.session.running = true;
|
||||
fixture.snapshot.session.paused = false;
|
||||
fixture.snapshot.canStop = true;
|
||||
fixture.snapshot.canPause = true;
|
||||
},
|
||||
startPackages: async (packageIds) => {
|
||||
for (const packageId of packageIds) {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.status = "downloading";
|
||||
}
|
||||
}
|
||||
fixture.snapshot.session.running = true;
|
||||
},
|
||||
stop: async () => {
|
||||
fixture.snapshot.session.running = false;
|
||||
fixture.snapshot.session.paused = false;
|
||||
fixture.snapshot.canStop = false;
|
||||
fixture.snapshot.canPause = false;
|
||||
},
|
||||
togglePause: async () => {
|
||||
fixture.snapshot.session.paused = !fixture.snapshot.session.paused;
|
||||
return fixture.snapshot.session.paused;
|
||||
},
|
||||
cancelPackage: async (packageId) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.cancelled = true;
|
||||
entry.status = "cancelled";
|
||||
}
|
||||
},
|
||||
renamePackage: async (packageId, newName) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.name = newName;
|
||||
}
|
||||
},
|
||||
reorderPackages: async (packageIds) => {
|
||||
fixture.snapshot.session.packageOrder = [...packageIds];
|
||||
},
|
||||
removeItem: async (itemId) => {
|
||||
const item = fixture.snapshot.session.items[itemId];
|
||||
if (item) {
|
||||
const entry = fixture.snapshot.session.packages[item.packageId];
|
||||
if (entry) {
|
||||
entry.itemIds = entry.itemIds.filter((id) => id !== itemId);
|
||||
}
|
||||
delete fixture.snapshot.session.items[itemId];
|
||||
}
|
||||
},
|
||||
togglePackage: async (packageId) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.enabled = !entry.enabled;
|
||||
}
|
||||
},
|
||||
exportPackageSelection: async (packageIds) => ({
|
||||
saved: true,
|
||||
packageCount: packageIds.length,
|
||||
linkCount: packageIds.reduce(
|
||||
(count, packageId) => count + (fixture.snapshot.session.packages[packageId]?.itemIds.length ?? 0),
|
||||
0
|
||||
),
|
||||
filePath: "C:\\Visual\\Exports\\packages.txt"
|
||||
}),
|
||||
exportItemSelection: async (itemIds) => ({
|
||||
saved: true,
|
||||
packageCount: new Set(
|
||||
itemIds.map((itemId) => fixture.snapshot.session.items[itemId]?.packageId).filter(Boolean)
|
||||
).size,
|
||||
linkCount: itemIds.length,
|
||||
filePath: "C:\\Visual\\Exports\\items.txt"
|
||||
}),
|
||||
exportQueue: async () => ({ saved: true }),
|
||||
importQueue: async () => ({ addedPackages: 0, addedLinks: 0 }),
|
||||
toggleClipboard: async () => {
|
||||
fixture.snapshot.clipboardActive = !fixture.snapshot.clipboardActive;
|
||||
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
|
||||
return fixture.snapshot.clipboardActive;
|
||||
},
|
||||
pickFolder: async () => "C:\\Visual\\Selected",
|
||||
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
|
||||
getSessionStats: async () => ({
|
||||
bandwidth: {
|
||||
samples: [
|
||||
{ timestamp: 1786312680000, speedBps: 10485760 },
|
||||
{ timestamp: 1786312740000, speedBps: 11534336 },
|
||||
{ timestamp: 1786312800000, speedBps: 12582912 }
|
||||
],
|
||||
currentSpeedBps: 12582912,
|
||||
averageSpeedBps: 11534336,
|
||||
maxSpeedBps: 15728640,
|
||||
totalBytesSession: 16642998272,
|
||||
sessionDurationSeconds: 3600
|
||||
},
|
||||
totalDownloads: 4,
|
||||
completedDownloads: 1,
|
||||
failedDownloads: 1,
|
||||
activeDownloads: 1,
|
||||
queuedDownloads: 1
|
||||
}),
|
||||
resetSessionStats: async () => {
|
||||
fixture.snapshot.stats.totalDownloaded = 0;
|
||||
fixture.snapshot.stats.totalFilesSession = 0;
|
||||
fixture.snapshot.session.totalDownloadedBytes = 0;
|
||||
},
|
||||
resetDownloadStats: async () => {
|
||||
fixture.snapshot.stats.totalDownloadedAllTime = 0;
|
||||
fixture.snapshot.stats.totalFilesAllTime = 0;
|
||||
fixture.snapshot.settings.totalDownloadedAllTime = 0;
|
||||
fixture.snapshot.settings.totalCompletedFilesAllTime = 0;
|
||||
fixture.snapshot.settings.totalRuntimeAllTimeMs = 0;
|
||||
},
|
||||
restart: async () => {},
|
||||
quit: async () => {},
|
||||
exportBackup: async () => ({ saved: true }),
|
||||
importBackup: async () => ({ restored: true, relaunch: false, message: "Visual backup importiert" }),
|
||||
exportOnlineBackup: async () => ({ key: "visual-online-backup-key" }),
|
||||
importOnlineBackup: async () => ({ restored: true, relaunch: false, message: "Visual online backup importiert" }),
|
||||
exportSupportBundle: async () => ({ saved: true, filePath: "C:\\Visual\\Support\\support.zip" }),
|
||||
openLog: async () => {},
|
||||
openAuditLog: async () => {},
|
||||
openRenameLog: async () => {},
|
||||
openSessionLog: async () => {},
|
||||
openTraceLog: async () => {},
|
||||
openPackageLog: async () => {},
|
||||
openItemLog: async () => {},
|
||||
getDebugSetupCheck: async () => ({
|
||||
status: "ok",
|
||||
enabled: false,
|
||||
runtimeBaseDir: "C:\\Visual\\Runtime",
|
||||
host: "127.0.0.1",
|
||||
port: 7843,
|
||||
localOnly: true,
|
||||
tokenConfigured: true,
|
||||
tokenPath: "C:\\Visual\\Runtime\\debug-token",
|
||||
supportManifestPath: "C:\\Visual\\Runtime\\support-manifest.json",
|
||||
supportManifestPresent: true,
|
||||
traceConfigPath: "C:\\Visual\\Runtime\\trace-config.json",
|
||||
traceLogPath: "C:\\Visual\\Runtime\\trace.log",
|
||||
traceEnabled: fixture.traceConfig.enabled,
|
||||
traceAutoDisableAt: fixture.traceConfig.autoDisableAt,
|
||||
diskSpace: {
|
||||
runtime: { path: "C:\\Visual\\Runtime", totalBytes: 1099511627776, freeBytes: 549755813888, freePercent: 50 },
|
||||
output: { path: "C:\\Visual\\Downloads", totalBytes: 1099511627776, freeBytes: 549755813888, freePercent: 50 },
|
||||
extract: { path: "C:\\Visual\\Extracted", totalBytes: 1099511627776, freeBytes: 549755813888, freePercent: 50 }
|
||||
},
|
||||
logSummary: {
|
||||
totalBytes: 12288,
|
||||
main: { path: "C:\\Visual\\Runtime\\main.log", exists: true, bytes: 4096 },
|
||||
mainBackup: { path: null, exists: false, bytes: 0 },
|
||||
audit: { path: "C:\\Visual\\Runtime\\audit.log", exists: true, bytes: 2048 },
|
||||
auditBackup: { path: null, exists: false, bytes: 0 },
|
||||
rename: { path: "C:\\Visual\\Runtime\\rename.log", exists: true, bytes: 1024 },
|
||||
renameBackup: { path: null, exists: false, bytes: 0 },
|
||||
session: { path: "C:\\Visual\\Runtime\\session.log", exists: true, bytes: 2048 },
|
||||
trace: { path: "C:\\Visual\\Runtime\\trace.log", exists: true, bytes: 3072 },
|
||||
traceBackup: { path: null, exists: false, bytes: 0 },
|
||||
sessionLogs: { path: "C:\\Visual\\Runtime\\sessions", exists: true, fileCount: 2, bytes: 2048 },
|
||||
packageLogs: { path: "C:\\Visual\\Runtime\\packages", exists: true, fileCount: 3, bytes: 3072 },
|
||||
itemLogs: { path: "C:\\Visual\\Runtime\\items", exists: true, fileCount: 4, bytes: 4096 }
|
||||
},
|
||||
supportBundle: {
|
||||
estimatedBytes: 24576,
|
||||
estimatedEntries: 12,
|
||||
duplicatedLiveLogBytes: 0,
|
||||
note: "Visual support bundle"
|
||||
},
|
||||
warnings: [],
|
||||
notes: ["Deterministischer Visual-Harness"],
|
||||
localUrls: {
|
||||
health: "http://127.0.0.1:7843/health",
|
||||
meta: "http://127.0.0.1:7843/meta",
|
||||
diagnostics: "http://127.0.0.1:7843/diagnostics"
|
||||
},
|
||||
remoteUrlTemplates: {
|
||||
health: "https://visual.example.test/health",
|
||||
meta: "https://visual.example.test/meta",
|
||||
diagnostics: "https://visual.example.test/diagnostics"
|
||||
}
|
||||
}),
|
||||
getRecentErrors: async () => [
|
||||
{ ts: "2026-08-10T11:55:00.000Z", level: "WARN", message: "Visualer Beispielhinweis" }
|
||||
],
|
||||
testNotification: async () => true,
|
||||
getTraceConfig: async () => clone(fixture.traceConfig),
|
||||
setTraceEnabled: async (enabled) => {
|
||||
fixture.traceConfig = { ...fixture.traceConfig, enabled };
|
||||
return clone(fixture.traceConfig);
|
||||
},
|
||||
rotateDebugToken: async () => ({ path: "C:\\Visual\\Runtime\\debug-token" }),
|
||||
getRemoteDiagnostics: async () => clone(fixture.remoteDiagnostics),
|
||||
enableRemoteDiagnostics: async (input) => {
|
||||
fixture.remoteDiagnostics = {
|
||||
...fixture.remoteDiagnostics,
|
||||
status: {
|
||||
...fixture.remoteDiagnostics.status,
|
||||
running: true,
|
||||
host: input.hostMode === "local" ? "127.0.0.1" : "0.0.0.0",
|
||||
port: input.port ?? 7843,
|
||||
localOnly: input.hostMode === "local",
|
||||
allowlistCount: input.allowlist.length
|
||||
},
|
||||
publicHost: input.publicHost,
|
||||
name: input.name ?? fixture.remoteDiagnostics.name,
|
||||
allowlist: [...input.allowlist]
|
||||
};
|
||||
return clone(fixture.remoteDiagnostics);
|
||||
},
|
||||
disableRemoteDiagnostics: async () => {
|
||||
fixture.remoteDiagnostics = {
|
||||
...fixture.remoteDiagnostics,
|
||||
status: { ...fixture.remoteDiagnostics.status, running: false }
|
||||
};
|
||||
return clone(fixture.remoteDiagnostics);
|
||||
},
|
||||
rotateRemoteDiagnosticsToken: async () => clone(fixture.remoteDiagnostics),
|
||||
openRealDebridLogin: async () => {},
|
||||
openAllDebridLogin: async () => {},
|
||||
importBestDebridCookies: async () => 2,
|
||||
getAllDebridHostInfo: async () => ({
|
||||
host: "rapidgator.net",
|
||||
source: "api",
|
||||
state: "up",
|
||||
statusLabel: "Verfügbar",
|
||||
fetchedAt: 1786312800000,
|
||||
lastCheckedAt: 1786312740000,
|
||||
quota: 42,
|
||||
quotaMax: 100,
|
||||
quotaType: "daily",
|
||||
limitSimuDl: 8,
|
||||
note: "Visual host status"
|
||||
}),
|
||||
getDebridLinkHostLimits: async () => {
|
||||
const primaryKey = parseDebridLinkApiKeys(fixture.snapshot.settings.debridLinkApiKeys)[0];
|
||||
return [{
|
||||
keyId: primaryKey.id,
|
||||
keyLabel: primaryKey.label,
|
||||
host: "ddownload.com",
|
||||
fetchedAt: 1786312800000,
|
||||
trafficCurrentBytes: 53687091200,
|
||||
trafficMaxBytes: 268435456000,
|
||||
linksCurrent: 12,
|
||||
linksMax: 100,
|
||||
note: "Visual quota",
|
||||
state: "ready",
|
||||
stateLabel: "Bereit",
|
||||
stateDetail: "Kontingent verfügbar",
|
||||
cooldownUntil: null,
|
||||
cooldownRemainingMs: 0,
|
||||
lastCheckedAt: 1786312740000,
|
||||
hostState: "up",
|
||||
hostStateLabel: "Online",
|
||||
hostNote: "Hoster verfügbar"
|
||||
}];
|
||||
},
|
||||
checkDebridAccounts: async () => clone(Object.values(fixture.snapshot.settings.debridAccountStatuses)),
|
||||
checkMegaDebridAccount: async () => {
|
||||
const status = Object.values(fixture.snapshot.settings.debridAccountStatuses).find(
|
||||
(entry) => entry.provider === "megadebrid"
|
||||
);
|
||||
return status ? clone(status) : null;
|
||||
},
|
||||
retryExtraction: async (packageId) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.status = "extracting";
|
||||
}
|
||||
},
|
||||
extractNow: async (packageId) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.status = "extracting";
|
||||
}
|
||||
},
|
||||
resetPackage: async (packageId) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.status = "queued";
|
||||
entry.cancelled = false;
|
||||
}
|
||||
},
|
||||
getHistory: async () => {
|
||||
historyRequestCount += 1;
|
||||
if (historyRequestCount > 1 && historyState === "loading") {
|
||||
return new Promise<HistoryEntry[]>(() => {});
|
||||
}
|
||||
if (historyRequestCount > 1 && historyState === "error") {
|
||||
throw new Error("Visual history load failed");
|
||||
}
|
||||
return clone(fixture.history);
|
||||
},
|
||||
clearHistory: async () => {
|
||||
fixture.history.splice(0, fixture.history.length);
|
||||
},
|
||||
removeHistoryEntry: async (entryId) => {
|
||||
const index = fixture.history.findIndex((entry) => entry.id === entryId);
|
||||
if (index >= 0) {
|
||||
fixture.history.splice(index, 1);
|
||||
}
|
||||
},
|
||||
revealHistoryEntry: async (entryId) => fixture.history.some((entry) => entry.id === entryId)
|
||||
? { ok: true }
|
||||
: { ok: false, reason: "entry-not-found" },
|
||||
setPackagePriority: async (packageId, priority) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.priority = priority;
|
||||
}
|
||||
},
|
||||
skipItems: async (itemIds) => {
|
||||
for (const itemId of itemIds) {
|
||||
const item = fixture.snapshot.session.items[itemId];
|
||||
if (item) {
|
||||
item.status = "cancelled";
|
||||
item.fullStatus = "Übersprungen";
|
||||
}
|
||||
}
|
||||
},
|
||||
resetItems: async (itemIds) => {
|
||||
for (const itemId of itemIds) {
|
||||
const item = fixture.snapshot.session.items[itemId];
|
||||
if (item) {
|
||||
item.status = "queued";
|
||||
item.downloadedBytes = 0;
|
||||
item.progressPercent = 0;
|
||||
item.speedBps = 0;
|
||||
item.lastError = "";
|
||||
item.fullStatus = "In Warteschlange";
|
||||
}
|
||||
}
|
||||
},
|
||||
startItems: async (itemIds) => {
|
||||
for (const itemId of itemIds) {
|
||||
const item = fixture.snapshot.session.items[itemId];
|
||||
if (item) {
|
||||
item.status = "downloading";
|
||||
item.fullStatus = "Download läuft";
|
||||
}
|
||||
}
|
||||
fixture.snapshot.session.running = true;
|
||||
},
|
||||
reportRendererError: () => {},
|
||||
onStateUpdate: () => stableNoopUnsubscribe,
|
||||
onClipboardDetected: () => stableNoopUnsubscribe,
|
||||
onUpdateInstallProgress: () => stableNoopUnsubscribe
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import { VISUAL_SCENARIOS, type VisualScenario } from "./fixtures";
|
||||
|
||||
export type MainViewId = "downloads" | "collector" | "settings" | "history" | "statistics";
|
||||
|
||||
export interface VisualInteraction {
|
||||
type: "click" | "hover" | "fill" | "press" | "wait-visible" | "wait-absent";
|
||||
role?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface VisualAssertion {
|
||||
type: "active-view" | "visible" | "absent" | "nonempty" | "minimum-row-count" | "layer-above";
|
||||
role?: string;
|
||||
name?: string;
|
||||
region?: string;
|
||||
value?: string | number;
|
||||
referenceRole?: string;
|
||||
referenceName?: string;
|
||||
}
|
||||
|
||||
export interface VisualCapture {
|
||||
name: string;
|
||||
scenario: VisualScenario;
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
activeView: MainViewId;
|
||||
interactions: VisualInteraction[];
|
||||
assertions: VisualAssertion[];
|
||||
}
|
||||
|
||||
const MAIN_VIEWS = ["downloads", "collector", "settings", "history", "statistics"] as const;
|
||||
const INTERACTION_TYPES = ["click", "hover", "fill", "press", "wait-visible", "wait-absent"] as const;
|
||||
const ASSERTION_TYPES = ["active-view", "visible", "absent", "nonempty", "minimum-row-count", "layer-above"] as const;
|
||||
const REGION_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const VIEW_NAMES: Record<MainViewId, string> = {
|
||||
downloads: "Downloads",
|
||||
collector: "Linksammler",
|
||||
settings: "Einstellungen",
|
||||
history: "Verlauf",
|
||||
statistics: "Statistiken"
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonemptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isOneOf<T extends string>(value: unknown, choices: readonly T[]): value is T {
|
||||
return typeof value === "string" && choices.includes(value as T);
|
||||
}
|
||||
|
||||
function validateRoleName(value: Record<string, unknown>, path: string, errors: string[]): void {
|
||||
if (!isNonemptyString(value.role)) {
|
||||
errors.push(`${path}.role must be a nonempty string`);
|
||||
}
|
||||
if (!isNonemptyString(value.name)) {
|
||||
errors.push(`${path}.name must be a nonempty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegion(value: unknown, path: string, errors: string[]): void {
|
||||
if (!isNonemptyString(value) || !REGION_PATTERN.test(value)) {
|
||||
errors.push(`${path} must match ^[a-z0-9]+(?:-[a-z0-9]+)*$`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateInteraction(value: unknown, path: string, errors: string[]): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${path} must be an object`);
|
||||
return;
|
||||
}
|
||||
if (!isOneOf(value.type, INTERACTION_TYPES)) {
|
||||
errors.push(`${path}.type must be one of ${INTERACTION_TYPES.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
validateRoleName(value, path, errors);
|
||||
if (value.type === "fill" && typeof value.value !== "string") {
|
||||
errors.push(`${path}.value must be a string`);
|
||||
}
|
||||
if (value.type === "press" && !isNonemptyString(value.key)) {
|
||||
errors.push(`${path}.key must be a nonempty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAssertion(value: unknown, path: string, errors: string[]): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${path} must be an object`);
|
||||
return;
|
||||
}
|
||||
if (!isOneOf(value.type, ASSERTION_TYPES)) {
|
||||
errors.push(`${path}.type must be one of ${ASSERTION_TYPES.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (value.type === "active-view") {
|
||||
if (!isOneOf(value.value, MAIN_VIEWS)) {
|
||||
errors.push(`${path}.value must be one of ${MAIN_VIEWS.join(", ")}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.type === "minimum-row-count") {
|
||||
validateRegion(value.region, `${path}.region`, errors);
|
||||
if (!Number.isInteger(value.value) || Number(value.value) < 0) {
|
||||
errors.push(`${path}.value must be a nonnegative integer`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.type === "layer-above") {
|
||||
validateRoleName(value, path, errors);
|
||||
if (!isNonemptyString(value.referenceRole)) {
|
||||
errors.push(`${path}.referenceRole must be a nonempty string`);
|
||||
}
|
||||
if (!isNonemptyString(value.referenceName)) {
|
||||
errors.push(`${path}.referenceName must be a nonempty string`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.region !== undefined) {
|
||||
validateRegion(value.region, `${path}.region`, errors);
|
||||
return;
|
||||
}
|
||||
validateRoleName(value, path, errors);
|
||||
}
|
||||
|
||||
export function validateVisualCaptureManifest(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return ["$ must be an array"];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const names = new Set<string>();
|
||||
input.forEach((value, index) => {
|
||||
const path = `$[${index}]`;
|
||||
const entry = isRecord(value) ? value : {};
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${path} must be an object`);
|
||||
}
|
||||
if (!isNonemptyString(entry.name)) {
|
||||
errors.push(`${path}.name must be a nonempty string`);
|
||||
} else if (names.has(entry.name)) {
|
||||
errors.push(`${path}.name must be unique`);
|
||||
} else {
|
||||
names.add(entry.name);
|
||||
}
|
||||
if (!isOneOf(entry.scenario, VISUAL_SCENARIOS)) {
|
||||
errors.push(`${path}.scenario must be one of ${VISUAL_SCENARIOS.join(", ")}`);
|
||||
}
|
||||
if (!isRecord(entry.viewport)) {
|
||||
errors.push(`${path}.viewport must be an object`);
|
||||
} else {
|
||||
if (!Number.isInteger(entry.viewport.width) || Number(entry.viewport.width) <= 0) {
|
||||
errors.push(`${path}.viewport.width must be a positive integer`);
|
||||
}
|
||||
if (!Number.isInteger(entry.viewport.height) || Number(entry.viewport.height) <= 0) {
|
||||
errors.push(`${path}.viewport.height must be a positive integer`);
|
||||
}
|
||||
}
|
||||
if (!isOneOf(entry.activeView, MAIN_VIEWS)) {
|
||||
errors.push(`${path}.activeView must be one of ${MAIN_VIEWS.join(", ")}`);
|
||||
}
|
||||
if (!Array.isArray(entry.interactions)) {
|
||||
errors.push(`${path}.interactions must be an array`);
|
||||
} else {
|
||||
entry.interactions.forEach((interaction, interactionIndex) => {
|
||||
validateInteraction(interaction, `${path}.interactions[${interactionIndex}]`, errors);
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(entry.assertions)) {
|
||||
errors.push(`${path}.assertions must be an array`);
|
||||
} else {
|
||||
entry.assertions.forEach((assertion, assertionIndex) => {
|
||||
validateAssertion(assertion, `${path}.assertions[${assertionIndex}]`, errors);
|
||||
});
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function loadVisualCapture(name: string): Promise<VisualCapture> {
|
||||
const response = await fetch(new URL("./capture-manifest.json", import.meta.url));
|
||||
if (!response.ok) {
|
||||
throw new Error(`capture manifest could not be loaded: ${response.status}`);
|
||||
}
|
||||
const manifest: unknown = await response.json();
|
||||
const errors = validateVisualCaptureManifest(manifest);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`capture manifest is invalid: ${errors.join("; ")}`);
|
||||
}
|
||||
const capture = (manifest as VisualCapture[]).find((entry) => entry.name === name);
|
||||
if (!capture) {
|
||||
throw new Error(`capture "${name}" is missing`);
|
||||
}
|
||||
return capture;
|
||||
}
|
||||
|
||||
function roleForElement(element: Element): string | null {
|
||||
const explicitRole = element.getAttribute("role");
|
||||
if (explicitRole) {
|
||||
return explicitRole;
|
||||
}
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === "button") {
|
||||
return "button";
|
||||
}
|
||||
if (tagName === "textarea") {
|
||||
return "textbox";
|
||||
}
|
||||
if (tagName === "input") {
|
||||
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
||||
if (["text", "email", "search", "tel", "url", "password"].includes(type)) {
|
||||
return "textbox";
|
||||
}
|
||||
}
|
||||
if (tagName === "section" && accessibleName(element).length > 0) {
|
||||
return "region";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function accessibleName(element: Element): string {
|
||||
const ariaLabel = element.getAttribute("aria-label");
|
||||
if (ariaLabel !== null) {
|
||||
return ariaLabel.trim();
|
||||
}
|
||||
const labelledBy = element.getAttribute("aria-labelledby");
|
||||
if (labelledBy) {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
const name = labelledBy
|
||||
.split(/\s+/)
|
||||
.map((id) => ownerDocument?.getElementById(id)?.textContent?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return element.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function isVisible(element: Element, targetDocument: Document): boolean {
|
||||
if (element.hasAttribute("hidden") || element.getAttribute("aria-hidden") === "true") {
|
||||
return false;
|
||||
}
|
||||
const style = targetDocument.defaultView?.getComputedStyle(element);
|
||||
if (style && (
|
||||
style.display === "none"
|
||||
|| style.visibility === "hidden"
|
||||
|| style.visibility === "collapse"
|
||||
|| style.opacity === "0"
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return element.getClientRects().length > 0;
|
||||
}
|
||||
|
||||
function targetLabel(role: string, name: string): string {
|
||||
return `${role} "${name}"`;
|
||||
}
|
||||
|
||||
function roleMatches(targetDocument: Document, role: string, name: string): Element[] {
|
||||
return Array.from(targetDocument.querySelectorAll("*")).filter((element) => (
|
||||
roleForElement(element) === role
|
||||
&& accessibleName(element) === name
|
||||
&& isVisible(element, targetDocument)
|
||||
));
|
||||
}
|
||||
|
||||
function resolveRole(targetDocument: Document, role: string, name: string): Element {
|
||||
const matches = roleMatches(targetDocument, role, name);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`${targetLabel(role, name)} is missing`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`${targetLabel(role, name)} is ambiguous`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function regionMatches(targetDocument: Document, region: string): Element[] {
|
||||
if (!REGION_PATTERN.test(region)) {
|
||||
throw new Error(`region "${region}" is invalid`);
|
||||
}
|
||||
return Array.from(targetDocument.querySelectorAll("[data-visual-region]"))
|
||||
.filter((element) => element.getAttribute("data-visual-region") === region)
|
||||
.filter((element) => isVisible(element, targetDocument));
|
||||
}
|
||||
|
||||
function resolveRegion(targetDocument: Document, region: string): Element {
|
||||
const matches = regionMatches(targetDocument, region);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`region marker "${region}" is missing`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`region marker "${region}" is ambiguous`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function requestFrame(targetDocument: Document): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const request = targetDocument.defaultView?.requestAnimationFrame;
|
||||
if (!request) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
request.call(targetDocument.defaultView, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForStableDom(targetDocument: Document): Promise<void> {
|
||||
await requestFrame(targetDocument);
|
||||
await requestFrame(targetDocument);
|
||||
}
|
||||
|
||||
function createEvent(targetDocument: Document, type: string, kind: "event" | "mouse" | "pointer" | "keyboard", key = ""): Event | null {
|
||||
const view = targetDocument.defaultView;
|
||||
if (!view) {
|
||||
return null;
|
||||
}
|
||||
if (kind === "keyboard") {
|
||||
return new view.KeyboardEvent(type, { bubbles: true, cancelable: true, key });
|
||||
}
|
||||
if (kind === "pointer" && typeof view.PointerEvent === "function") {
|
||||
return new view.PointerEvent(type, { bubbles: true, cancelable: true });
|
||||
}
|
||||
if (kind === "mouse" || kind === "pointer") {
|
||||
return new view.MouseEvent(type, { bubbles: true, cancelable: true });
|
||||
}
|
||||
return new view.Event(type, { bubbles: true, cancelable: true });
|
||||
}
|
||||
|
||||
function dispatch(element: Element, event: Event | null): void {
|
||||
if (event && typeof element.dispatchEvent === "function") {
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
function focusElement(element: Element): void {
|
||||
if ("focus" in element && typeof element.focus === "function") {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function clickElement(targetDocument: Document, element: Element): void {
|
||||
focusElement(element);
|
||||
dispatch(element, createEvent(targetDocument, "pointerdown", "pointer"));
|
||||
dispatch(element, createEvent(targetDocument, "mousedown", "mouse"));
|
||||
dispatch(element, createEvent(targetDocument, "pointerup", "pointer"));
|
||||
dispatch(element, createEvent(targetDocument, "mouseup", "mouse"));
|
||||
if ("click" in element && typeof element.click === "function") {
|
||||
element.click();
|
||||
} else {
|
||||
dispatch(element, createEvent(targetDocument, "click", "mouse"));
|
||||
}
|
||||
}
|
||||
|
||||
function hoverElement(targetDocument: Document, element: Element): void {
|
||||
dispatch(element, createEvent(targetDocument, "pointerover", "pointer"));
|
||||
dispatch(element, createEvent(targetDocument, "pointerenter", "pointer"));
|
||||
dispatch(element, createEvent(targetDocument, "mouseover", "mouse"));
|
||||
dispatch(element, createEvent(targetDocument, "mouseenter", "mouse"));
|
||||
}
|
||||
|
||||
function setNativeValue(targetDocument: Document, element: Element, value: string): void {
|
||||
const view = targetDocument.defaultView;
|
||||
if (!view) {
|
||||
throw new Error("document window is missing");
|
||||
}
|
||||
let prototype: object | null = Object.getPrototypeOf(element);
|
||||
let setter: ((this: Element, nextValue: string) => void) | undefined;
|
||||
while (prototype && !setter) {
|
||||
setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set as typeof setter;
|
||||
prototype = Object.getPrototypeOf(prototype);
|
||||
}
|
||||
if (!setter) {
|
||||
throw new Error("textbox native value setter is missing");
|
||||
}
|
||||
focusElement(element);
|
||||
setter.call(element, value);
|
||||
dispatch(element, createEvent(targetDocument, "input", "event"));
|
||||
dispatch(element, createEvent(targetDocument, "change", "event"));
|
||||
}
|
||||
|
||||
function pressElement(targetDocument: Document, element: Element, key: string): void {
|
||||
focusElement(element);
|
||||
dispatch(element, createEvent(targetDocument, "keydown", "keyboard", key));
|
||||
dispatch(element, createEvent(targetDocument, "keyup", "keyboard", key));
|
||||
}
|
||||
|
||||
async function waitForRoleState(
|
||||
targetDocument: Document,
|
||||
role: string,
|
||||
name: string,
|
||||
present: boolean,
|
||||
maxFrames = 180
|
||||
): Promise<void> {
|
||||
for (let frame = 0; frame <= maxFrames; frame += 1) {
|
||||
const count = roleMatches(targetDocument, role, name).length;
|
||||
if ((present && count === 1) || (!present && count === 0)) {
|
||||
return;
|
||||
}
|
||||
if (present && count > 1) {
|
||||
throw new Error(`${targetLabel(role, name)} is ambiguous`);
|
||||
}
|
||||
if (frame < maxFrames) {
|
||||
await requestFrame(targetDocument);
|
||||
}
|
||||
}
|
||||
throw new Error(`${targetLabel(role, name)} did not become ${present ? "visible" : "absent"}`);
|
||||
}
|
||||
|
||||
async function activateView(activeView: MainViewId, targetDocument: Document): Promise<void> {
|
||||
const name = VIEW_NAMES[activeView];
|
||||
const tabMatches = roleMatches(targetDocument, "tab", name);
|
||||
const buttonMatches = roleMatches(targetDocument, "button", name);
|
||||
const viewButtonMatches = buttonMatches.filter((element) => element.classList.contains("tab"));
|
||||
const matches = tabMatches.length > 0
|
||||
? tabMatches
|
||||
: viewButtonMatches.length > 0
|
||||
? viewButtonMatches
|
||||
: buttonMatches;
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`tab or button "${name}" is missing`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`tab or button "${name}" is ambiguous`);
|
||||
}
|
||||
clickElement(targetDocument, matches[0]);
|
||||
await waitForStableDom(targetDocument);
|
||||
}
|
||||
|
||||
async function runInteraction(interaction: VisualInteraction, targetDocument: Document): Promise<void> {
|
||||
const role = interaction.role as string;
|
||||
const name = interaction.name as string;
|
||||
if (interaction.type === "wait-visible" || interaction.type === "wait-absent") {
|
||||
await waitForRoleState(targetDocument, role, name, interaction.type === "wait-visible");
|
||||
await waitForStableDom(targetDocument);
|
||||
return;
|
||||
}
|
||||
const element = resolveRole(targetDocument, role, name);
|
||||
if (interaction.type === "click") {
|
||||
clickElement(targetDocument, element);
|
||||
} else if (interaction.type === "hover") {
|
||||
hoverElement(targetDocument, element);
|
||||
} else if (interaction.type === "fill") {
|
||||
setNativeValue(targetDocument, element, interaction.value as string);
|
||||
} else {
|
||||
pressElement(targetDocument, element, interaction.key as string);
|
||||
}
|
||||
await waitForStableDom(targetDocument);
|
||||
}
|
||||
|
||||
function resolveAssertionElement(assertion: VisualAssertion, targetDocument: Document): Element {
|
||||
if (assertion.region) {
|
||||
return resolveRegion(targetDocument, assertion.region);
|
||||
}
|
||||
return resolveRole(targetDocument, assertion.role as string, assertion.name as string);
|
||||
}
|
||||
|
||||
function assertActiveView(activeView: MainViewId, targetDocument: Document): void {
|
||||
const marker = targetDocument.querySelector(`[data-visual-active-view="${activeView}"]`);
|
||||
if (marker && isVisible(marker, targetDocument)) {
|
||||
return;
|
||||
}
|
||||
const name = VIEW_NAMES[activeView];
|
||||
const candidates = [
|
||||
...roleMatches(targetDocument, "tab", name),
|
||||
...roleMatches(targetDocument, "button", name)
|
||||
];
|
||||
const active = candidates.filter((element) => (
|
||||
element.getAttribute("aria-current") === "page"
|
||||
|| element.getAttribute("aria-selected") === "true"
|
||||
|| element.classList.contains("active")
|
||||
));
|
||||
if (active.length !== 1) {
|
||||
throw new Error(`active view "${activeView}" is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
function rowCount(element: Element, targetDocument: Document): number {
|
||||
return Array.from(element.querySelectorAll('[role="row"]')).filter((row) => isVisible(row, targetDocument)).length;
|
||||
}
|
||||
|
||||
function assertCapture(assertion: VisualAssertion, targetDocument: Document): void {
|
||||
if (assertion.type === "active-view") {
|
||||
assertActiveView(assertion.value as MainViewId, targetDocument);
|
||||
return;
|
||||
}
|
||||
if (assertion.type === "absent") {
|
||||
if (assertion.region) {
|
||||
const matches = regionMatches(targetDocument, assertion.region);
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`region marker "${assertion.region}" is ambiguous`);
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
throw new Error(`region marker "${assertion.region}" is visible`);
|
||||
}
|
||||
} else {
|
||||
const matches = roleMatches(targetDocument, assertion.role as string, assertion.name as string);
|
||||
if (matches.length > 0) {
|
||||
throw new Error(`${targetLabel(assertion.role as string, assertion.name as string)} is visible`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const element = resolveAssertionElement(assertion, targetDocument);
|
||||
if (assertion.type === "visible") {
|
||||
return;
|
||||
}
|
||||
if (assertion.type === "nonempty") {
|
||||
if (!(element.textContent ?? "").trim()) {
|
||||
throw new Error(`${assertion.region ? `region marker "${assertion.region}"` : targetLabel(assertion.role as string, assertion.name as string)} is empty`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (assertion.type === "minimum-row-count") {
|
||||
const count = rowCount(element, targetDocument);
|
||||
if (count < Number(assertion.value)) {
|
||||
throw new Error(`region marker "${assertion.region}" has ${count} rows, expected at least ${assertion.value}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const reference = resolveRole(targetDocument, assertion.referenceRole as string, assertion.referenceName as string);
|
||||
const view = targetDocument.defaultView;
|
||||
const zIndex = Number.parseInt(view?.getComputedStyle(element).zIndex ?? "", 10);
|
||||
const referenceZIndex = Number.parseInt(view?.getComputedStyle(reference).zIndex ?? "", 10);
|
||||
if (!Number.isFinite(zIndex) || !Number.isFinite(referenceZIndex) || zIndex <= referenceZIndex) {
|
||||
throw new Error(`${targetLabel(assertion.role as string, assertion.name as string)} is not layered above ${targetLabel(assertion.referenceRole as string, assertion.referenceName as string)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareVisualCapture(capture: VisualCapture, targetDocument: Document): Promise<void> {
|
||||
await activateView(capture.activeView, targetDocument);
|
||||
for (const interaction of capture.interactions) {
|
||||
await runInteraction(interaction, targetDocument);
|
||||
}
|
||||
for (const assertion of capture.assertions) {
|
||||
assertCapture(assertion, targetDocument);
|
||||
}
|
||||
await waitForStableDom(targetDocument);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: path.resolve(__dirname),
|
||||
publicDir: path.resolve(__dirname, "../../assets"),
|
||||
server: {
|
||||
fs: {
|
||||
allow: [path.resolve(__dirname, "../..")]
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user