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:
@@ -1,15 +1,27 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||
const workspaceSource = readFileSync(
|
||||
new URL("../src/renderer/views/settings/AccountWorkspace.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const styles = readFileSync(
|
||||
new URL("../src/renderer/views/settings/settings.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("account management layout", () => {
|
||||
it("keeps both account tabs inside the same fixed content row", () => {
|
||||
expect(appSource).toContain('<div className="account-settings-layout">');
|
||||
expect(appSource).not.toContain("account-settings-layout ${accountManagementTab}");
|
||||
expect(appSource).toContain('<div className="account-rules-panel" hidden={accountManagementTab !== "rules"}>');
|
||||
expect(styles).not.toMatch(/\.account-settings-layout\.rules\s*{/);
|
||||
expect(styles).toMatch(/\.account-rules-panel\s*{[^}]*min-width:\s*0;[^}]*min-height:\s*0;[^}]*overflow-y:\s*auto;/s);
|
||||
expect(workspaceSource).toContain('<div className="settings-account-workspace">');
|
||||
expect(workspaceSource.match(/className="settings-account-panel"/g)).toHaveLength(2);
|
||||
expect(workspaceSource).toContain('hidden={model.activePanel !== "overview"}');
|
||||
expect(workspaceSource).toContain('hidden={model.activePanel !== "rules"}');
|
||||
expect(styles).toMatch(
|
||||
/\.settings-account-workspace\s*{[^}]*grid-template-rows:\s*auto auto minmax\(0, 1fr\);[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.settings-account-panel\s*{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s
|
||||
);
|
||||
expect(styles).toMatch(/\.settings-account-rules\s*{[^}]*min-width:\s*0;[^}]*overflow-y:\s*auto;/s);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
|
||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||
|
||||
describe("desktop shell", () => {
|
||||
it("exposes all five views with exactly one active item", () => {
|
||||
const items = buildMainNavigation("downloads");
|
||||
expect(items.map((item) => item.id)).toEqual(["downloads", "collector", "settings", "history", "statistics"]);
|
||||
expect(items.filter((item) => item.active)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders context regions without global placeholders", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell
|
||||
activeView="downloads"
|
||||
onViewChange={() => {}}
|
||||
sidebar={<div>Filter</div>}
|
||||
sidebarStatus={<div>2 Downloads</div>}
|
||||
headerActions={null}
|
||||
toolbar={<div>Aktionen</div>}
|
||||
footer={<div>1–1 von 1</div>}
|
||||
contextInfo={null}
|
||||
sidebarCollapsed={false}
|
||||
onSidebarCollapsedChange={() => {}}
|
||||
>
|
||||
<div>Inhalt</div>
|
||||
</AppShell>
|
||||
);
|
||||
expect(html).toContain("data-ui-region=\"header\"");
|
||||
expect(html).toContain("data-ui-region=\"sidebar\"");
|
||||
expect(html).toContain("data-ui-region=\"sidebar-status\"");
|
||||
expect(html).toContain("data-ui-region=\"main\"");
|
||||
expect(html).toContain("1–1 von 1");
|
||||
});
|
||||
|
||||
it("does not reserve a collapsed sidebar column when sidebar slots are empty", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell
|
||||
activeView="settings"
|
||||
onViewChange={() => {}}
|
||||
sidebar={null}
|
||||
sidebarStatus={null}
|
||||
headerActions={null}
|
||||
toolbar={null}
|
||||
footer={null}
|
||||
contextInfo={null}
|
||||
sidebarCollapsed
|
||||
onSidebarCollapsedChange={() => {}}
|
||||
>
|
||||
<div>Einstellungen</div>
|
||||
</AppShell>
|
||||
);
|
||||
expect(html).not.toContain("has-collapsed-sidebar");
|
||||
expect(html).not.toContain("data-ui-region=\"sidebar\"");
|
||||
expect(html).not.toContain("data-ui-region=\"toolbar\"");
|
||||
expect(html).not.toContain("data-ui-region=\"footer\"");
|
||||
});
|
||||
|
||||
it("renders the account popover only while open", () => {
|
||||
expect(renderToStaticMarkup(<AvatarMenu open={false} accountLabel="konto@example.test" actions={[]} onClose={() => {}} />)).toBe("");
|
||||
const html = renderToStaticMarkup(
|
||||
<AvatarMenu
|
||||
open
|
||||
accountLabel="konto@example.test"
|
||||
actions={[{ id: "logout", label: "Abmelden", danger: true, onSelect: () => {} }]}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(html).toContain("role=\"menu\"");
|
||||
expect(html).toContain("aria-label=\"Kontomenü\"");
|
||||
expect(html).toContain("konto@example.test");
|
||||
expect(html).toContain("Abmelden");
|
||||
expect(html).toContain("autofocus=\"\"");
|
||||
});
|
||||
|
||||
it("maps menu keys to wrapped focus movement and closing", () => {
|
||||
expect(getAvatarMenuKeyboardAction("ArrowDown", 0, 3)).toEqual({ type: "focus", index: 1 });
|
||||
expect(getAvatarMenuKeyboardAction("ArrowDown", 2, 3)).toEqual({ type: "focus", index: 0 });
|
||||
expect(getAvatarMenuKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
|
||||
expect(getAvatarMenuKeyboardAction("Home", 2, 3)).toEqual({ type: "focus", index: 0 });
|
||||
expect(getAvatarMenuKeyboardAction("End", 0, 3)).toEqual({ type: "focus", index: 2 });
|
||||
expect(getAvatarMenuKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
|
||||
expect(getAvatarMenuKeyboardAction("Tab", 1, 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeCollectorDraftText,
|
||||
planCollectorTabRemoval,
|
||||
planCollectorTextReplacement
|
||||
} from "../src/renderer/App";
|
||||
import {
|
||||
buildCollectorRows,
|
||||
buildCollectorViewModel,
|
||||
type CollectorSourceTab
|
||||
} from "../src/renderer/views/collector/collector-model";
|
||||
import {
|
||||
CollectorInputDialog,
|
||||
CollectorContent,
|
||||
CollectorToolbar,
|
||||
CollectorView,
|
||||
type CollectorViewActions
|
||||
} from "../src/renderer/views/collector/CollectorView";
|
||||
|
||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) => visitElements(child, visit));
|
||||
return;
|
||||
}
|
||||
if (!isValidElement(node)) {
|
||||
return;
|
||||
}
|
||||
visit(node);
|
||||
visitElements(node.props.children, visit);
|
||||
visitElements(node.props.actions, visit);
|
||||
}
|
||||
|
||||
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
|
||||
let result: ReactElement | null = null;
|
||||
visitElements(node, (element) => {
|
||||
if (!result && predicate(element)) {
|
||||
result = element;
|
||||
}
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Element not found");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findButton(node: ReactNode, label: string): ReactElement {
|
||||
return findElement(node, (element) => element.type === "button" && element.props.children === label);
|
||||
}
|
||||
|
||||
function createActions(overrides: Partial<CollectorViewActions> = {}): CollectorViewActions {
|
||||
return {
|
||||
onTabSelect: () => {},
|
||||
onTabAdd: () => {},
|
||||
onTabRemove: () => {},
|
||||
onOpenInput: () => {},
|
||||
onImportDlc: () => {},
|
||||
onImportFile: () => {},
|
||||
onExportQueue: () => {},
|
||||
onSubmit: () => {},
|
||||
onQueryChange: () => {},
|
||||
onSelectionChange: () => {},
|
||||
onRemoveSelected: () => {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const populatedTabs: CollectorSourceTab[] = [
|
||||
{
|
||||
id: "tab-a",
|
||||
name: "Sammlung A",
|
||||
text: "https://example.test/a\n\n https://example.test/b "
|
||||
}
|
||||
];
|
||||
|
||||
describe("collector model", () => {
|
||||
it("derives stable rows from non-empty raw lines without validating or regrouping them", () => {
|
||||
const rows = buildCollectorRows(populatedTabs, "tab-a", "");
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((row) => row.id)).toEqual(["tab-a:0", "tab-a:2"]);
|
||||
expect(rows.map((row) => row.originalLineIndex)).toEqual([0, 2]);
|
||||
expect(rows.map((row) => row.value)).toEqual([
|
||||
"https://example.test/a",
|
||||
"https://example.test/b"
|
||||
]);
|
||||
expect(rows[0].linkCount).toBe(2);
|
||||
expect(rows[1].linkCount).toBe(2);
|
||||
});
|
||||
|
||||
it("filters presentation rows while keeping source counts and original line identities", () => {
|
||||
const model = buildCollectorViewModel(populatedTabs, "tab-a", "EXAMPLE.TEST/B", false, ["tab-a:0"]);
|
||||
|
||||
expect(model.rows.map((row) => row.id)).toEqual(["tab-a:2"]);
|
||||
expect(model.tabs).toEqual([{ id: "tab-a", name: "Sammlung A", linkCount: 2 }]);
|
||||
expect(model.selectedIds).toEqual(["tab-a:0"]);
|
||||
expect(model.empty).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves clipboard and drop appends that arrive while an input draft is open", () => {
|
||||
expect(mergeCollectorDraftText(
|
||||
"https://example.test/old",
|
||||
"https://example.test/old\nhttps://example.test/clipboard",
|
||||
"https://example.test/edited"
|
||||
)).toBe("https://example.test/edited\nhttps://example.test/clipboard");
|
||||
expect(mergeCollectorDraftText("old", "old", "edited")).toBe("edited");
|
||||
});
|
||||
|
||||
it("moves the active identity to an existing neighbor before later appends arrive", () => {
|
||||
const tabs: CollectorSourceTab[] = [
|
||||
{ id: "tab-a", name: "Sammlung A", text: "a" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "b" },
|
||||
{ id: "tab-c", name: "Sammlung C", text: "c" }
|
||||
];
|
||||
|
||||
expect(planCollectorTabRemoval(tabs, "tab-b", "tab-b")).toEqual({
|
||||
tabs: [tabs[0], tabs[2]],
|
||||
activeTabId: "tab-a"
|
||||
});
|
||||
expect(planCollectorTabRemoval(tabs, "tab-c", "tab-a")).toEqual({
|
||||
tabs: [tabs[1], tabs[2]],
|
||||
activeTabId: "tab-c"
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates positional row selection whenever raw text is replaced", () => {
|
||||
const tabs: CollectorSourceTab[] = [
|
||||
{ id: "tab-a", name: "Sammlung A", text: "old-a\nold-b" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "untouched" }
|
||||
];
|
||||
|
||||
expect(planCollectorTextReplacement(tabs, "tab-a", "new-a")).toEqual({
|
||||
tabs: [
|
||||
{ id: "tab-a", name: "Sammlung A", text: "new-a" },
|
||||
tabs[1]
|
||||
],
|
||||
selectedIds: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CollectorView", () => {
|
||||
it("keeps empty, busy and error states inside the same table body", () => {
|
||||
const empty = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={buildCollectorViewModel([{ id: "tab-a", name: "Sammlung A", text: "" }], "tab-a", "", false, [])}
|
||||
/>
|
||||
);
|
||||
const busy = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={{ ...buildCollectorViewModel([], "", "", true, []), error: "" }}
|
||||
/>
|
||||
);
|
||||
const failed = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={{ ...buildCollectorViewModel([], "", "", false, []), error: "Import fehlgeschlagen" }}
|
||||
/>
|
||||
);
|
||||
|
||||
for (const [html, state] of [
|
||||
[empty, "Noch keine Links"],
|
||||
[busy, "Links werden verarbeitet"],
|
||||
[failed, "Import fehlgeschlagen"]
|
||||
]) {
|
||||
expect(html.indexOf(state)).toBeGreaterThan(html.indexOf("data-visual-region=\"collector-table-body\""));
|
||||
}
|
||||
expect(empty).toContain("data-visual-region=\"collector-empty-state\"");
|
||||
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
|
||||
});
|
||||
|
||||
it("renders compact occupied rows and removes the empty marker", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html.match(/class=\"collector-row(?: is-selected)?\"/g)).toHaveLength(2);
|
||||
expect(html).not.toContain("data-visual-region=\"collector-empty-state\"");
|
||||
expect(html).toContain("data-visual-region=\"collector-sidebar\"");
|
||||
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
|
||||
expect(html).toContain("data-visual-region=\"collector-table-body\"");
|
||||
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
|
||||
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
||||
});
|
||||
|
||||
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
||||
let inputOpens = 0;
|
||||
let queueSubmits = 0;
|
||||
let query = "";
|
||||
let selected = "";
|
||||
let removals = 0;
|
||||
const actions = createActions({
|
||||
onOpenInput: () => { inputOpens += 1; },
|
||||
onSubmit: () => { queueSubmits += 1; },
|
||||
onQueryChange: (value) => { query = value; },
|
||||
onSelectionChange: (rowId) => { selected = rowId; },
|
||||
onRemoveSelected: () => { removals += 1; }
|
||||
});
|
||||
const toolbar = CollectorToolbar({
|
||||
actions,
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
||||
});
|
||||
const content = CollectorContent({
|
||||
actions,
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
||||
});
|
||||
|
||||
findButton(toolbar, "Links hinzufügen").props.onClick();
|
||||
expect(inputOpens).toBe(1);
|
||||
expect(queueSubmits).toBe(0);
|
||||
|
||||
findButton(toolbar, "An Downloads übergeben").props.onClick();
|
||||
expect(queueSubmits).toBe(1);
|
||||
|
||||
const search = findElement(toolbar, (element) => element.props.label === "Links durchsuchen");
|
||||
search.props.onChange({ target: { value: "release" } });
|
||||
expect(query).toBe("release");
|
||||
|
||||
const checkbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Link auswählen");
|
||||
checkbox.props.onChange();
|
||||
findButton(toolbar, "Auswahl entfernen").props.onClick();
|
||||
expect(selected).toBe("tab-a:0");
|
||||
expect(removals).toBe(1);
|
||||
expect(queueSubmits).toBe(1);
|
||||
});
|
||||
|
||||
it("names the input dialog and commits only through the local draft callback", () => {
|
||||
let value = "";
|
||||
let commits = 0;
|
||||
const dialog = CollectorInputDialog({
|
||||
open: true,
|
||||
tabName: "Sammlung A",
|
||||
value,
|
||||
onChange: (next) => { value = next; },
|
||||
onClose: () => {},
|
||||
onCommit: () => { commits += 1; }
|
||||
});
|
||||
const html = renderToStaticMarkup(dialog);
|
||||
|
||||
expect(html).toContain("role=\"dialog\"");
|
||||
expect(html).toContain("aria-label=\"Links\"");
|
||||
expect(html).toContain("Links hinzufügen");
|
||||
expect(html).toContain("Übernehmen");
|
||||
|
||||
const textbox = findElement(dialog, (element) => element.type === "textarea" && element.props["aria-label"] === "Links");
|
||||
textbox.props.onChange({ target: { value: "https://example.test/new" } });
|
||||
findButton(dialog, "Übernehmen").props.onClick();
|
||||
expect(value).toBe("https://example.test/new");
|
||||
expect(commits).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clampContextMenuPosition,
|
||||
ContextMenu,
|
||||
getContextMenuKeyboardAction,
|
||||
getContextMenuSubmenuKeyboardAction,
|
||||
getContextSubmenuPosition
|
||||
} from "../src/renderer/ui/ContextMenu";
|
||||
|
||||
describe("ContextMenu", () => {
|
||||
it("renders menu semantics and marks buttons as menu items", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContextMenu ariaLabel="Aktionen" onClose={() => {}} open x={40} y={60}>
|
||||
<button>Öffnen</button>
|
||||
<button disabled>Gesperrt</button>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
expect(html).toContain("role=\"menu\"");
|
||||
expect(html).toContain("aria-label=\"Aktionen\"");
|
||||
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(2);
|
||||
expect(html).toContain("tabindex=\"-1\"");
|
||||
});
|
||||
|
||||
it("server-renders without layout-effect warnings", () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
renderToStaticMarkup(
|
||||
<ContextMenu onClose={() => {}} open x={0} y={0}>
|
||||
<button>Öffnen</button>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it("clamps every edge to the visible viewport", () => {
|
||||
expect(clampContextMenuPosition(790, 590, 220, 180, 800, 600)).toEqual({ x: 580, y: 420 });
|
||||
expect(clampContextMenuPosition(-12, -8, 220, 180, 800, 600)).toEqual({ x: 0, y: 0 });
|
||||
expect(clampContextMenuPosition(40, 60, 220, 180, 800, 600)).toEqual({ x: 40, y: 60 });
|
||||
});
|
||||
|
||||
it("navigates enabled items, activates Enter and closes only the menu on Escape", () => {
|
||||
const enabled = [true, false, true, true];
|
||||
|
||||
expect(getContextMenuKeyboardAction("ArrowDown", 0, enabled)).toEqual({ type: "focus", index: 2 });
|
||||
expect(getContextMenuKeyboardAction("ArrowDown", 3, enabled)).toEqual({ type: "focus", index: 0 });
|
||||
expect(getContextMenuKeyboardAction("ArrowUp", 0, enabled)).toEqual({ type: "focus", index: 3 });
|
||||
expect(getContextMenuKeyboardAction("Home", 3, enabled)).toEqual({ type: "focus", index: 0 });
|
||||
expect(getContextMenuKeyboardAction("End", 0, enabled)).toEqual({ type: "focus", index: 3 });
|
||||
expect(getContextMenuKeyboardAction("Enter", 2, enabled)).toEqual({ type: "activate", index: 2 });
|
||||
expect(getContextMenuKeyboardAction("Escape", 2, enabled)).toEqual({ type: "close" });
|
||||
expect(getContextMenuKeyboardAction("ArrowDown", -1, [false, false])).toBeNull();
|
||||
});
|
||||
|
||||
it("opens and leaves submenus with standard keyboard commands", () => {
|
||||
expect(getContextMenuSubmenuKeyboardAction("Enter", true, false)).toBe("open");
|
||||
expect(getContextMenuSubmenuKeyboardAction("ArrowRight", true, false)).toBe("open");
|
||||
expect(getContextMenuSubmenuKeyboardAction("ArrowLeft", false, true)).toBe("close");
|
||||
expect(getContextMenuSubmenuKeyboardAction("Escape", false, true)).toBe("close");
|
||||
expect(getContextMenuSubmenuKeyboardAction("ArrowDown", true, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nested priority choices as an announced submenu", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContextMenu onClose={() => {}} open x={0} y={0}>
|
||||
<div className="ctx-menu-sub">
|
||||
<button aria-haspopup="menu">Priorität</button>
|
||||
<div className="ctx-menu-sub-items" role="menu">
|
||||
<button>Hoch</button>
|
||||
<button>Standard</button>
|
||||
<button>Niedrig</button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
expect(html).toContain("aria-haspopup=\"menu\"");
|
||||
expect(html.match(/role=\"menu\"/g)).toHaveLength(2);
|
||||
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("places submenus inside the viewport on every edge", () => {
|
||||
expect(getContextSubmenuPosition(
|
||||
{ left: 700, right: 790, top: 40 },
|
||||
{ width: 180, height: 150 },
|
||||
{ width: 800, height: 600 }
|
||||
)).toEqual({ x: 520, y: 40 });
|
||||
expect(getContextSubmenuPosition(
|
||||
{ left: 8, right: 98, top: 40 },
|
||||
{ width: 180, height: 150 },
|
||||
{ width: 800, height: 600 }
|
||||
)).toEqual({ x: 98, y: 40 });
|
||||
expect(getContextSubmenuPosition(
|
||||
{ left: 500, right: 590, top: 560 },
|
||||
{ width: 180, height: 150 },
|
||||
{ width: 800, height: 600 }
|
||||
)).toEqual({ x: 590, y: 450 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createRef } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
getConnectedDialogRestoreTarget,
|
||||
getDialogInitialFocusTarget,
|
||||
getDialogKeyboardAction,
|
||||
getDialogRestoreFocusTarget
|
||||
} from "../src/renderer/ui/Dialog";
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("renders labelled modal semantics only while open", () => {
|
||||
const closed = renderToStaticMarkup(
|
||||
<Dialog actions={null} onClose={() => {}} open={false} title="Test">Body</Dialog>
|
||||
);
|
||||
const open = renderToStaticMarkup(
|
||||
<Dialog actions={<button>OK</button>} description="Beschreibung" onClose={() => {}} open title="Test">Body</Dialog>
|
||||
);
|
||||
|
||||
expect(closed).toBe("");
|
||||
expect(open).toContain("role=\"dialog\"");
|
||||
expect(open).toContain("aria-modal=\"true\"");
|
||||
expect(open).toMatch(/aria-labelledby=\"[^\"]+\"/);
|
||||
expect(open).toMatch(/aria-describedby=\"[^\"]+\"/);
|
||||
expect(open).toContain("Beschreibung");
|
||||
expect(open).toContain("Body");
|
||||
expect(open).toContain("OK");
|
||||
});
|
||||
|
||||
it("applies bounded account and update surfaces without changing the dialog contract", () => {
|
||||
const account = renderToStaticMarkup(
|
||||
<Dialog actions={null} initialFocusRef={createRef<HTMLButtonElement>()} onClose={() => {}} open size="account" title="Account">Body</Dialog>
|
||||
);
|
||||
const update = renderToStaticMarkup(
|
||||
<Dialog actions={null} danger onClose={() => {}} open size="update" title="Update">Body</Dialog>
|
||||
);
|
||||
|
||||
expect(account).toContain("md-dialog-size-account");
|
||||
expect(update).toContain("md-dialog-size-update");
|
||||
expect(update).toContain("is-danger");
|
||||
});
|
||||
|
||||
it("traps forward and reverse tabbing and honors closable Escape", () => {
|
||||
expect(getDialogKeyboardAction("Tab", false, -1, 4, true)).toEqual({ type: "focus", index: 0 });
|
||||
expect(getDialogKeyboardAction("Tab", true, -1, 4, true)).toEqual({ type: "focus", index: 3 });
|
||||
expect(getDialogKeyboardAction("Tab", false, 3, 4, true)).toEqual({ type: "focus", index: 0 });
|
||||
expect(getDialogKeyboardAction("Tab", true, 0, 4, true)).toEqual({ type: "focus", index: 3 });
|
||||
expect(getDialogKeyboardAction("Tab", false, 1, 4, true)).toBeNull();
|
||||
expect(getDialogKeyboardAction("Escape", false, 0, 4, true)).toEqual({ type: "close" });
|
||||
expect(getDialogKeyboardAction("Escape", false, 0, 4, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps existing autofocus targets before falling back to the dialog surface", () => {
|
||||
const explicitTarget = {} as HTMLElement;
|
||||
const autofocusTarget = {} as HTMLElement;
|
||||
const activeAutofocusTarget = {} as HTMLElement;
|
||||
const dialog = {
|
||||
contains: (target: HTMLElement) => target === activeAutofocusTarget,
|
||||
querySelector: (selector: string) => selector === "[autofocus]" ? autofocusTarget : null
|
||||
} as unknown as HTMLElement;
|
||||
const fallbackDialog = { querySelector: () => null } as unknown as HTMLElement;
|
||||
|
||||
expect(getDialogInitialFocusTarget(dialog, explicitTarget)).toBe(explicitTarget);
|
||||
expect(getDialogInitialFocusTarget(dialog, null, activeAutofocusTarget)).toBe(activeAutofocusTarget);
|
||||
expect(getDialogInitialFocusTarget(dialog, null)).toBe(autofocusTarget);
|
||||
expect(getDialogInitialFocusTarget(fallbackDialog, null)).toBe(fallbackDialog);
|
||||
});
|
||||
|
||||
it("captures the opener before autofocus moves into the mounted dialog", () => {
|
||||
const opener = { isConnected: true } as HTMLElement;
|
||||
const dialogTarget = { isConnected: true } as HTMLElement;
|
||||
const dialog = {
|
||||
contains: (target: HTMLElement) => target === dialogTarget
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
expect(getDialogRestoreFocusTarget(dialog, opener)).toBe(opener);
|
||||
expect(getDialogRestoreFocusTarget(dialog, dialogTarget)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a stable caller fallback when a transient opener unmounts", () => {
|
||||
const transientOpener = { isConnected: false } as HTMLElement;
|
||||
const stableFallback = { isConnected: true } as HTMLElement;
|
||||
|
||||
expect(getConnectedDialogRestoreTarget(transientOpener, stableFallback)).toBe(stableFallback);
|
||||
expect(getConnectedDialogRestoreTarget(stableFallback, null)).toBe(stableFallback);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,678 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types";
|
||||
import {
|
||||
buildDownloadSidebarCounts,
|
||||
buildDownloadsViewModel,
|
||||
classifyDownloadStatus,
|
||||
type DownloadSidebarFilter,
|
||||
type DownloadsModelInput
|
||||
} from "../src/renderer/views/downloads/downloads-model";
|
||||
import {
|
||||
DownloadsContent,
|
||||
DownloadsFooter,
|
||||
DownloadsSidebar,
|
||||
DownloadsSidebarStatus,
|
||||
DownloadsToolbar,
|
||||
DownloadsView,
|
||||
type DownloadsViewActions
|
||||
} from "../src/renderer/views/downloads/DownloadsView";
|
||||
import {
|
||||
DownloadsTableHeader,
|
||||
PackageCardContent,
|
||||
areItemRowPropsEqual,
|
||||
arePackageCardPropsEqual
|
||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||
|
||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||
|
||||
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial<DownloadItem> = {}): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
packageId,
|
||||
url: `https://rapidgator.net/file/${id}`,
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid",
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: status === "downloading" ? 12_000_000 : 0,
|
||||
downloadedBytes: status === "completed" ? 2_000_000_000 : 500_000_000,
|
||||
totalBytes: 2_000_000_000,
|
||||
progressPercent: status === "completed" ? 100 : 25,
|
||||
fileName: `${id}.mkv`,
|
||||
targetPath: `C:\\Downloads\\${id}.mkv`,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: status === "failed" ? "Hoster nicht erreichbar" : "",
|
||||
fullStatus: status,
|
||||
createdAt: now - 1_000,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
|
||||
return { id, name, itemIds, createdAt: now } as PackageEntry;
|
||||
}
|
||||
|
||||
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
|
||||
const items = [
|
||||
item("active", "package-a", "downloading"),
|
||||
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
|
||||
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
|
||||
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
|
||||
];
|
||||
const packages = [
|
||||
pkg("package-a", "Aktive Serie", ["active", "queued"]),
|
||||
pkg("package-b", "Archiv Paket", ["failed", "done"])
|
||||
];
|
||||
return {
|
||||
packageOrder: packages.map((entry) => entry.id),
|
||||
packages: Object.fromEntries(packages.map((entry) => [entry.id, entry])),
|
||||
items: Object.fromEntries(items.map((entry) => [entry.id, entry])),
|
||||
displayMode: "packages",
|
||||
filter: "all",
|
||||
providerFilter: "all",
|
||||
query: "",
|
||||
collapsedPackageIds: [],
|
||||
selectedIds: [],
|
||||
hideExtractedItems: false,
|
||||
showAllPackages: false,
|
||||
renderLimit: 260,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createActions(overrides: Partial<DownloadsViewActions> = {}): DownloadsViewActions {
|
||||
return {
|
||||
onDisplayModeChange: () => {},
|
||||
onFilterChange: () => {},
|
||||
onProviderFilterChange: () => {},
|
||||
onQueryChange: () => {},
|
||||
onAddLinks: () => {},
|
||||
onStartDownloads: () => {},
|
||||
onPauseDownloads: () => {},
|
||||
onStopDownloads: () => {},
|
||||
onToggleSchedule: () => {},
|
||||
onScheduleTimeChange: () => {},
|
||||
onActivateSchedule: () => {},
|
||||
onCancelSchedule: () => {},
|
||||
onMoveSelectionUp: () => {},
|
||||
onMoveSelectionDown: () => {},
|
||||
onRenameSelection: () => {},
|
||||
onRemoveSelection: () => {},
|
||||
onToggleClipboardWatcher: () => {},
|
||||
onClearAll: () => {},
|
||||
onToggleAllPackages: () => {},
|
||||
onShowAllPackages: () => {},
|
||||
onPackageDragStart: () => {},
|
||||
onPackageDrop: () => {},
|
||||
onPackageDragEnd: () => {},
|
||||
onSetVisibleSelection: () => {},
|
||||
onToggleSelection: () => {},
|
||||
onSelectionMouseDown: () => {},
|
||||
onSelectionMouseEnter: () => {},
|
||||
onTogglePackage: () => {},
|
||||
onTogglePackageCollapse: () => {},
|
||||
onStartPackageRename: () => {},
|
||||
onPackageRenameChange: () => {},
|
||||
onCommitPackageRename: () => {},
|
||||
onCancelPackageRename: () => {},
|
||||
onCancelPackage: () => {},
|
||||
onMovePackageUp: () => {},
|
||||
onMovePackageDown: () => {},
|
||||
onRemoveItem: () => {},
|
||||
onOpenContextMenu: () => {},
|
||||
onSortColumn: () => {},
|
||||
onColumnDragStart: () => {},
|
||||
onColumnDragOver: () => {},
|
||||
onColumnDragLeave: () => {},
|
||||
onColumnDrop: () => {},
|
||||
onColumnDragEnd: () => {},
|
||||
onColumnContextMenu: () => {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) => visitElements(child, visit));
|
||||
return;
|
||||
}
|
||||
if (!isValidElement(node)) {
|
||||
return;
|
||||
}
|
||||
visit(node);
|
||||
visitElements(node.props.children, visit);
|
||||
}
|
||||
|
||||
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
|
||||
let result: ReactElement | null = null;
|
||||
visitElements(node, (element) => {
|
||||
if (!result && predicate(element)) {
|
||||
result = element;
|
||||
}
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Element not found");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findButton(node: ReactNode, label: string): ReactElement {
|
||||
return findElement(node, (element) => element.type === "button" && element.props.children === label);
|
||||
}
|
||||
|
||||
function withRuntime(input: DownloadsModelInput, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...buildDownloadsViewModel(input),
|
||||
running: true,
|
||||
paused: false,
|
||||
canStart: true,
|
||||
canPause: true,
|
||||
canStop: true,
|
||||
actionBusy: false,
|
||||
reconnectSeconds: 0,
|
||||
reconnectReason: "",
|
||||
clipboardWatcher: true,
|
||||
scheduleActive: false,
|
||||
scheduleOpen: false,
|
||||
scheduleTime: "23:30",
|
||||
scheduleLabel: "",
|
||||
packageSpeedBps: { "package-a": 12_000_000 },
|
||||
editingPackageId: null,
|
||||
editingName: "",
|
||||
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
||||
gridTemplate: "minmax(280px, 2fr) 140px 160px minmax(220px, 1fr)",
|
||||
status: {
|
||||
packages: 2,
|
||||
links: 4,
|
||||
session: "3,00 GB",
|
||||
total: "10,00 GB",
|
||||
hosters: 3,
|
||||
speed: "96,00 Mbit/s",
|
||||
eta: "00:05:00"
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("downloads model", () => {
|
||||
it("exports the sidebar filter contract used by the downloads shell", () => {
|
||||
const filter: DownloadSidebarFilter = "queued";
|
||||
|
||||
expect(filter).toBe("queued");
|
||||
});
|
||||
|
||||
it("builds sidebar counts without mutating the runtime items", () => {
|
||||
const items = [
|
||||
item("count-active", "count-package", "downloading"),
|
||||
item("count-done", "count-package", "completed"),
|
||||
item("count-cancelled", "count-package", "cancelled")
|
||||
];
|
||||
const snapshot = items.map((entry) => ({ ...entry }));
|
||||
|
||||
expect(buildDownloadSidebarCounts(items)).toEqual({ all: 3, active: 1, queued: 0, paused: 0, completed: 1, failed: 0 });
|
||||
expect(items).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("maps every runtime status into the exact semantic filter class", () => {
|
||||
expect(classifyDownloadStatus("downloading")).toBe("active");
|
||||
expect(classifyDownloadStatus("validating")).toBe("active");
|
||||
expect(classifyDownloadStatus("extracting")).toBe("active");
|
||||
expect(classifyDownloadStatus("integrity_check")).toBe("active");
|
||||
expect(classifyDownloadStatus("queued")).toBe("queued");
|
||||
expect(classifyDownloadStatus("reconnect_wait")).toBe("queued");
|
||||
expect(classifyDownloadStatus("paused")).toBe("paused");
|
||||
expect(classifyDownloadStatus("completed")).toBe("completed");
|
||||
expect(classifyDownloadStatus("failed")).toBe("failed");
|
||||
expect(classifyDownloadStatus("cancelled")).toBe("all");
|
||||
});
|
||||
|
||||
it("derives sidebar counts from the complete queue before presentation filters", () => {
|
||||
const model = buildDownloadsViewModel(createInput({ filter: "failed" }));
|
||||
|
||||
expect(model.counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 1 });
|
||||
expect(model.visibleItemIds).toEqual(["failed"]);
|
||||
});
|
||||
|
||||
it("filters by package name, file name, provider, status and extracted visibility", () => {
|
||||
const byPackage = buildDownloadsViewModel(createInput({ query: "aktive serie" }));
|
||||
const byFile = buildDownloadsViewModel(createInput({ query: "done.mkv" }));
|
||||
const byProvider = buildDownloadsViewModel(createInput({ providerFilter: "debridlink" }));
|
||||
const hiddenExtracted = buildDownloadsViewModel(createInput({
|
||||
items: { ...createInput().items, done: item("done", "package-b", "completed", { fullStatus: "Entpackt" }) },
|
||||
hideExtractedItems: true
|
||||
}));
|
||||
|
||||
expect(byPackage.packageRows.map((row) => row.package.id)).toEqual(["package-a"]);
|
||||
expect(byPackage.packageRows[0].items.map((entry) => entry.id)).toEqual(["active", "queued"]);
|
||||
expect(byFile.packageRows.map((row) => row.package.id)).toEqual(["package-b"]);
|
||||
expect(byFile.packageRows[0].items.map((entry) => entry.id)).toEqual(["done"]);
|
||||
expect(byProvider.visibleItemIds).toEqual(["queued"]);
|
||||
expect(hiddenExtracted.visibleItemIds).not.toContain("done");
|
||||
});
|
||||
|
||||
it("supports the genuine flat file mode without synthetic package rows", () => {
|
||||
const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
|
||||
|
||||
expect(model.packageRows).toEqual([]);
|
||||
expect(model.fileRows.map((entry) => entry.id)).toEqual(["active", "queued", "failed", "done"]);
|
||||
expect(model.mainRowCount).toBe(4);
|
||||
});
|
||||
|
||||
it("reports a filtered flat-file range from the actually rendered rows", () => {
|
||||
const model = buildDownloadsViewModel(createInput({ displayMode: "files", filter: "failed" }));
|
||||
|
||||
expect(model.paginationLabel).toBe("1\u20131 von 1");
|
||||
expect(model.totalMainRowCount).toBe(1);
|
||||
});
|
||||
|
||||
it("counts cancelled downloads only in the complete all queue", () => {
|
||||
const model = buildDownloadsViewModel(createInput({
|
||||
packageOrder: ["cancelled-package"],
|
||||
packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) },
|
||||
items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") }
|
||||
}));
|
||||
|
||||
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 });
|
||||
expect(buildDownloadsViewModel({ ...createInput(), filter: "completed", packageOrder: model.packageRows.map((row) => row.package.id), packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) }, items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") } }).visibleItemIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds an account label without treating it as a provider id", () => {
|
||||
const base = createInput();
|
||||
const model = buildDownloadsViewModel({
|
||||
...base,
|
||||
items: {
|
||||
...base.items,
|
||||
active: { ...base.items.active, providerAccountLabel: "Privates Real-Debrid Konto" }
|
||||
},
|
||||
query: "privates real-debrid"
|
||||
});
|
||||
|
||||
expect(model.visibleItemIds).toEqual(["active"]);
|
||||
expect(model.providerFilter).toBe("all");
|
||||
});
|
||||
|
||||
it("excludes collapsed children from visible and actionable row selection", () => {
|
||||
const model = buildDownloadsViewModel(createInput({
|
||||
collapsedPackageIds: ["package-a"],
|
||||
selectedIds: ["package-a", "active", "queued"]
|
||||
}));
|
||||
|
||||
expect(model.visibleRowIds).not.toContain("active");
|
||||
expect(model.actionableSelectedIds).toEqual(["package-a"]);
|
||||
});
|
||||
|
||||
it("limits occupied package rows honestly while preserving active packages and an actionable visible selection", () => {
|
||||
const packageEntries = Array.from({ length: 264 }, (_, index) => pkg(`p-${index}`, `Paket ${index}`, [`i-${index}`]));
|
||||
const itemEntries = packageEntries.map((entry, index) => item(`i-${index}`, entry.id, index === 263 ? "downloading" : "queued"));
|
||||
const model = buildDownloadsViewModel(createInput({
|
||||
packageOrder: packageEntries.map((entry) => entry.id),
|
||||
packages: Object.fromEntries(packageEntries.map((entry) => [entry.id, entry])),
|
||||
items: Object.fromEntries(itemEntries.map((entry) => [entry.id, entry])),
|
||||
selectedIds: ["p-0", "i-0", "p-263", "i-263", "missing"],
|
||||
renderLimit: 260
|
||||
}));
|
||||
|
||||
expect(model.packageRows).toHaveLength(260);
|
||||
expect(model.packageRows.some((row) => row.package.id === "p-263")).toBe(true);
|
||||
expect(model.paginationLabel).toBe("1–260 von 264");
|
||||
expect(model.actionableSelectedIds).toEqual(["p-0", "i-0", "p-263", "i-263"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloads view", () => {
|
||||
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
|
||||
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
||||
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
|
||||
|
||||
for (const marker of ["downloads-sidebar", "downloads-sidebar-status", "downloads-toolbar", "downloads-table-body", "downloads-pagination"]) {
|
||||
expect(occupied.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||||
expect(empty.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
expect(occupied).not.toContain("data-visual-region=\"downloads-empty-state\"");
|
||||
expect(empty).toContain("data-visual-region=\"downloads-empty-state\"");
|
||||
expect(empty).toContain("F\u00fcge Links hinzu, um den ersten Download zu starten.");
|
||||
});
|
||||
|
||||
it("renders the distinct filtered-empty guidance inside the table body", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ filter: "paused" }))} />);
|
||||
|
||||
expect(html).toContain("Keine passenden Downloads");
|
||||
expect(html).toContain("Passe Filter oder Suche an.");
|
||||
expect(html).not.toContain("data-visual-region=\"downloads-empty-state\"");
|
||||
expect(html).toContain("0 von 0");
|
||||
});
|
||||
|
||||
it("keeps sidebar, status, toolbar, table and footer as independently renderable production modules", () => {
|
||||
const model = withRuntime(createInput());
|
||||
const actions = createActions();
|
||||
|
||||
expect(renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />)).toContain("downloads-sidebar");
|
||||
expect(renderToStaticMarkup(<DownloadsSidebarStatus model={model} />)).toContain("downloads-sidebar-status");
|
||||
expect(renderToStaticMarkup(<DownloadsToolbar actions={actions} model={model} />)).toContain("downloads-toolbar");
|
||||
expect(renderToStaticMarkup(<DownloadsContent actions={actions} model={model} />)).toContain("downloads-table-body");
|
||||
expect(renderToStaticMarkup(<DownloadsFooter actions={actions} model={model} />)).toContain("downloads-pagination");
|
||||
});
|
||||
|
||||
it("keeps the compact download search in the sidebar instead of the action toolbar", () => {
|
||||
const model = withRuntime(createInput());
|
||||
const actions = createActions();
|
||||
const sidebar = renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />);
|
||||
const toolbar = renderToStaticMarkup(<DownloadsToolbar actions={actions} model={model} />);
|
||||
|
||||
expect(sidebar).toContain("downloads-sidebar-search");
|
||||
expect(sidebar).toContain("Paket, Datei oder Service");
|
||||
expect(toolbar).not.toContain("downloads-search-input");
|
||||
});
|
||||
|
||||
it("forwards package drag lifecycle callbacks through the extracted downloads content", () => {
|
||||
const calls: string[] = [];
|
||||
const actions = createActions() as DownloadsViewActions & {
|
||||
onPackageDragStart: (packageId: string) => void;
|
||||
onPackageDrop: (packageId: string) => void;
|
||||
onPackageDragEnd: () => void;
|
||||
};
|
||||
actions.onPackageDragStart = (packageId) => calls.push(`start:${packageId}`);
|
||||
actions.onPackageDrop = (packageId) => calls.push(`drop:${packageId}`);
|
||||
actions.onPackageDragEnd = () => calls.push("end");
|
||||
const content = DownloadsContent({ actions, model: withRuntime(createInput()) });
|
||||
const packageElement = findElement(content, (element) => element.props.row?.package.id === "package-a");
|
||||
|
||||
packageElement.props.onDragStart("package-a");
|
||||
packageElement.props.onDrop("package-b");
|
||||
packageElement.props.onDragEnd();
|
||||
|
||||
expect(calls).toEqual(["start:package-a", "drop:package-b", "end"]);
|
||||
});
|
||||
|
||||
it("dispatches add, start, pause, stop, scheduling and selection actions through separate existing callbacks", () => {
|
||||
const calls: string[] = [];
|
||||
const actions = createActions({
|
||||
onAddLinks: () => calls.push("add"),
|
||||
onStartDownloads: () => calls.push("start"),
|
||||
onPauseDownloads: () => calls.push("pause"),
|
||||
onStopDownloads: () => calls.push("stop"),
|
||||
onActivateSchedule: () => calls.push("schedule"),
|
||||
onMoveSelectionUp: () => calls.push("up"),
|
||||
onMoveSelectionDown: () => calls.push("down"),
|
||||
onRenameSelection: () => calls.push("rename"),
|
||||
onRemoveSelection: () => calls.push("remove")
|
||||
});
|
||||
const toolbar = DownloadsToolbar({
|
||||
actions,
|
||||
model: withRuntime(createInput({ selectedIds: ["active"] }), { scheduleOpen: true })
|
||||
});
|
||||
|
||||
for (const label of ["Links hinzufügen", "Start", "Pause", "Stop", "Planen", "Nach oben", "Nach unten", "Umbenennen", "Entfernen"]) {
|
||||
findButton(toolbar, label).props.onClick();
|
||||
}
|
||||
|
||||
expect(calls).toEqual(["add", "start", "pause", "stop", "schedule", "up", "down", "rename", "remove"]);
|
||||
});
|
||||
|
||||
it("uses exact toolbar disabled semantics without a dead reconnect branch", () => {
|
||||
const toolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { canStart: false, canPause: false, canStop: false, reconnectSeconds: 8 })
|
||||
});
|
||||
|
||||
expect(findButton(toolbar, "Start").props.disabled).toBe(true);
|
||||
expect(findButton(toolbar, "Pause").props.disabled).toBe(true);
|
||||
expect(findButton(toolbar, "Stop").props.disabled).toBe(true);
|
||||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
||||
});
|
||||
|
||||
it("keeps start available for resume and pause independent from unrelated action busy state", () => {
|
||||
const pausedToolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
|
||||
});
|
||||
const busyToolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { paused: false, canPause: true, actionBusy: true })
|
||||
});
|
||||
|
||||
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(false);
|
||||
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
|
||||
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("enables package movement only for a visible selected package row", () => {
|
||||
const itemOnly = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput({ selectedIds: ["active"] }))
|
||||
});
|
||||
const packageSelected = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput({ selectedIds: ["package-a"] }))
|
||||
});
|
||||
|
||||
expect(findButton(itemOnly, "Nach oben").props.disabled).toBe(true);
|
||||
expect(findButton(itemOnly, "Nach unten").props.disabled).toBe(true);
|
||||
expect(findButton(packageSelected, "Nach oben").props.disabled).toBe(false);
|
||||
expect(findButton(packageSelected, "Nach unten").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an active schedule visible and cancellable while the picker is closed", () => {
|
||||
const toolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { scheduleActive: true, scheduleOpen: false, scheduleLabel: "1m 30s" })
|
||||
});
|
||||
const html = renderToStaticMarkup(toolbar);
|
||||
|
||||
expect(html).toContain("Geplant: 1m 30s");
|
||||
expect(findButton(toolbar, "Abbrechen").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the table header and all rows in one horizontal scroll context with exact dense geometry", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
|
||||
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
|
||||
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
|
||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;/s);
|
||||
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
||||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*48px;/s);
|
||||
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
|
||||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
|
||||
expect(css).toMatch(/\.downloads-action-cell button,\s*\.downloads-collapse-button\s*\{[^}]*width:\s*30px;[^}]*height:\s*30px;/s);
|
||||
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid var\(--ui-border\);[^}]*padding:\s*0;/s);
|
||||
expect(css).not.toMatch(/gradient|box-shadow|nth-child/i);
|
||||
});
|
||||
|
||||
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.downloads-sidebar,\s*\.downloads-sidebar-status,\s*\.downloads-toolbar,\s*\.downloads-content,\s*\.downloads-footer\s*\{[^}]*user-select:\s*none;/s);
|
||||
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
|
||||
});
|
||||
|
||||
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/@media \(max-width:\s*1120px\)/);
|
||||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*overflow:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*auto;/s);
|
||||
});
|
||||
|
||||
it("uses only semantic color variables declared by the shared theme", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||||
const usedVariables = [...css.matchAll(/var\((--ui-[a-z-]+)/g)].map((match) => match[1]);
|
||||
const declaredVariables = new Set([...theme.matchAll(/(--ui-[a-z-]+)\s*:/g)].map((match) => match[1]));
|
||||
|
||||
expect([...new Set(usedVariables)].filter((name) => !declaredVariables.has(name))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloads App integration", () => {
|
||||
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
|
||||
expect(source).not.toMatch(/^const ItemRow\b|^const PackageCard\b|^interface ItemRowProps\b|^interface PackageCardProps\b/m);
|
||||
expect(source).toContain('window.removeEventListener("mouseup", dragMouseUpRef.current)');
|
||||
expect(source).toContain("downloadsActions.onStartDownloads(); setContextMenu(null);");
|
||||
expect(source).not.toContain(") : false ? (");
|
||||
expect(source).not.toContain("{false && (");
|
||||
});
|
||||
});
|
||||
|
||||
describe("download table row contracts", () => {
|
||||
it("preserves the package download and extraction phase split", () => {
|
||||
const extractionPackage = {
|
||||
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
|
||||
status: "extracting"
|
||||
} as PackageEntry;
|
||||
const extractionItem = item("extracting-item", extractionPackage.id, "completed", {
|
||||
fullStatus: "Entpacken 40%",
|
||||
progressPercent: 100
|
||||
});
|
||||
const html = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["progress"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "80px",
|
||||
packageSpeedBps: 0,
|
||||
row: { package: extractionPackage, items: [extractionItem], collapsed: true },
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
|
||||
expect(html).toContain("<b>70%</b>");
|
||||
});
|
||||
|
||||
it("sets the whole visible selection atomically from the header checkbox", () => {
|
||||
const calls: unknown[] = [];
|
||||
const header = DownloadsTableHeader({
|
||||
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push([ids, selected]) }),
|
||||
columnOrder: ["name"],
|
||||
gridTemplate: "minmax(280px, 1fr)",
|
||||
selectedCount: 1,
|
||||
sortColumn: "name",
|
||||
sortDirection: "asc",
|
||||
visibleIds: ["package-a", "active", "queued"]
|
||||
});
|
||||
const checkbox = findElement(header, (element) => element.type === "input");
|
||||
|
||||
checkbox.props.onChange({ target: { checked: true } });
|
||||
|
||||
expect(calls).toEqual([[['package-a', 'active', 'queued'], true]]);
|
||||
});
|
||||
|
||||
it("includes package selection state in memo equality", () => {
|
||||
const model = withRuntime(createInput());
|
||||
const row = model.packageRows[0];
|
||||
const base = {
|
||||
actions: createActions(),
|
||||
columnOrder: model.columnOrder,
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: model.gridTemplate,
|
||||
packageSpeedBps: 0,
|
||||
row,
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 1
|
||||
};
|
||||
|
||||
expect(arePackageCardPropsEqual(base, { ...base, selectedIds: new Set([row.package.id]), selectedVersion: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates visible item rows when provider, error or timestamp presentation changes", () => {
|
||||
const model = withRuntime(createInput());
|
||||
const base = {
|
||||
actions: createActions(),
|
||||
columnOrder: model.columnOrder,
|
||||
gridTemplate: model.gridTemplate,
|
||||
item: model.packageRows[0].items[0],
|
||||
selected: false
|
||||
};
|
||||
|
||||
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, providerLabel: "Debrid-Link" } })).toBe(false);
|
||||
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, lastError: "Neuer Fehler" } })).toBe(false);
|
||||
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, updatedAt: base.item.updatedAt + 1 } })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not collapse a package on shift selection", () => {
|
||||
const calls: string[] = [];
|
||||
const model = withRuntime(createInput());
|
||||
const row = model.packageRows[0];
|
||||
const component = PackageCardContent({
|
||||
actions: createActions({
|
||||
onToggleSelection: () => calls.push("select"),
|
||||
onTogglePackageCollapse: () => calls.push("collapse")
|
||||
}),
|
||||
columnOrder: model.columnOrder,
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: model.gridTemplate,
|
||||
packageSpeedBps: 0,
|
||||
row,
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 1
|
||||
});
|
||||
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
|
||||
|
||||
packageRow.props.onClick({ button: 0, ctrlKey: false, metaKey: false, shiftKey: true, target: {}, currentTarget: { contains: () => true } });
|
||||
|
||||
expect(calls).toEqual(["select"]);
|
||||
});
|
||||
|
||||
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
|
||||
const commits: string[] = [];
|
||||
const model = withRuntime(createInput());
|
||||
const row = model.packageRows[0];
|
||||
const component = PackageCardContent({
|
||||
actions: createActions({ onCommitPackageRename: (id, value) => commits.push(`${id}:${value}`) }),
|
||||
columnOrder: model.columnOrder,
|
||||
editing: true,
|
||||
editingName: "Neuer Name",
|
||||
gridTemplate: model.gridTemplate,
|
||||
packageSpeedBps: 0,
|
||||
row,
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 1
|
||||
});
|
||||
const input = findElement(component, (element) => element.type === "input" && element.props.className === "downloads-rename-input");
|
||||
|
||||
input.props.onKeyDown({ key: "Enter", preventDefault: () => {}, currentTarget: { blur: () => {} } });
|
||||
input.props.onBlur();
|
||||
|
||||
expect(commits).toEqual(["package-a:Neuer Name"]);
|
||||
});
|
||||
|
||||
it("keeps package selection and activation as separate controls and sends context coordinates", () => {
|
||||
const calls: Array<unknown> = [];
|
||||
const model = withRuntime(createInput());
|
||||
const row = model.packageRows[0];
|
||||
const component = PackageCardContent({
|
||||
actions: createActions({
|
||||
onToggleSelection: (id) => calls.push(["select", id]),
|
||||
onTogglePackage: (id) => calls.push(["toggle", id]),
|
||||
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
||||
}),
|
||||
columnOrder: model.columnOrder,
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: model.gridTemplate,
|
||||
packageSpeedBps: 0,
|
||||
row,
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 1
|
||||
});
|
||||
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
|
||||
const activation = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren");
|
||||
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
|
||||
|
||||
selection.props.onChange();
|
||||
activation.props.onChange();
|
||||
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
|
||||
|
||||
expect(calls).toEqual([["select", "package-a"], ["toggle", "package-a"], ["context", "package-a", 30, 50]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { win32 } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HistoryEntry } from "../src/shared/types";
|
||||
import {
|
||||
revealHistoryEntry,
|
||||
type HistoryRevealDependencies
|
||||
} from "../src/main/history-reveal";
|
||||
|
||||
function historyEntry(overrides: Partial<HistoryEntry> = {}): HistoryEntry {
|
||||
return {
|
||||
id: "known-id",
|
||||
name: "Paket",
|
||||
totalBytes: 1,
|
||||
downloadedBytes: 1,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid",
|
||||
completedAt: 1,
|
||||
durationSeconds: 1,
|
||||
status: "completed",
|
||||
outputDir: "C:\\Downloads\\Paket",
|
||||
urls: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function dependencies(overrides: Partial<HistoryRevealDependencies> = {}): HistoryRevealDependencies {
|
||||
return {
|
||||
loadHistory: () => [historyEntry()],
|
||||
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
||||
openPath: vi.fn(async () => ""),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("revealHistoryEntry", () => {
|
||||
it.each(["", " ", " padded", "padded ", "x".repeat(257)])("rejects malformed entry id %j before loading history", async (entryId) => {
|
||||
const loadHistory = vi.fn(() => [historyEntry()]);
|
||||
const deps = dependencies({ loadHistory });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId }, deps)).resolves.toEqual({ ok: false, reason: "entry-not-found" });
|
||||
expect(loadHistory).not.toHaveBeenCalled();
|
||||
expect(deps.stat).not.toHaveBeenCalled();
|
||||
expect(deps.openPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a known case-sensitive id to the authoritative directory and opens it exactly once", async () => {
|
||||
const deps = dependencies();
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
|
||||
expect(deps.stat).toHaveBeenCalledTimes(1);
|
||||
expect(deps.stat).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||
expect(deps.openPath).toHaveBeenCalledTimes(1);
|
||||
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||
});
|
||||
|
||||
it("ignores every renderer-supplied field except entryId", async () => {
|
||||
const deps = dependencies();
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id", outputDir: "C:\\Angriff" } as never, deps)).resolves.toEqual({ ok: true });
|
||||
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||
});
|
||||
|
||||
it("rejects unknown and differently cased ids before path inspection", async () => {
|
||||
const deps = dependencies();
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "KNOWN-ID" }, deps)).resolves.toEqual({ ok: false, reason: "entry-not-found" });
|
||||
expect(deps.stat).not.toHaveBeenCalled();
|
||||
expect(deps.openPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"relative\\folder",
|
||||
"C:relative\\folder",
|
||||
"\\current-drive-rooted",
|
||||
"/current-drive-rooted",
|
||||
"\\\\server",
|
||||
"\\\\server\\",
|
||||
"\\\\..\\share\\folder",
|
||||
"\\\\server\\.\\folder",
|
||||
"\\\\server\\..\\folder",
|
||||
"\\\\.\\C:\\folder",
|
||||
"\\\\?\\C:\\folder",
|
||||
"\\\\?\\GLOBALROOT\\Device\\HarddiskVolumeShadowCopy1",
|
||||
"C:\\folder:stream",
|
||||
"\\\\server\\share\\folder:stream",
|
||||
"C:\\folder\0bad",
|
||||
"C:\\folder\nbad",
|
||||
"C:\\bad?name"
|
||||
])("rejects unsafe or non-absolute Windows path %s", async (outputDir) => {
|
||||
const deps = dependencies({ loadHistory: () => [historyEntry({ outputDir })] });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: false, reason: "invalid-output-dir" });
|
||||
expect(deps.stat).not.toHaveBeenCalled();
|
||||
expect(deps.openPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["C:/Media/Folder//Child", win32.normalize("C:/Media/Folder//Child")],
|
||||
["\\\\server\\share\\Folder\\Child\\", win32.normalize("\\\\server\\share\\Folder\\Child\\")]
|
||||
])("normalizes valid drive and UNC paths before stat and openPath", async (outputDir, normalized) => {
|
||||
const deps = dependencies({ loadHistory: () => [historyEntry({ outputDir })] });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
|
||||
expect(deps.stat).toHaveBeenCalledWith(normalized);
|
||||
expect(deps.openPath).toHaveBeenCalledWith(normalized);
|
||||
});
|
||||
|
||||
it("maps a missing directory and a file target without calling openPath", async () => {
|
||||
const missing = dependencies({ stat: vi.fn(async () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); }) });
|
||||
const file = dependencies({ stat: vi.fn(async () => ({ isDirectory: () => false })) });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, missing)).resolves.toEqual({ ok: false, reason: "output-dir-missing" });
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, file)).resolves.toEqual({ ok: false, reason: "output-dir-not-directory" });
|
||||
expect(missing.openPath).not.toHaveBeenCalled();
|
||||
expect(file.openPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
Object.assign(new Error("denied"), { code: "EACCES" }),
|
||||
new Error("network unavailable")
|
||||
])("maps non-missing stat failures to open-failed without calling openPath", async (error) => {
|
||||
const deps = dependencies({ stat: vi.fn(async () => { throw error; }) });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: false, reason: "open-failed" });
|
||||
expect(deps.openPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts followed junction or symlink stats when the resolved target is a directory", async () => {
|
||||
const deps = dependencies({ stat: vi.fn(async () => ({ isDirectory: () => true, isSymbolicLink: () => true })) });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
|
||||
expect(deps.openPath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats a non-empty shell result and a rejected shell promise as open failures", async () => {
|
||||
const returnedError = dependencies({ openPath: vi.fn(async () => "Zugriff verweigert") });
|
||||
const rejected = dependencies({ openPath: vi.fn(async () => { throw new Error("shell failed"); }) });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, returnedError)).resolves.toEqual({ ok: false, reason: "open-failed" });
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, rejected)).resolves.toEqual({ ok: false, reason: "open-failed" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HistoryEntry } from "../src/shared/types";
|
||||
import {
|
||||
buildHistoryViewModel,
|
||||
deriveHistoryHoster,
|
||||
deriveHistoryStartAt,
|
||||
filterHistoryRows,
|
||||
pruneHistoryIds,
|
||||
selectVisibleHistoryIds,
|
||||
type HistoryFilter,
|
||||
type HistoryViewEntry
|
||||
} from "../src/renderer/views/history/history-model";
|
||||
import {
|
||||
HistoryContent,
|
||||
HistoryToolbar,
|
||||
HistoryView,
|
||||
type HistoryViewActions
|
||||
} from "../src/renderer/views/history/HistoryView";
|
||||
import { createVisualFixture } from "./visual/fixtures";
|
||||
import { createVisualElectronApi } from "./visual/mock-electron-api";
|
||||
|
||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) => visitElements(child, visit));
|
||||
return;
|
||||
}
|
||||
if (!isValidElement(node)) {
|
||||
return;
|
||||
}
|
||||
visit(node);
|
||||
visitElements(node.props.children, visit);
|
||||
}
|
||||
|
||||
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
|
||||
let result: ReactElement | null = null;
|
||||
visitElements(node, (element) => {
|
||||
if (!result && predicate(element)) {
|
||||
result = element;
|
||||
}
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Element not found");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findButton(node: ReactNode, label: string): ReactElement {
|
||||
return findElement(node, (element) => element.type === "button" && element.props.children === label);
|
||||
}
|
||||
|
||||
function createActions(overrides: Partial<HistoryViewActions> = {}): HistoryViewActions {
|
||||
return {
|
||||
onFilterChange: () => {},
|
||||
onQueryChange: () => {},
|
||||
onToggleSelection: () => {},
|
||||
onToggleSelectAll: () => {},
|
||||
onToggleExpansion: () => {},
|
||||
onRestore: () => {},
|
||||
onReveal: () => {},
|
||||
onRemove: () => {},
|
||||
onClearSelection: () => {},
|
||||
onClearHistory: () => {},
|
||||
onContextMenu: () => {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||
const todayStart = new Date(2026, 7, 10, 0, 0, 0, 0).getTime();
|
||||
const weekStart = new Date(2026, 7, 4, 0, 0, 0, 0).getTime();
|
||||
|
||||
function entry(overrides: Partial<HistoryViewEntry> & Pick<HistoryViewEntry, "id" | "name">): HistoryViewEntry {
|
||||
return {
|
||||
totalBytes: 2_000_000_000,
|
||||
downloadedBytes: 1_500_000_000,
|
||||
fileCount: 2,
|
||||
provider: "realdebrid",
|
||||
completedAt: todayStart + 60_000,
|
||||
durationSeconds: 60,
|
||||
status: "completed",
|
||||
outputDir: `C:\\Downloads\\${overrides.name}`,
|
||||
urls: ["https://rapidgator.net/file/test"],
|
||||
...overrides,
|
||||
id: overrides.id,
|
||||
name: overrides.name
|
||||
};
|
||||
}
|
||||
|
||||
const entries: HistoryViewEntry[] = [
|
||||
entry({ id: "today", name: "Heute Paket", completedAt: todayStart + 1 }),
|
||||
entry({ id: "week-edge", name: "Wochenanfang", completedAt: weekStart }),
|
||||
entry({ id: "week", name: "Wochen Paket", completedAt: todayStart - 1, status: "deleted", provider: "debridlink", urls: ["https://ddownload.com/a"] }),
|
||||
entry({ id: "older", name: "Altes Paket", completedAt: weekStart - 1, status: "failed", provider: null, outputDir: "D:\\Archiv\\Alt", urls: ["https://sub.example.test/a"] })
|
||||
];
|
||||
|
||||
describe("history model", () => {
|
||||
it("separates today, previous six calendar days, older and status filters at exact boundaries", () => {
|
||||
const expected: Record<HistoryFilter, string[]> = {
|
||||
all: ["today", "week-edge", "week", "older"],
|
||||
today: ["today"],
|
||||
week: ["week-edge", "week"],
|
||||
older: ["older"],
|
||||
completed: ["today", "week-edge"],
|
||||
deleted: ["week"],
|
||||
failed: ["older"]
|
||||
};
|
||||
|
||||
for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) {
|
||||
expect(filterHistoryRows(entries, filter, "", now).map((row) => row.id)).toEqual(ids);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
|
||||
const springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime();
|
||||
const springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime();
|
||||
const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime();
|
||||
const autumnBoundary = new Date(2026, 9, 20, 0, 0, 0, 0).getTime();
|
||||
|
||||
expect(filterHistoryRows([
|
||||
entry({ id: "spring-before", name: "Spring before", completedAt: springBoundary - 30 * 60 * 1000 }),
|
||||
entry({ id: "spring-boundary", name: "Spring boundary", completedAt: springBoundary })
|
||||
], "week", "", springNow).map((row) => row.id)).toEqual(["spring-boundary"]);
|
||||
expect(filterHistoryRows([
|
||||
entry({ id: "autumn-boundary", name: "Autumn boundary", completedAt: autumnBoundary + 30 * 60 * 1000 })
|
||||
], "week", "", autumnNow).map((row) => row.id)).toEqual(["autumn-boundary"]);
|
||||
});
|
||||
|
||||
it("bounds today to the exact local calendar day and excludes future timestamps", () => {
|
||||
const tomorrowStart = new Date(2026, 7, 11, 0, 0, 0, 0).getTime();
|
||||
const temporalEntries = [
|
||||
entry({ id: "today-last", name: "Today last", completedAt: tomorrowStart - 1 }),
|
||||
entry({ id: "tomorrow", name: "Tomorrow", completedAt: tomorrowStart }),
|
||||
entry({ id: "future", name: "Future", completedAt: tomorrowStart + 86_400_000 })
|
||||
];
|
||||
|
||||
expect(filterHistoryRows(temporalEntries, "today", "", now).map((row) => row.id)).toEqual(["today-last"]);
|
||||
});
|
||||
|
||||
it("searches name, path, hoster, provider and URLs without changing newest-first input order", () => {
|
||||
const searchable = [
|
||||
entry({ id: "new", name: "Neu", completedAt: now, provider: "debridlink", outputDir: "C:\\Filme\\Staffel", urls: ["https://rapidgator.net/file/needle"] }),
|
||||
entry({ id: "old", name: "Älter", completedAt: now - 1, provider: "realdebrid", outputDir: "D:\\Archiv", urls: ["https://ddownload.com/archive"] })
|
||||
];
|
||||
|
||||
expect(filterHistoryRows(searchable, "all", "neu", now).map((row) => row.id)).toEqual(["new"]);
|
||||
expect(filterHistoryRows(searchable, "all", "staffel", now).map((row) => row.id)).toEqual(["new"]);
|
||||
expect(filterHistoryRows(searchable, "all", "rapidgator.net", now).map((row) => row.id)).toEqual(["new"]);
|
||||
expect(filterHistoryRows(searchable, "all", "Debrid-Link", now).map((row) => row.id)).toEqual(["new"]);
|
||||
expect(filterHistoryRows(searchable, "all", "needle", now).map((row) => row.id)).toEqual(["new"]);
|
||||
expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]);
|
||||
});
|
||||
|
||||
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
|
||||
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
|
||||
expect(deriveHistoryHoster([])).toBe("—");
|
||||
expect(deriveHistoryHoster(undefined)).toBe("—");
|
||||
expect(deriveHistoryStartAt(entry({ id: "start", name: "Start", completedAt: 20_000, durationSeconds: 3 }))).toBe(17_000);
|
||||
expect(deriveHistoryStartAt(entry({ id: "clamped", name: "Clamp", completedAt: 2_000, durationSeconds: 3 }))).toBe(0);
|
||||
|
||||
const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0];
|
||||
expect(row.hoster).toBe("—");
|
||||
expect(row.providerLabel).toBe("Real-Debrid");
|
||||
});
|
||||
|
||||
it("prunes removed ids and preserves the original set instance when every id survives", () => {
|
||||
const stable = new Set(["today", "week"]);
|
||||
expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable);
|
||||
|
||||
const pruned = pruneHistoryIds(new Set(["today", "removed"]), ["today", "week"]);
|
||||
expect([...pruned]).toEqual(["today"]);
|
||||
});
|
||||
|
||||
it("builds Ctrl+A selection from only the currently visible filtered row ids", () => {
|
||||
const visibleIds = filterHistoryRows(entries, "week", "Wochen", now).map((row) => row.id);
|
||||
expect([...selectVisibleHistoryIds(visibleIds)]).toEqual(["week-edge", "week"]);
|
||||
});
|
||||
|
||||
it("removes hidden selected ids from the filtered view model and every toolbar action", () => {
|
||||
const model = buildHistoryViewModel(entries, "deleted", "", ["today", "week"], [], false, "", now);
|
||||
const calls: Array<unknown> = [];
|
||||
const toolbar = HistoryToolbar({
|
||||
model,
|
||||
actions: createActions({
|
||||
onRestore: (ids) => calls.push(["restore", ids]),
|
||||
onReveal: (id) => calls.push(["reveal", id]),
|
||||
onRemove: (ids) => calls.push(["remove", ids])
|
||||
})
|
||||
});
|
||||
|
||||
expect(model.rows.map((row) => row.id)).toEqual(["week"]);
|
||||
expect(model.selectedIds).toEqual(["week"]);
|
||||
findButton(toolbar, "Erneut hinzufügen").props.onClick();
|
||||
findButton(toolbar, "Im Ordner zeigen").props.onClick();
|
||||
findButton(toolbar, "Entfernen").props.onClick();
|
||||
expect(calls).toEqual([
|
||||
["restore", ["week"]],
|
||||
["reveal", "week"],
|
||||
["remove", ["week"]]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryView", () => {
|
||||
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now)}
|
||||
/>
|
||||
);
|
||||
const css = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
|
||||
const tableStart = html.indexOf("history-table");
|
||||
const headerStart = html.indexOf("history-table-header");
|
||||
const bodyStart = html.indexOf("data-visual-region=\"history-table-body\"");
|
||||
|
||||
expect(tableStart).toBeGreaterThan(-1);
|
||||
expect(headerStart).toBeGreaterThan(tableStart);
|
||||
expect(bodyStart).toBeGreaterThan(headerStart);
|
||||
expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*auto;/s);
|
||||
expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;/s);
|
||||
expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*visible;/s);
|
||||
expect(css).not.toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s);
|
||||
});
|
||||
|
||||
it("keeps every real AppShell history surface non-selectable while allowing text selection only for detail values", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.history-sidebar,\s*\.history-workspace-toolbar,\s*\.history-content,\s*\.history-pagination\s*\{[^}]*user-select:\s*none;/s);
|
||||
expect(css).toMatch(/\.history-copyable\s*\{[^}]*user-select:\s*text;/s);
|
||||
expect(css).toMatch(/\.history-workspace-toolbar \.ui-toolbar-search-input\s*\{[^}]*user-select:\s*text;/s);
|
||||
expect(css).not.toMatch(/(^|\n)\.history-toolbar(?:\s|,|\{)/);
|
||||
expect(css).not.toMatch(/(^|\n)\.history-detail-grid(?:\s|>|\.|\{)/);
|
||||
});
|
||||
|
||||
it("keeps loading, empty, filtered-empty and error states inside the same table body", () => {
|
||||
const states = [
|
||||
[buildHistoryViewModel([], "all", "", [], [], true, "", now), "Verlauf wird geladen"],
|
||||
[buildHistoryViewModel([], "all", "", [], [], false, "", now), "Noch kein Verlauf"],
|
||||
[buildHistoryViewModel([entry({ id: "done", name: "Fertig" })], "failed", "", [], [], false, "", now), "Keine passenden Einträge"],
|
||||
[buildHistoryViewModel([], "all", "", [], [], false, "Verlauf konnte nicht geladen werden", now), "Verlauf konnte nicht geladen werden"]
|
||||
] as const;
|
||||
|
||||
for (const [model, label] of states) {
|
||||
const html = renderToStaticMarkup(<HistoryView actions={createActions()} model={model} />);
|
||||
expect(html.indexOf(label)).toBeGreaterThan(html.indexOf("data-visual-region=\"history-table-body\""));
|
||||
expect(html).toContain("data-visual-region=\"history-pagination\"");
|
||||
expect(html).toContain("0 von 0");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the exact compact headers, semantic statuses and no operative download controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel(entries, "all", "", [], [], false, "", now)}
|
||||
/>
|
||||
);
|
||||
const headerStart = html.indexOf("history-table-header-row");
|
||||
const headerEnd = html.indexOf("data-visual-region=\"history-table-body\"");
|
||||
const headerMarkup = html.slice(headerStart, headerEnd);
|
||||
const headers = ["Paket / Datei", "Status", "Größe", "Hoster", "Gestartet", "Beendet", "Aktion"];
|
||||
let previous = -1;
|
||||
for (const header of headers) {
|
||||
const index = headerMarkup.indexOf(`>${header}<`);
|
||||
expect(index).toBeGreaterThan(previous);
|
||||
previous = index;
|
||||
}
|
||||
expect(html).toContain("history-status-completed");
|
||||
expect(html).toContain("history-status-deleted");
|
||||
expect(html).toContain("history-status-failed");
|
||||
expect(html).toContain("Abgeschlossen");
|
||||
expect(html).toContain("Gelöscht");
|
||||
expect(html).toContain("Fehlgeschlagen");
|
||||
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
|
||||
});
|
||||
|
||||
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
for (const marker of ["history-sidebar", "history-toolbar", "history-table-body", "history-pagination"]) {
|
||||
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
expect(html.match(/data-history-row-id=/g)).toHaveLength(2);
|
||||
expect(html).not.toContain("history-detail-row");
|
||||
expect(html).toContain("1–2 von 2");
|
||||
});
|
||||
|
||||
it("dispatches selection, expansion, select-all and context coordinates with exact visible ids", () => {
|
||||
const calls: Array<unknown> = [];
|
||||
const actions = createActions({
|
||||
onToggleSelection: (id) => calls.push(["select", id]),
|
||||
onToggleSelectAll: (ids) => calls.push(["all", ids]),
|
||||
onToggleExpansion: (id) => calls.push(["expand", id]),
|
||||
onContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
||||
});
|
||||
const model = buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now);
|
||||
const content = HistoryContent({ actions, model });
|
||||
|
||||
const selectAll = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Alle sichtbaren Einträge auswählen");
|
||||
selectAll.props.onChange();
|
||||
const rowCheckbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Heute Paket auswählen");
|
||||
rowCheckbox.props.onChange();
|
||||
findElement(content, (element) => element.type === "button" && element.props["aria-label"] === "Details anzeigen").props.onClick({ stopPropagation: () => {} });
|
||||
const row = findElement(content, (element) => element.props["data-history-row-id"] === "today");
|
||||
row.props.onContextMenu({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
clientX: 144,
|
||||
clientY: 288,
|
||||
currentTarget: { querySelector: () => null }
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
["all", ["today", "week-edge"]],
|
||||
["select", "today"],
|
||||
["expand", "today"],
|
||||
["context", "today", 144, 288]
|
||||
]);
|
||||
});
|
||||
|
||||
it("focuses the matching row action before opening a genuine row context menu", () => {
|
||||
const calls: Array<unknown> = [];
|
||||
const focusCalls: Array<unknown> = [];
|
||||
const content = HistoryContent({
|
||||
actions: createActions({ onContextMenu: (id, x, y) => calls.push([id, x, y]) }),
|
||||
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
|
||||
});
|
||||
const row = findElement(content, (element) => element.props["data-history-row-id"] === "today");
|
||||
|
||||
row.props.onContextMenu({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
clientX: 21,
|
||||
clientY: 34,
|
||||
currentTarget: {
|
||||
querySelector: () => ({ focus: (options: unknown) => focusCalls.push(options) })
|
||||
}
|
||||
});
|
||||
|
||||
expect(focusCalls).toEqual([{ preventScroll: true }]);
|
||||
expect(calls).toEqual([["today", 21, 34]]);
|
||||
});
|
||||
|
||||
it("enables reveal only for exactly one selected row and sends selection actions as ids", () => {
|
||||
const selected = buildHistoryViewModel(entries, "all", "", ["today"], [], false, "", now);
|
||||
const multiple = buildHistoryViewModel(entries, "all", "", ["today", "week"], [], false, "", now);
|
||||
const calls: Array<unknown> = [];
|
||||
const actions = createActions({
|
||||
onRestore: (ids) => calls.push(["restore", ids]),
|
||||
onReveal: (id) => calls.push(["reveal", id]),
|
||||
onRemove: (ids) => calls.push(["remove", ids]),
|
||||
onClearSelection: () => calls.push(["clear"])
|
||||
});
|
||||
const singleToolbar = HistoryToolbar({ actions, model: selected });
|
||||
const multiToolbar = HistoryToolbar({ actions, model: multiple });
|
||||
|
||||
expect(findButton(singleToolbar, "Im Ordner zeigen").props.disabled).toBe(false);
|
||||
expect(findButton(multiToolbar, "Im Ordner zeigen").props.disabled).toBe(true);
|
||||
findButton(singleToolbar, "Erneut hinzufügen").props.onClick();
|
||||
findButton(singleToolbar, "Im Ordner zeigen").props.onClick();
|
||||
findButton(singleToolbar, "Entfernen").props.onClick();
|
||||
findButton(singleToolbar, "Auswahl löschen").props.onClick();
|
||||
|
||||
expect(calls).toEqual([
|
||||
["restore", ["today"]],
|
||||
["reveal", "today"],
|
||||
["remove", ["today"]],
|
||||
["clear"]
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders expanded paths and URLs as copyable details without changing the 48px main-row contract", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("history-detail-row");
|
||||
expect(html).toContain("history-copyable");
|
||||
expect(html).toContain("C:\\Downloads\\Heute Paket");
|
||||
expect(html).toContain("https://rapidgator.net/file/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visual history states", () => {
|
||||
it("re-arms the real App mounted gate before every StrictMode lifecycle setup can start async work", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
const firstRequest = source.indexOf("window.rd.getVersion()");
|
||||
const effectStart = source.lastIndexOf("useEffect(() => {", firstRequest);
|
||||
const cleanup = source.indexOf("mountedRef.current = false", firstRequest);
|
||||
const setup = source.indexOf("mountedRef.current = true", effectStart);
|
||||
|
||||
expect(effectStart).toBeGreaterThan(-1);
|
||||
expect(setup).toBeGreaterThan(effectStart);
|
||||
expect(setup).toBeLessThan(firstRequest);
|
||||
expect(cleanup).toBeGreaterThan(firstRequest);
|
||||
});
|
||||
|
||||
it("keeps bootstrap deterministic before exposing loading and error responses to the opened history view", async () => {
|
||||
const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading");
|
||||
await expect(loadingApi.getHistory()).resolves.toHaveLength(2);
|
||||
const pending = loadingApi.getHistory();
|
||||
let settled = false;
|
||||
void pending.finally(() => { settled = true; });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
const errorApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=error");
|
||||
await expect(errorApi.getHistory()).resolves.toHaveLength(2);
|
||||
await expect(errorApi.getHistory()).rejects.toThrow("Visual history load failed");
|
||||
});
|
||||
});
|
||||
|
||||
const productionEntry: HistoryEntry = {
|
||||
...entry({ id: "production", name: "Produktiv" }),
|
||||
status: "completed"
|
||||
};
|
||||
void productionEntry;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OverlayHost } from "../src/renderer/shell/OverlayHost";
|
||||
import { UpdateExperience } from "../src/renderer/shell/UpdateExperience";
|
||||
|
||||
describe("OverlayHost", () => {
|
||||
it("renders every desktop overlay slot exactly once", () => {
|
||||
const slots = {
|
||||
confirm: <span>confirm-slot</span>,
|
||||
onlineBackup: <span>backup-slot</span>,
|
||||
diagnostics: <span>diagnostics-slot</span>,
|
||||
deleteConfirmation: <span>delete-slot</span>,
|
||||
conflict: <span>conflict-slot</span>,
|
||||
accountCreate: <span>account-create-slot</span>,
|
||||
accountEdit: <span>account-edit-slot</span>,
|
||||
keyStats: <span>key-stats-slot</span>,
|
||||
linkPopup: <span>link-popup-slot</span>,
|
||||
update: <span>update-slot</span>,
|
||||
toast: <span>toast-slot</span>,
|
||||
accountContextMenu: <span>account-menu-slot</span>,
|
||||
downloadContextMenu: <span>download-menu-slot</span>,
|
||||
columnContextMenu: <span>column-menu-slot</span>,
|
||||
historyContextMenu: <span>history-menu-slot</span>,
|
||||
dropOverlay: <span>drop-slot</span>
|
||||
};
|
||||
const html = renderToStaticMarkup(<OverlayHost {...slots} />);
|
||||
|
||||
expect(html).toContain("id=\"md-overlay-host\"");
|
||||
for (const value of Object.values(slots)) {
|
||||
const label = String(value.props.children);
|
||||
expect(html.split(label)).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render placeholders for empty slots", () => {
|
||||
const html = renderToStaticMarkup(<OverlayHost toast={<span>sichtbar</span>} />);
|
||||
|
||||
expect(html).toContain("sichtbar");
|
||||
expect(html).not.toContain("data-overlay-slot");
|
||||
});
|
||||
|
||||
it("hosts the update surface through the shared dialog without duplicating the trigger", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OverlayHost
|
||||
update={(
|
||||
<UpdateExperience
|
||||
available
|
||||
currentVersion="v2.0.12"
|
||||
latestTag="v9.9.9"
|
||||
onClose={() => {}}
|
||||
onInstall={() => {}}
|
||||
onLater={() => {}}
|
||||
onOpen={() => {}}
|
||||
open
|
||||
progress={0}
|
||||
releaseNotes="Changes"
|
||||
renderTrigger={false}
|
||||
state="prompt"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("md-dialog-size-update");
|
||||
expect(html).not.toContain("aria-label=\"Update verfügbar\"");
|
||||
expect(html.match(/role=\"dialog\"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("defines a stacking-context-safe menu, tooltip, toast and modal layer order", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/--md-layer-menu:\s*600/);
|
||||
expect(css).toMatch(/--md-layer-tooltip:\s*700/);
|
||||
expect(css).toMatch(/--md-layer-toast:\s*800/);
|
||||
expect(css).toMatch(/--md-layer-modal:\s*1000/);
|
||||
expect(css).toMatch(/\.md-context-menu\s*\{[^}]*z-index:\s*var\(--md-layer-menu\)/s);
|
||||
expect(css).toMatch(/\.md-update-tooltip\s*\{[^}]*z-index:\s*var\(--md-layer-tooltip\)/s);
|
||||
expect(css).toMatch(/\.md-toast\s*\{[^}]*z-index:\s*var\(--md-layer-toast\)/s);
|
||||
expect(css).toMatch(/\.md-dialog-backdrop\s*\{[^}]*z-index:\s*var\(--md-layer-modal\)/s);
|
||||
expect(css).toMatch(/\.md-drop-overlay\s*\{[^}]*pointer-events:\s*none/s);
|
||||
expect(css).toMatch(/\.md-overlay-host \.md-dialog-backdrop\s*\{[^}]*z-index:\s*var\(--md-layer-modal\)/s);
|
||||
expect(css).toMatch(/\.md-overlay-host \.md-context-menu\s*\{[^}]*z-index:\s*var\(--md-layer-menu\)/s);
|
||||
expect(css).toMatch(/\.md-overlay-host \.md-toast\s*\{[^}]*z-index:\s*var\(--md-layer-toast\)/s);
|
||||
expect(css).toMatch(/\.md-overlay-host \.md-dialog\s*\{[^}]*background:\s*var\(--ui-surface\)/s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ErrorBoundary } from "../src/renderer/error-boundary";
|
||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||
import * as focusModule from "../src/renderer/ui/focus";
|
||||
import * as shellModel from "../src/renderer/shell/shell-model";
|
||||
|
||||
describe("responsive shell mode", () => {
|
||||
it("selects full, compact and minimum modes at every boundary", () => {
|
||||
const getResponsiveShellMode = (shellModel as unknown as {
|
||||
getResponsiveShellMode?: (width: number) => "full" | "compact" | "minimum";
|
||||
}).getResponsiveShellMode;
|
||||
|
||||
expect(getResponsiveShellMode).toBeTypeOf("function");
|
||||
expect([
|
||||
getResponsiveShellMode!(2560),
|
||||
getResponsiveShellMode!(1920),
|
||||
getResponsiveShellMode!(1367),
|
||||
getResponsiveShellMode!(1366),
|
||||
getResponsiveShellMode!(1121),
|
||||
getResponsiveShellMode!(1120)
|
||||
]).toEqual(["full", "full", "full", "compact", "compact", "minimum"]);
|
||||
});
|
||||
|
||||
it("wires the derived mode into stable shell markup and responsive CSS", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell
|
||||
activeView="downloads"
|
||||
contextInfo={null}
|
||||
footer={null}
|
||||
headerActions={null}
|
||||
onSidebarCollapsedChange={() => {}}
|
||||
onViewChange={() => {}}
|
||||
sidebar={<div>Filter</div>}
|
||||
sidebarCollapsed={false}
|
||||
sidebarStatus={null}
|
||||
toolbar={null}
|
||||
>
|
||||
<div>Downloads</div>
|
||||
</AppShell>
|
||||
);
|
||||
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
expect(html).toContain('data-responsive-mode="full"');
|
||||
expect(html).toContain("md-shell is-full");
|
||||
expect(css).toContain(".md-shell.is-compact");
|
||||
expect(css).toContain(".md-shell.is-minimum");
|
||||
expect(css).toMatch(/grid-template-columns:\s*56px minmax\(0,\s*1fr\)/);
|
||||
});
|
||||
|
||||
it("keeps responsive rail content hidden behind a visible expand control", () => {
|
||||
const shellSource = readFileSync(new URL("../src/renderer/shell/AppShell.tsx", import.meta.url), "utf8");
|
||||
const sidebarSource = readFileSync(new URL("../src/renderer/shell/AppSidebar.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
expect(shellSource).toContain("responsiveRail={responsiveSidebarCollapsed}");
|
||||
expect(sidebarSource).toContain('is-responsive-rail');
|
||||
expect(css).toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*width:\s*32px;[^}]*height:\s*32px;[^}]*opacity:\s*1;/s);
|
||||
expect(css).not.toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-scroll\s*\{[^}]*visibility:\s*visible;/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus restoration", () => {
|
||||
it("restores the preferred connected target after the closing render", () => {
|
||||
const restoreFocus = (focusModule as unknown as {
|
||||
restoreFocus?: (
|
||||
preferredTarget: HTMLElement | null,
|
||||
fallbackTarget: HTMLElement | null,
|
||||
schedule: (callback: () => void) => void
|
||||
) => void;
|
||||
}).restoreFocus;
|
||||
let scheduled: (() => void) | null = null;
|
||||
let preferredFocusCount = 0;
|
||||
let fallbackFocusCount = 0;
|
||||
const preferredTarget = {
|
||||
isConnected: true,
|
||||
focus: () => {
|
||||
preferredFocusCount += 1;
|
||||
}
|
||||
} as HTMLElement;
|
||||
const fallbackTarget = {
|
||||
isConnected: true,
|
||||
focus: () => {
|
||||
fallbackFocusCount += 1;
|
||||
}
|
||||
} as HTMLElement;
|
||||
|
||||
expect(restoreFocus).toBeTypeOf("function");
|
||||
restoreFocus!(preferredTarget, fallbackTarget, (callback) => {
|
||||
scheduled = callback;
|
||||
});
|
||||
|
||||
expect(preferredFocusCount).toBe(0);
|
||||
expect(fallbackFocusCount).toBe(0);
|
||||
(scheduled as (() => void) | null)?.();
|
||||
expect(preferredFocusCount).toBe(1);
|
||||
expect(fallbackFocusCount).toBe(0);
|
||||
});
|
||||
|
||||
it("uses only a connected fallback when the preferred target was removed", () => {
|
||||
const restoreFocus = (focusModule as unknown as {
|
||||
restoreFocus?: (
|
||||
preferredTarget: HTMLElement | null,
|
||||
fallbackTarget: HTMLElement | null,
|
||||
schedule: (callback: () => void) => void
|
||||
) => void;
|
||||
}).restoreFocus;
|
||||
let scheduled: (() => void) | null = null;
|
||||
let preferredConnected = true;
|
||||
let preferredFocusCount = 0;
|
||||
let fallbackFocusCount = 0;
|
||||
const preferredTarget = {
|
||||
get isConnected() {
|
||||
return preferredConnected;
|
||||
},
|
||||
focus: () => {
|
||||
preferredFocusCount += 1;
|
||||
}
|
||||
} as HTMLElement;
|
||||
const fallbackTarget = {
|
||||
isConnected: true,
|
||||
focus: () => {
|
||||
fallbackFocusCount += 1;
|
||||
}
|
||||
} as HTMLElement;
|
||||
|
||||
restoreFocus!(preferredTarget, fallbackTarget, (callback) => {
|
||||
scheduled = callback;
|
||||
});
|
||||
preferredConnected = false;
|
||||
(scheduled as (() => void) | null)?.();
|
||||
|
||||
expect(preferredFocusCount).toBe(0);
|
||||
expect(fallbackFocusCount).toBe(1);
|
||||
|
||||
restoreFocus!(null, { ...fallbackTarget, isConnected: false } as HTMLElement, (callback) => callback());
|
||||
expect(fallbackFocusCount).toBe(1);
|
||||
});
|
||||
|
||||
it("is consumed by dialogs, context menus and the avatar menu", () => {
|
||||
const consumers = [
|
||||
["../src/renderer/ui/Dialog.tsx", 'from "./focus"'],
|
||||
["../src/renderer/ui/ContextMenu.tsx", 'from "./focus"'],
|
||||
["../src/renderer/shell/AvatarMenu.tsx", 'from "../ui/focus"']
|
||||
] as const;
|
||||
|
||||
for (const [path, importPath] of consumers) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
expect(source).toContain("restoreFocus");
|
||||
expect(source).toContain(importPath);
|
||||
expect(source).toMatch(/restoreFocus\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderer error boundary", () => {
|
||||
it("renders a tokenized accessible recovery surface", () => {
|
||||
const boundary = new ErrorBoundary({ children: "content" });
|
||||
boundary.state = { hasError: true, message: "Render failure" };
|
||||
|
||||
const html = renderToStaticMarkup(boundary.render());
|
||||
|
||||
expect(html).toContain('class="ui-error-boundary"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('aria-labelledby="renderer-error-title"');
|
||||
expect(html).toContain('aria-describedby="renderer-error-description renderer-error-details"');
|
||||
expect(html).toContain('id="renderer-error-title"');
|
||||
expect(html).toContain('id="renderer-error-description"');
|
||||
expect(html).toContain('id="renderer-error-details"');
|
||||
expect(html).toContain("Render failure");
|
||||
expect(html).toContain("Oberfläche neu laden");
|
||||
expect(html).not.toContain("style=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,632 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import {
|
||||
applyAccountEdit,
|
||||
createAccountEditState,
|
||||
type AccountEditTarget
|
||||
} from "../src/renderer/account-edit";
|
||||
import {
|
||||
buildBulkAccountEnabledState,
|
||||
buildConfiguredProviderOrder
|
||||
} from "../src/renderer/account-ui";
|
||||
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import {
|
||||
ACCOUNT_COLUMNS,
|
||||
SETTINGS_SECTIONS,
|
||||
buildAccountRowId,
|
||||
buildTargetedAccountCheck,
|
||||
filterAccountAddOptions,
|
||||
getSettingsSaveLabel,
|
||||
projectAccountRows,
|
||||
pruneAccountSelection,
|
||||
reconcileAccountAddDraft,
|
||||
sortAccountRows,
|
||||
type AccountAddOption,
|
||||
type AccountRowSource,
|
||||
type SettingsFormViewModel
|
||||
} from "../src/renderer/views/settings/settings-model";
|
||||
import {
|
||||
AccountAddDialog,
|
||||
AccountEditDialog,
|
||||
AccountWorkspace,
|
||||
type AccountWorkspaceActions,
|
||||
type AccountWorkspaceViewModel
|
||||
} from "../src/renderer/views/settings/AccountWorkspace";
|
||||
import { SettingsForm } from "../src/renderer/views/settings/SettingsForm";
|
||||
import {
|
||||
SettingsContent,
|
||||
SettingsSidebar,
|
||||
SettingsView,
|
||||
type SettingsViewActions,
|
||||
type SettingsViewModel
|
||||
} from "../src/renderer/views/settings/SettingsView";
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
const NOW = 1_700_000_000_000;
|
||||
const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const accountWorkspaceSource = readFileSync(
|
||||
new URL("../src/renderer/views/settings/AccountWorkspace.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function sourceBlock(source: string, start: string, end: string): string {
|
||||
return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
|
||||
}
|
||||
|
||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) => visitElements(child, visit));
|
||||
return;
|
||||
}
|
||||
if (!isValidElement(node)) {
|
||||
return;
|
||||
}
|
||||
visit(node);
|
||||
visitElements(node.props.children, visit);
|
||||
visitElements(node.props.actions, visit);
|
||||
}
|
||||
|
||||
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
|
||||
let result: ReactElement | null = null;
|
||||
visitElements(node, (element) => {
|
||||
if (!result && predicate(element)) {
|
||||
result = element;
|
||||
}
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Element not found");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function count(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
function accountSources(): AccountRowSource[] {
|
||||
return [
|
||||
{
|
||||
identityId: "mega-premium",
|
||||
service: "megadebrid-api",
|
||||
hoster: "Mega-Debrid",
|
||||
mode: "API",
|
||||
icon: "./provider-icons/mega-debrid.png",
|
||||
enabled: true,
|
||||
status: {
|
||||
state: "premium",
|
||||
message: "Premium aktiv",
|
||||
premiumUntilMs: NOW + 7 * 24 * 60 * 60 * 1000,
|
||||
email: "verified@example.test"
|
||||
},
|
||||
dailyLimitBytes: 10 * GIB,
|
||||
dailyUsageBytes: 4 * GIB,
|
||||
username: "stored@example.test",
|
||||
credentialKind: "password",
|
||||
canCheck: true
|
||||
},
|
||||
{
|
||||
identityId: "debrid-free",
|
||||
service: "debridlink",
|
||||
hoster: "Debrid-Link",
|
||||
mode: "API-Key",
|
||||
icon: "./provider-icons/debrid-link.ico",
|
||||
enabled: true,
|
||||
status: { state: "free", message: "Free Account", premiumUntilMs: null },
|
||||
dailyLimitBytes: 0,
|
||||
dailyUsageBytes: 0,
|
||||
username: "free-user",
|
||||
credentialKind: "api-key",
|
||||
canCheck: true
|
||||
},
|
||||
{
|
||||
identityId: "invalid",
|
||||
service: "ddownload",
|
||||
hoster: "DDownload",
|
||||
mode: "Login",
|
||||
icon: "./provider-icons/ddownload.ico",
|
||||
enabled: true,
|
||||
status: { state: "invalid", message: "Login abgelehnt", premiumUntilMs: null },
|
||||
username: "invalid@example.test",
|
||||
credentialKind: "password",
|
||||
canCheck: false
|
||||
},
|
||||
{
|
||||
identityId: "unknown",
|
||||
service: "onefichier",
|
||||
hoster: "1Fichier",
|
||||
mode: "API",
|
||||
icon: "./provider-icons/onefichier.png",
|
||||
enabled: true,
|
||||
status: { state: "unchecked", message: "", premiumUntilMs: null },
|
||||
username: "—",
|
||||
credentialKind: "api-key",
|
||||
canCheck: false
|
||||
},
|
||||
{
|
||||
identityId: "disabled",
|
||||
service: "linksnappy",
|
||||
hoster: "LinkSnappy",
|
||||
mode: "Web-Login",
|
||||
icon: "./provider-icons/linksnappy.png",
|
||||
enabled: false,
|
||||
status: { state: "disabled", message: "", premiumUntilMs: null },
|
||||
username: "disabled@example.test",
|
||||
credentialKind: "password",
|
||||
canCheck: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function accountOptions(): AccountAddOption[] {
|
||||
return [
|
||||
{
|
||||
id: "realdebrid-api",
|
||||
service: "realdebrid",
|
||||
title: "Real-Debrid",
|
||||
mode: "API",
|
||||
description: "API-Token verwenden",
|
||||
functionLabel: "API-Token",
|
||||
filter: "api",
|
||||
multi: false
|
||||
},
|
||||
{
|
||||
id: "ddownload-login",
|
||||
service: "ddownload",
|
||||
title: "DDownload",
|
||||
mode: "Login",
|
||||
description: "Login und Passwort",
|
||||
functionLabel: "Login:Passwort",
|
||||
filter: "web",
|
||||
multi: false
|
||||
},
|
||||
{
|
||||
id: "megadebrid-api",
|
||||
service: "megadebrid-api",
|
||||
title: "Mega-Debrid",
|
||||
mode: "API",
|
||||
description: "Weiteren Account hinzufügen",
|
||||
functionLabel: "Login:Passwort",
|
||||
filter: "api",
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
id: "debridlink-api",
|
||||
service: "debridlink",
|
||||
title: "Debrid-Link",
|
||||
mode: "API",
|
||||
description: "Weiteren API-Key hinzufügen",
|
||||
functionLabel: "API-Key",
|
||||
filter: "api",
|
||||
multi: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function formModel(): SettingsFormViewModel {
|
||||
return {
|
||||
title: "Allgemein",
|
||||
description: "Grundeinstellungen der Anwendung.",
|
||||
groups: [
|
||||
{
|
||||
id: "appearance",
|
||||
title: "Darstellung",
|
||||
fields: [
|
||||
{
|
||||
id: "downloadDir",
|
||||
kind: "path",
|
||||
label: "Download-Ordner",
|
||||
value: "C:\\Downloads",
|
||||
help: "Zielordner für Downloads."
|
||||
},
|
||||
{
|
||||
id: "theme",
|
||||
kind: "theme",
|
||||
label: "Theme",
|
||||
value: "dark",
|
||||
options: [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "system", label: "System" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "autoUpdate",
|
||||
kind: "switch",
|
||||
label: "Automatisch nach Updates suchen",
|
||||
value: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceModel(): AccountWorkspaceViewModel {
|
||||
return {
|
||||
activePanel: "overview",
|
||||
rows: projectAccountRows(accountSources(), [buildAccountRowId("megadebrid-api", "API", "mega-premium")], NOW),
|
||||
selectedIds: [buildAccountRowId("megadebrid-api", "API", "mega-premium")],
|
||||
busy: false,
|
||||
rules: {
|
||||
providerOrder: ["Debrid-Link", "Real-Debrid"],
|
||||
routing: ["rapidgator.net → Debrid-Link"],
|
||||
autoFallback: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceActions(overrides: Partial<AccountWorkspaceActions> = {}): AccountWorkspaceActions {
|
||||
return {
|
||||
onPanelChange: () => {},
|
||||
onSelect: () => {},
|
||||
onToggleEnabled: () => {},
|
||||
onEdit: () => {},
|
||||
onContextMenu: () => {},
|
||||
onAdd: () => {},
|
||||
onRemoveSelected: () => {},
|
||||
onCheckAll: () => {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function viewModel(saveState: SettingsViewModel["saveState"] = "clean"): SettingsViewModel {
|
||||
return {
|
||||
section: "accounts",
|
||||
saveState,
|
||||
form: formModel(),
|
||||
accounts: workspaceModel()
|
||||
};
|
||||
}
|
||||
|
||||
function viewActions(): SettingsViewActions {
|
||||
return {
|
||||
onSectionChange: () => {},
|
||||
onSave: () => {},
|
||||
form: { onChange: () => {}, onAction: () => {} },
|
||||
accounts: workspaceActions()
|
||||
};
|
||||
}
|
||||
|
||||
describe("settings model", () => {
|
||||
it("keeps six stable sections and accessible save-state labels", () => {
|
||||
expect(SETTINGS_SECTIONS).toEqual([
|
||||
{ id: "allgemein", label: "Allgemein" },
|
||||
{ id: "accounts", label: "Accounts" },
|
||||
{ id: "extract", label: "Entpacken" },
|
||||
{ id: "speed", label: "Geschwindigkeit" },
|
||||
{ id: "cleanup", label: "Bereinigung" },
|
||||
{ id: "updates", label: "Updates" }
|
||||
]);
|
||||
expect(["clean", "dirty", "saving", "saved", "error"].map((state) => getSettingsSaveLabel(state as never)))
|
||||
.toEqual(["Gespeichert", "Ungespeicherte Änderungen", "Wird gespeichert…", "Gespeichert", "Speichern fehlgeschlagen"]);
|
||||
});
|
||||
|
||||
it("projects stable sanitized rows with full verified usernames and distinct states", () => {
|
||||
const rows = projectAccountRows(accountSources(), [], NOW);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(accountSources().map((source) => buildAccountRowId(source.service, source.mode, source.identityId)));
|
||||
expect(rows[0].username).toBe("verified@example.test");
|
||||
expect(rows[0].credential).toBe("••••••");
|
||||
expect(rows[1].credential).toBe("API-Key");
|
||||
expect(rows.map((row) => row.status.tone)).toEqual(["ok", "free", "invalid", "unknown", "disabled"]);
|
||||
expect(rows.map((row) => row.status.text)).toEqual([
|
||||
"Premium aktiv",
|
||||
"Free Account",
|
||||
"Login abgelehnt",
|
||||
"Noch nicht geprüft",
|
||||
"Deaktiviert"
|
||||
]);
|
||||
expect(JSON.stringify(rows)).not.toContain("test-password");
|
||||
expect(JSON.stringify(rows)).not.toContain("test-token");
|
||||
});
|
||||
|
||||
it("sorts positive premium expirations first and prunes vanished selections", () => {
|
||||
const rows = projectAccountRows(accountSources(), [], NOW);
|
||||
const sorted = sortAccountRows(rows);
|
||||
|
||||
expect(sorted[0].status.tone).toBe("ok");
|
||||
expect(pruneAccountSelection([rows[0].id, "missing"], rows)).toEqual([rows[0].id]);
|
||||
});
|
||||
|
||||
it("filters add options honestly and clears hidden credentials", () => {
|
||||
const options = accountOptions();
|
||||
expect(filterAccountAddOptions(options, "", "web", []) .map((option) => option.id)).toEqual(["ddownload-login"]);
|
||||
expect(filterAccountAddOptions(options, "api-key", "all", ["realdebrid"]).map((option) => option.id)).toEqual(["debridlink-api"]);
|
||||
expect(filterAccountAddOptions(options, "", "all", ["realdebrid"]).map((option) => option.id)).not.toContain("realdebrid-api");
|
||||
expect(filterAccountAddOptions(options, "", "all", ["realdebrid"]).map((option) => option.id)).toContain("megadebrid-api");
|
||||
|
||||
expect(reconcileAccountAddDraft({
|
||||
selectedId: "realdebrid-api",
|
||||
login: "member@example.test",
|
||||
password: "test-password",
|
||||
token: "test-token",
|
||||
dailyLimitGb: "10"
|
||||
}, [options[1]])).toEqual({ selectedId: null, login: "", password: "", token: "", dailyLimitGb: "" });
|
||||
});
|
||||
|
||||
it("targets new Mega and Debrid-Link identities without inventing checks for other services", () => {
|
||||
const options = accountOptions();
|
||||
expect(buildTargetedAccountCheck(options[2], "mda-new")).toEqual({ service: "megadebrid-api", expectedStatusId: "mda-new" });
|
||||
expect(buildTargetedAccountCheck(options[3], "dlk-new")).toEqual({ service: "debridlink", expectedStatusId: "dlk-new" });
|
||||
expect(buildTargetedAccountCheck(options[1], "ddownload-new")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves provider order and deduplicates bulk account identities", () => {
|
||||
expect(buildConfiguredProviderOrder(
|
||||
["debridlink", "realdebrid", "alldebrid"],
|
||||
["realdebrid", "alldebrid", "debridlink", "bestdebrid"]
|
||||
)).toEqual(["debridlink", "realdebrid", "alldebrid", "bestdebrid"]);
|
||||
expect(buildBulkAccountEnabledState(
|
||||
["alldebrid"],
|
||||
["megadebrid-api", "alldebrid"],
|
||||
["mega-1", "mega-1"],
|
||||
["dl-1", "dl-1"],
|
||||
false
|
||||
)).toEqual({
|
||||
disabledProviders: ["alldebrid", "megadebrid-api"],
|
||||
megaDebridDisabledAccountIds: ["mega-1"],
|
||||
debridLinkDisabledKeyIds: ["dl-1"]
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps exact rounded limits and migrates edited identity metadata", () => {
|
||||
const login = "member@example.test";
|
||||
const oldId = getMegaDebridAccountId(login);
|
||||
const newLogin = "renamed@example.test";
|
||||
const newId = getMegaDebridAccountId(newLogin);
|
||||
const exactLimit = Math.floor(10.05 * GIB);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: `${login}:test-password`,
|
||||
megaLogin: login,
|
||||
megaPassword: "test-password",
|
||||
megaDebridDisabledAccountIds: [oldId],
|
||||
megaDebridAccountDailyLimitBytes: { [oldId]: exactLimit },
|
||||
megaDebridAccountDailyUsageBytes: { [oldId]: 2 * GIB },
|
||||
megaDebridAccountTotalUsageBytes: { [oldId]: 20 * GIB }
|
||||
};
|
||||
const target: AccountEditTarget = {
|
||||
type: "mega",
|
||||
rowKey: "row",
|
||||
kind: "megadebrid-api",
|
||||
service: "megadebrid-api",
|
||||
accountId: oldId
|
||||
};
|
||||
const unchanged = applyAccountEdit(settings, createAccountEditState(target, settings));
|
||||
const renamed = applyAccountEdit(settings, {
|
||||
...createAccountEditState(target, settings),
|
||||
login: newLogin
|
||||
});
|
||||
|
||||
expect(unchanged.megaDebridAccountDailyLimitBytes[oldId]).toBe(exactLimit);
|
||||
expect(renamed.megaDebridDisabledAccountIds).toEqual([newId]);
|
||||
expect(renamed.megaDebridAccountDailyLimitBytes[newId]).toBe(exactLimit);
|
||||
expect(renamed.megaDebridAccountDailyUsageBytes[newId]).toBeUndefined();
|
||||
expect(renamed.megaDebridAccountTotalUsageBytes[newId]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings views", () => {
|
||||
it("renders one real sidebar marker and all sections", () => {
|
||||
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
||||
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(html).toContain(section.label);
|
||||
}
|
||||
expect(html).toContain("aria-current=\"page\"");
|
||||
});
|
||||
|
||||
it("shows every save state without a generic toolbar, pagination or info control", () => {
|
||||
for (const saveState of ["clean", "dirty", "saving", "saved", "error"] as const) {
|
||||
const html = renderToStaticMarkup(<SettingsContent actions={viewActions()} model={viewModel(saveState)} />);
|
||||
expect(html).toContain(getSettingsSaveLabel(saveState));
|
||||
expect(html).toContain("Einstellungen speichern");
|
||||
expect(html).not.toContain("role=\"toolbar\"");
|
||||
expect(html).not.toContain("table-pagination");
|
||||
expect(html).not.toContain("ui-context-info");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the sidebar and content once in the complete view", () => {
|
||||
const html = renderToStaticMarkup(<SettingsView actions={viewActions()} model={viewModel()} />);
|
||||
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
|
||||
expect(count(html, "data-visual-region=\"accounts-table-body\"")).toBe(1);
|
||||
});
|
||||
|
||||
it("renders form controls, theme choices and switches through bounded callbacks", () => {
|
||||
let changed = "";
|
||||
const form = SettingsForm({
|
||||
model: formModel(),
|
||||
actions: {
|
||||
onChange: (id) => { changed = id; },
|
||||
onAction: () => {}
|
||||
}
|
||||
});
|
||||
const html = renderToStaticMarkup(form);
|
||||
|
||||
expect(html).toContain("Light");
|
||||
expect(html).toContain("Dark");
|
||||
expect(html).toContain("System");
|
||||
expect(html).toContain("role=\"switch\"");
|
||||
const switchButton = findElement(form, (element) => element.props.role === "switch");
|
||||
switchButton.props.onClick();
|
||||
expect(changed).toBe("autoUpdate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("account workspace", () => {
|
||||
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
|
||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
|
||||
|
||||
expect(positions.every((position) => position >= 0)).toBe(true);
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
||||
expect(count(html, "data-visual-region=\"accounts-table-body\"")).toBe(1);
|
||||
expect(html).toContain("verified@example.test");
|
||||
expect(html).not.toContain("ve***st");
|
||||
expect(html).toContain("••••••");
|
||||
expect(html).not.toContain("test-password");
|
||||
expect(html).not.toContain("test-token");
|
||||
expect(html).not.toContain("table-pagination");
|
||||
expect(html).not.toContain("role=\"toolbar\"");
|
||||
});
|
||||
|
||||
it("keeps row selection, enable toggles, edit and context actions separate", () => {
|
||||
const calls: string[] = [];
|
||||
const tree = AccountWorkspace({
|
||||
model: workspaceModel(),
|
||||
actions: workspaceActions({
|
||||
onSelect: (id) => calls.push(`select:${id}`),
|
||||
onToggleEnabled: (id) => calls.push(`toggle:${id}`),
|
||||
onEdit: (id) => calls.push(`edit:${id}`),
|
||||
onContextMenu: (id) => calls.push(`context:${id}`)
|
||||
})
|
||||
});
|
||||
const row = findElement(tree, (element) => element.props.role === "row" && element.props["aria-selected"] === true);
|
||||
const checkbox = findElement(row, (element) => element.type === "input" && element.props.type === "checkbox");
|
||||
const actionButton = findElement(row, (element) => element.type === "button" && String(element.props["aria-label"] || "").includes("Aktionen"));
|
||||
const rowId = workspaceModel().rows[0].id;
|
||||
|
||||
row.props.onClick({ target: { role: "cell" }, currentTarget: row });
|
||||
row.props.onKeyDown({ key: "Enter", target: row, currentTarget: row, preventDefault: () => {} });
|
||||
row.props.onKeyDown({ key: " ", target: checkbox, currentTarget: row, preventDefault: () => {} });
|
||||
checkbox.props.onChange();
|
||||
row.props.onDoubleClick();
|
||||
actionButton.props.onClick({ stopPropagation: () => {}, currentTarget: { getBoundingClientRect: () => ({ right: 20, bottom: 30 }) } });
|
||||
|
||||
expect(calls).toEqual([
|
||||
`select:${rowId}`,
|
||||
`select:${rowId}`,
|
||||
`toggle:${rowId}`,
|
||||
`edit:${rowId}`,
|
||||
`context:${rowId}`
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps overview and rules in the same workspace while only one panel is active", () => {
|
||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||
expect(count(html, "class=\"settings-account-panel\"")).toBe(2);
|
||||
expect(count(html, "hidden=\"\"")).toBe(1);
|
||||
expect(html).toContain("Provider-Reihenfolge");
|
||||
expect(html).toContain("Hoster-Routing");
|
||||
expect(html).toContain("Automatischer Fallback");
|
||||
});
|
||||
|
||||
it("keeps add and edit dialogs separate and every secret field protected", () => {
|
||||
const addHtml = renderToStaticMarkup(
|
||||
<AccountAddDialog
|
||||
actions={{
|
||||
onQueryChange: () => {},
|
||||
onFilterChange: () => {},
|
||||
onOptionSelect: () => {},
|
||||
onFieldChange: () => {},
|
||||
onClose: () => {},
|
||||
onSubmit: () => {}
|
||||
}}
|
||||
model={{
|
||||
open: true,
|
||||
query: "",
|
||||
filter: "all",
|
||||
options: accountOptions(),
|
||||
selectedOptionId: "megadebrid-api",
|
||||
fields: [
|
||||
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
|
||||
{ id: "password", label: "Passwort", type: "password", value: "test-password" }
|
||||
],
|
||||
error: "",
|
||||
busy: false
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const editHtml = renderToStaticMarkup(
|
||||
<AccountEditDialog
|
||||
actions={{
|
||||
onFieldChange: () => {},
|
||||
onClose: () => {},
|
||||
onCheck: () => {},
|
||||
onSave: () => {},
|
||||
onRemove: () => {},
|
||||
onToggleEnabled: () => {}
|
||||
}}
|
||||
model={{
|
||||
open: true,
|
||||
hoster: "Mega-Debrid",
|
||||
mode: "API",
|
||||
identity: "member@example.test",
|
||||
enabled: true,
|
||||
fields: [
|
||||
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
|
||||
{ id: "password", label: "Passwort", type: "password", value: "test-password" },
|
||||
{ id: "token", label: "Token", type: "password", value: "test-token" }
|
||||
],
|
||||
error: "",
|
||||
busy: false
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(addHtml).toContain("Account hinzufügen");
|
||||
expect(addHtml).toContain("Prüfen und speichern");
|
||||
expect(addHtml).toContain("Alle");
|
||||
expect(addHtml).toContain("API");
|
||||
expect(addHtml).toContain("Web");
|
||||
expect(editHtml).toContain("Account bearbeiten");
|
||||
expect(editHtml).toContain("member@example.test");
|
||||
expect(editHtml).toContain("Entfernen");
|
||||
expect(editHtml).toContain("Prüfen");
|
||||
expect(count(addHtml, "type=\"password\"")).toBe(1);
|
||||
expect(count(editHtml, "type=\"password\"")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings App integration", () => {
|
||||
it("keeps specific persistence revision-safe when the draft changes in flight", () => {
|
||||
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
|
||||
expect(block).toContain("revisionAtStart");
|
||||
expect(block).toContain("mergeConcurrentSpecificSettings");
|
||||
expect(block).toContain('setSettingsSaveState("dirty")');
|
||||
});
|
||||
|
||||
it("keeps unchecked single accounts honest without a positive status", () => {
|
||||
const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId");
|
||||
expect(block).toMatch(/:\s*!checkedStatus\s*\?\s*"unchecked"/s);
|
||||
});
|
||||
|
||||
it("stores only the stable account row id in context-menu state", () => {
|
||||
const stateBlock = sourceBlock(appSource, "interface AccountContextMenuState", "function getAccountQuickActionMeta");
|
||||
expect(stateBlock).toContain("rowId: string");
|
||||
expect(stateBlock).not.toContain("row: AccountTableRow");
|
||||
expect(appSource).toContain("activeAccountContextRow");
|
||||
});
|
||||
|
||||
it("preserves the System theme choice while applying its resolved palette", () => {
|
||||
expect(appSource).toContain("settingsThemeChoice");
|
||||
expect(appSource).toContain("resolveSettingsThemeChoice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings geometry", () => {
|
||||
it("keeps the specified form, table, switch, overflow and selection geometry", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
||||
expect(css).toMatch(/\.settings-content\s*{[^}]*padding:\s*24px;/s);
|
||||
expect(css).toMatch(
|
||||
/\.md-runtime-view-content\s*>\s*\.settings-content\s*{[^}]*height:\s*100%;[^}]*padding:\s*24px;/s
|
||||
);
|
||||
expect(css).toMatch(/\.settings-form-column\s*{[^}]*width:\s*500px;[^}]*max-width:\s*100%;/s);
|
||||
expect(css).toMatch(/\.settings-control\s*{[^}]*height:\s*44px;[^}]*border-radius:\s*6px;/s);
|
||||
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
|
||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
|
||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
|
||||
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
|
||||
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
|
||||
expect(css).toMatch(/\.settings-view\s*{[^}]*min-width:\s*0;/s);
|
||||
expect(css).toMatch(/\.settings-account-workspace\s*{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s);
|
||||
expect(css).toMatch(/\.settings-static\s*{[^}]*user-select:\s*none;/s);
|
||||
expect(css).toMatch(/\.settings-content\s+:where\(input,\s*textarea,\s*\[contenteditable="true"\],\s*\.settings-copyable\)\s*{[^}]*user-select:\s*text;/s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
||||
import { readBandwidthChartPalette } from "../src/renderer/App";
|
||||
import {
|
||||
buildStatisticsViewModel,
|
||||
type StatisticsMetric,
|
||||
type StatisticsRange
|
||||
} from "../src/renderer/views/statistics/statistics-model";
|
||||
import {
|
||||
StatisticsContent,
|
||||
StatisticsSidebar,
|
||||
StatisticsView,
|
||||
type StatisticsViewActions
|
||||
} from "../src/renderer/views/statistics/StatisticsView";
|
||||
import { createVisualFixture } from "./visual/fixtures";
|
||||
|
||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||
|
||||
function createSnapshot(): UiSnapshot {
|
||||
return structuredClone(createVisualFixture("empty").snapshot);
|
||||
}
|
||||
|
||||
function item(
|
||||
id: string,
|
||||
status: DownloadStatus,
|
||||
overrides: Partial<DownloadItem> = {}
|
||||
): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
packageId: "statistics-package",
|
||||
url: `https://url-host-${id}.example/file`,
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid",
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 100,
|
||||
totalBytes: 100,
|
||||
progressPercent: status === "completed" ? 100 : 50,
|
||||
fileName: `${id}.bin`,
|
||||
targetPath: `C:\\Downloads\\${id}.bin`,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: status === "failed" ? "Fehlgeschlagen" : "",
|
||||
fullStatus: status,
|
||||
createdAt: now - 1000,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function setItems(snapshot: UiSnapshot, items: DownloadItem[]): void {
|
||||
snapshot.session.items = Object.fromEntries(items.map((entry) => [entry.id, entry]));
|
||||
}
|
||||
|
||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) => visitElements(child, visit));
|
||||
return;
|
||||
}
|
||||
if (!isValidElement(node)) {
|
||||
return;
|
||||
}
|
||||
visit(node);
|
||||
visitElements(node.props.children, visit);
|
||||
}
|
||||
|
||||
function findButton(node: ReactNode, label: string): ReactElement {
|
||||
let result: ReactElement | null = null;
|
||||
visitElements(node, (element) => {
|
||||
if (!result && element.type === "button" && element.props.children === label) {
|
||||
result = element;
|
||||
}
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error(`Button not found: ${label}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createActions(overrides: Partial<StatisticsViewActions> = {}): StatisticsViewActions {
|
||||
return {
|
||||
onRangeChange: () => {},
|
||||
onResetSession: () => {},
|
||||
onResetAll: () => {},
|
||||
onResetErrors: () => {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function expectUnavailable(metric: StatisticsMetric): void {
|
||||
expect(metric).toMatchObject({ value: null, available: false });
|
||||
expect(metric.sourceLabel.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
describe("statistics model", () => {
|
||||
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloaded = 600;
|
||||
snapshot.stats.totalFilesSession = 3;
|
||||
snapshot.session.running = true;
|
||||
setItems(snapshot, [
|
||||
item("complete-a", "completed"),
|
||||
item("complete-b", "completed"),
|
||||
item("complete-c", "completed"),
|
||||
item("failed", "failed"),
|
||||
item("active", "downloading"),
|
||||
item("waiting", "queued")
|
||||
]);
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||
|
||||
expect(model.metrics.downloadedBytes.value).toBe(600);
|
||||
expect(model.metrics.files.value).toBe(3);
|
||||
expect(model.metrics.successRate.value).toBe(75);
|
||||
expect(model.metrics.errors.value).toBe(1);
|
||||
});
|
||||
|
||||
it("reports no success rate when the current queue has no completed or failed result", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.session.running = true;
|
||||
setItems(snapshot, [item("active", "downloading"), item("waiting", "queued")]);
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||
|
||||
expectUnavailable(model.metrics.successRate);
|
||||
expect(model.metrics.errors).toMatchObject({ value: 0, available: true });
|
||||
});
|
||||
|
||||
it("sorts current-queue providers by bytes and then id without deriving them from URL hostnames", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.session.running = true;
|
||||
setItems(snapshot, [
|
||||
item("real", "completed", {
|
||||
url: "https://alldebrid.invalid/wrong-source",
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid Konto",
|
||||
downloadedBytes: 200
|
||||
}),
|
||||
item("all", "failed", {
|
||||
url: "https://realdebrid.invalid/wrong-source",
|
||||
provider: "alldebrid",
|
||||
providerLabel: "AllDebrid",
|
||||
downloadedBytes: 200
|
||||
}),
|
||||
item("link", "completed", {
|
||||
url: "https://realdebrid.invalid/also-wrong",
|
||||
provider: "debridlink",
|
||||
providerLabel: "Debrid-Link",
|
||||
downloadedBytes: 400
|
||||
}),
|
||||
item("unknown", "completed", {
|
||||
url: "https://hoster-only.invalid/not-a-provider",
|
||||
provider: null,
|
||||
providerLabel: undefined,
|
||||
downloadedBytes: 900
|
||||
})
|
||||
]);
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||
|
||||
expect(model.providers.map((row) => row.id)).toEqual(["debridlink", "alldebrid", "realdebrid"]);
|
||||
expect(model.providers.map((row) => row.label)).toEqual(["Debrid-Link", "AllDebrid", "Real-Debrid Konto"]);
|
||||
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[1, 0], [0, 1], [1, 0]]);
|
||||
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses daily provider usage only for the matching local day", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloaded = 999_999;
|
||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "today", now);
|
||||
|
||||
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
|
||||
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
|
||||
["alldebrid", 1_500],
|
||||
["realdebrid", 500]
|
||||
]);
|
||||
expectUnavailable(model.metrics.files);
|
||||
expectUnavailable(model.metrics.successRate);
|
||||
expectUnavailable(model.metrics.errors);
|
||||
expectUnavailable(model.metrics.averageSpeedBps);
|
||||
});
|
||||
|
||||
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.settings.providerDailyUsageDay = "2026-08-09";
|
||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 900 };
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "today", now);
|
||||
|
||||
expect(model.metrics.downloadedBytes).toMatchObject({ value: 0, available: true });
|
||||
expect(model.providers).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloaded = 5_000;
|
||||
snapshot.stats.totalDownloadedAllTime = 50_000;
|
||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
|
||||
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, range, now);
|
||||
|
||||
expect(model.coverage).toBe("unavailable");
|
||||
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
|
||||
expect(model.providers).toEqual([]);
|
||||
expect(model.providerScope).toBeNull();
|
||||
Object.values(model.metrics).forEach(expectUnavailable);
|
||||
});
|
||||
|
||||
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloadedAllTime = 25_000;
|
||||
snapshot.stats.totalFilesAllTime = 42;
|
||||
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
|
||||
snapshot.summary = {
|
||||
total: 10,
|
||||
success: 9,
|
||||
failed: 1,
|
||||
cancelled: 0,
|
||||
extracted: 9,
|
||||
durationSeconds: 10,
|
||||
averageSpeedBps: 2_500
|
||||
};
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "all", now);
|
||||
|
||||
expect(model.metrics.downloadedBytes.value).toBe(25_000);
|
||||
expect(model.metrics.files.value).toBe(42);
|
||||
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
|
||||
["debridlink", 20_000],
|
||||
["realdebrid", 5_000]
|
||||
]);
|
||||
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
|
||||
expectUnavailable(model.metrics.successRate);
|
||||
expectUnavailable(model.metrics.errors);
|
||||
expectUnavailable(model.metrics.averageSpeedBps);
|
||||
});
|
||||
|
||||
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
|
||||
const snapshot = createSnapshot();
|
||||
setItems(snapshot, [
|
||||
item("complete-a", "completed"),
|
||||
item("complete-b", "completed"),
|
||||
item("complete-c", "completed"),
|
||||
item("failed", "failed")
|
||||
]);
|
||||
snapshot.summary = {
|
||||
total: 4,
|
||||
success: 1,
|
||||
failed: 3,
|
||||
cancelled: 0,
|
||||
extracted: 1,
|
||||
durationSeconds: 100,
|
||||
averageSpeedBps: 500
|
||||
};
|
||||
snapshot.session.running = true;
|
||||
|
||||
const active = buildStatisticsViewModel(snapshot, "session", now);
|
||||
snapshot.session.running = false;
|
||||
const ended = buildStatisticsViewModel(snapshot, "session", now);
|
||||
|
||||
expect(active.metrics.successRate.value).toBe(75);
|
||||
expect(active.metrics.errors.value).toBe(1);
|
||||
expectUnavailable(active.metrics.averageSpeedBps);
|
||||
expect(ended.metrics.successRate.value).toBe(25);
|
||||
expect(ended.metrics.errors.value).toBe(3);
|
||||
expect(ended.metrics.averageSpeedBps).toMatchObject({ value: 500, available: true });
|
||||
});
|
||||
|
||||
it("models empty, idle, active and paused session states separately", () => {
|
||||
const empty = createSnapshot();
|
||||
const idle = createSnapshot();
|
||||
setItems(idle, [item("idle", "completed")]);
|
||||
const active = createSnapshot();
|
||||
active.session.running = true;
|
||||
setItems(active, [item("active", "downloading")]);
|
||||
const paused = createSnapshot();
|
||||
paused.session.running = true;
|
||||
paused.session.paused = true;
|
||||
setItems(paused, [item("paused", "paused")]);
|
||||
|
||||
expect(buildStatisticsViewModel(empty, "session", now).sessionState).toBe("empty");
|
||||
expect(buildStatisticsViewModel(idle, "session", now).sessionState).toBe("idle");
|
||||
expect(buildStatisticsViewModel(active, "session", now).sessionState).toBe("active");
|
||||
expect(buildStatisticsViewModel(paused, "session", now).sessionState).toBe("paused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("statistics view", () => {
|
||||
it("renders each statistics marker exactly once, all ranges and no download toolbar or pagination", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloaded = 2_048;
|
||||
snapshot.stats.totalFilesSession = 1;
|
||||
setItems(snapshot, [item("complete", "completed")]);
|
||||
const html = renderToStaticMarkup(
|
||||
<StatisticsView
|
||||
actions={createActions()}
|
||||
chart={<div>Bestehender Bandbreitenverlauf</div>}
|
||||
model={buildStatisticsViewModel(snapshot, "session", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
|
||||
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
|
||||
expect(html).toContain(`>${label}<`);
|
||||
}
|
||||
expect(html).toContain("Bestehender Bandbreitenverlauf");
|
||||
expect(html).not.toContain("downloads-toolbar");
|
||||
expect(html.toLocaleLowerCase("de-DE")).not.toContain("pagination");
|
||||
});
|
||||
|
||||
it("dispatches range and reset controls only through the supplied callbacks", () => {
|
||||
const snapshot = createSnapshot();
|
||||
setItems(snapshot, [item("failed", "failed")]);
|
||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||
const calls: string[] = [];
|
||||
const actions = createActions({
|
||||
onRangeChange: (range) => calls.push(`range:${range}`),
|
||||
onResetSession: () => calls.push("reset:session"),
|
||||
onResetAll: () => calls.push("reset:all"),
|
||||
onResetErrors: () => calls.push("reset:errors")
|
||||
});
|
||||
const sidebar = StatisticsSidebar({ actions, model });
|
||||
const content = StatisticsContent({ actions, chart: <div />, model });
|
||||
|
||||
findButton(sidebar, "Heute").props.onClick();
|
||||
findButton(content, "Sitzung zurücksetzen").props.onClick();
|
||||
findButton(content, "Gesamt zurücksetzen").props.onClick();
|
||||
findButton(content, "Fehler zurücksetzen").props.onClick();
|
||||
|
||||
expect(calls).toEqual(["range:today", "reset:session", "reset:all", "reset:errors"]);
|
||||
});
|
||||
|
||||
it("enables error reset only for a positive genuine error metric", () => {
|
||||
const clean = createSnapshot();
|
||||
const failed = createSnapshot();
|
||||
setItems(failed, [item("failed", "failed")]);
|
||||
|
||||
const cleanContent = StatisticsContent({
|
||||
actions: createActions(),
|
||||
chart: <div />,
|
||||
model: buildStatisticsViewModel(clean, "session", now)
|
||||
});
|
||||
const failedContent = StatisticsContent({
|
||||
actions: createActions(),
|
||||
chart: <div />,
|
||||
model: buildStatisticsViewModel(failed, "session", now)
|
||||
});
|
||||
|
||||
expect(findButton(cleanContent, "Fehler zurücksetzen").props.disabled).toBe(true);
|
||||
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatisticsContent
|
||||
actions={createActions()}
|
||||
chart={<div />}
|
||||
model={buildStatisticsViewModel(createSnapshot(), "session", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain('class="statistics-provider-empty" role="row"');
|
||||
expect(html).toContain('aria-colspan="3" role="cell"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandwidth chart palette", () => {
|
||||
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
||||
const requested: string[] = [];
|
||||
const values: Record<string, string> = {
|
||||
"--ui-border": " rgb(61, 61, 61) ",
|
||||
"--ui-text-muted": " rgb(145, 145, 145) ",
|
||||
"--ui-accent": " rgb(56, 134, 255) "
|
||||
};
|
||||
|
||||
const palette = readBandwidthChartPalette((property) => {
|
||||
requested.push(property);
|
||||
return values[property];
|
||||
}, "Inter, Segoe UI, sans-serif");
|
||||
|
||||
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
|
||||
expect(palette).toEqual({
|
||||
grid: "rgb(61, 61, 61)",
|
||||
text: "rgb(145, 145, 145)",
|
||||
accent: "rgb(56, 134, 255)",
|
||||
fontFamily: "Inter, Segoe UI, sans-serif"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Toast } from "../src/renderer/ui/Toast";
|
||||
|
||||
describe("Toast", () => {
|
||||
it("announces the current single toast politely", () => {
|
||||
const html = renderToStaticMarkup(<Toast message="Einstellungen gespeichert" />);
|
||||
|
||||
expect(html).toContain("role=\"status\"");
|
||||
expect(html).toContain("aria-live=\"polite\"");
|
||||
expect(html).toContain("Einstellungen gespeichert");
|
||||
});
|
||||
|
||||
it("renders nothing without a message", () => {
|
||||
expect(renderToStaticMarkup(<Toast message="" />)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ContextInfoButton } from "../src/renderer/ui/ContextInfoButton";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableFooter,
|
||||
DataTableHeader
|
||||
} from "../src/renderer/ui/DataTable";
|
||||
import { Icon } from "../src/renderer/ui/Icon";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../src/renderer/ui/Toolbar";
|
||||
import { getThemeVariables, UI_FOCUS_RING_VARIABLE } from "../src/renderer/ui/theme";
|
||||
|
||||
const expectedThemes = {
|
||||
dark: {
|
||||
"--ui-canvas": "#0F0F0F",
|
||||
"--ui-surface": "#232323",
|
||||
"--ui-input": "#2B2B2B",
|
||||
"--ui-table-header": "#313131",
|
||||
"--ui-active": "#333436",
|
||||
"--ui-hover": "#373535",
|
||||
"--ui-tooltip": "#4F4D4D",
|
||||
"--ui-border": "#3D3D3D",
|
||||
"--ui-text": "#FFFFFF",
|
||||
"--ui-text-secondary": "#EAEDF3",
|
||||
"--ui-text-muted": "#919191",
|
||||
"--ui-primary": "#BAD0FC",
|
||||
"--ui-primary-hover": "#8AA5DC",
|
||||
"--ui-accent": "#3886FF",
|
||||
"--ui-warning": "#F1C786",
|
||||
"--ui-danger": "#F06464",
|
||||
"--ui-modal-secondary": "#35383D",
|
||||
"--ui-overlay": "rgba(0, 0, 0, 0.60)"
|
||||
},
|
||||
light: {
|
||||
"--ui-canvas": "#F3F4F6",
|
||||
"--ui-surface": "#FFFFFF",
|
||||
"--ui-input": "#F7F8FA",
|
||||
"--ui-table-header": "#E7E9ED",
|
||||
"--ui-active": "#DEE6F5",
|
||||
"--ui-hover": "#E8ECF3",
|
||||
"--ui-tooltip": "#35383D",
|
||||
"--ui-border": "#D0D4DB",
|
||||
"--ui-text": "#181A1F",
|
||||
"--ui-text-secondary": "#343842",
|
||||
"--ui-text-muted": "#667085",
|
||||
"--ui-primary": "#A9C2F3",
|
||||
"--ui-primary-hover": "#8AA5DC",
|
||||
"--ui-accent": "#256FDB",
|
||||
"--ui-warning": "#E8B85D",
|
||||
"--ui-danger": "#D94747",
|
||||
"--ui-modal-secondary": "#E7E9ED",
|
||||
"--ui-overlay": "rgba(0, 0, 0, 0.45)"
|
||||
}
|
||||
} as const;
|
||||
|
||||
function relativeLuminance(color: string): number {
|
||||
const channels = color.slice(1).match(/.{2}/g)?.map((value) => Number.parseInt(value, 16) / 255) ?? [];
|
||||
const [red, green, blue] = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const lighter = Math.max(relativeLuminance(first), relativeLuminance(second));
|
||||
const darker = Math.min(relativeLuminance(first), relativeLuminance(second));
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
describe("semantic themes", () => {
|
||||
it("exposes the exact frozen semantic roles for dark and light", () => {
|
||||
const dark = getThemeVariables("dark");
|
||||
const light = getThemeVariables("light");
|
||||
|
||||
expect(dark).toEqual(expectedThemes.dark);
|
||||
expect(light).toEqual(expectedThemes.light);
|
||||
expect(Object.keys(dark)).toEqual(Object.keys(light));
|
||||
expect(Object.keys(dark)).toHaveLength(18);
|
||||
expect(Object.isFrozen(dark)).toBe(true);
|
||||
expect(Object.isFrozen(light)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a focus ring role with at least 3 to 1 contrast on control surfaces", () => {
|
||||
for (const theme of ["dark", "light"] as const) {
|
||||
const variables = getThemeVariables(theme);
|
||||
const focus = variables[UI_FOCUS_RING_VARIABLE];
|
||||
for (const surface of ["--ui-canvas", "--ui-surface", "--ui-input", "--ui-active", "--ui-hover"] as const) {
|
||||
expect(contrastRatio(focus, variables[surface]), `${theme} focus on ${surface}`).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("new UI primitives", () => {
|
||||
it("renders labelled current-color outline icons without emoji text", () => {
|
||||
const html = renderToStaticMarkup(<Icon name="download" label="Downloads" />);
|
||||
|
||||
expect(html).toContain("aria-label=\"Downloads\"");
|
||||
expect(html).toContain("<svg");
|
||||
expect(html).toContain("stroke=\"currentColor\"");
|
||||
expect(html).not.toMatch(/[\u{1F300}-\u{1FAFF}]/u);
|
||||
});
|
||||
|
||||
it("gives toolbar roles and search controls accessible names", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Toolbar label="Downloadaktionen">
|
||||
<ToolbarGroup label="Steuerung">
|
||||
<button type="button">Start</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSearch label="Downloads durchsuchen" value="paket" onChange={() => {}} />
|
||||
</Toolbar>
|
||||
);
|
||||
|
||||
expect(html).toContain("role=\"toolbar\"");
|
||||
expect(html).toContain("aria-label=\"Downloadaktionen\"");
|
||||
expect(html).toContain("role=\"group\"");
|
||||
expect(html).toContain("aria-label=\"Steuerung\"");
|
||||
expect(html).toContain("type=\"search\"");
|
||||
expect(html).toContain("aria-label=\"Downloads durchsuchen\"");
|
||||
});
|
||||
|
||||
it("keeps empty content inside the aria table body", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<DataTable>
|
||||
<DataTableHeader>Spalten</DataTableHeader>
|
||||
<DataTableBody>
|
||||
<DataTableEmpty title="Keine Einträge" description="Noch sind keine Daten vorhanden." />
|
||||
</DataTableBody>
|
||||
<DataTableFooter pageSize={10} rangeLabel="0 von 0" paginationVisible />
|
||||
</DataTable>
|
||||
);
|
||||
|
||||
expect(html).toContain("role=\"table\"");
|
||||
expect(html).toContain("aria-label=\"Datentabelle\"");
|
||||
expect(html.indexOf("Keine Einträge")).toBeGreaterThan(html.indexOf("data-ui-region=\"table-body\""));
|
||||
expect(html).not.toContain("<table");
|
||||
expect(html).toContain("10 pro Seite");
|
||||
expect(html).toContain("0 von 0");
|
||||
});
|
||||
|
||||
it("omits the entire footer when pagination is not visible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<DataTableFooter pageSize={25} rangeLabel="1–25 von 80" paginationVisible={false} />
|
||||
);
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null", null],
|
||||
["false", false],
|
||||
["whitespace", " \n\t"],
|
||||
["empty array", []],
|
||||
["nested empty array", [null, false, " ", []]]
|
||||
])("omits context help for %s content", (_label, content) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContextInfoButton
|
||||
contextName="Downloads"
|
||||
content={content}
|
||||
open={false}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders an accessible trigger and named region for open real help", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContextInfoButton
|
||||
contextName="Downloads"
|
||||
content={["Vorhandene ", <strong key="help">Download-Hilfe</strong>]}
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("aria-label=\"Informationen\"");
|
||||
expect(html).toContain("aria-expanded=\"true\"");
|
||||
expect(html).toContain("role=\"region\"");
|
||||
expect(html).toContain("aria-label=\"Informationen zu Downloads\"");
|
||||
expect(html).toContain("Vorhandene <strong>Download-Hilfe</strong>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App";
|
||||
import type { UpdateCheckResult } from "../src/shared/types";
|
||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||
import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience";
|
||||
|
||||
const callbacks = {
|
||||
onOpen: () => {},
|
||||
onClose: () => {},
|
||||
onInstall: () => {},
|
||||
onLater: () => {}
|
||||
};
|
||||
|
||||
describe("update experience", () => {
|
||||
it("renders the available update and prompt as one accessible experience", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateExperience
|
||||
available
|
||||
currentVersion="v2.0.12"
|
||||
latestTag="v9.9.9"
|
||||
open
|
||||
progress={0}
|
||||
releaseNotes="Changes"
|
||||
state="prompt"
|
||||
{...callbacks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("aria-label=\"Update verfügbar\"");
|
||||
expect(html).toContain("role=\"tooltip\"");
|
||||
expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren.");
|
||||
expect(html).toContain("role=\"dialog\"");
|
||||
expect(html).toContain("aria-modal=\"true\"");
|
||||
expect(html).toContain("Update installieren");
|
||||
expect(html).toContain("Jetzt aktualisieren");
|
||||
expect(html).toContain("Später");
|
||||
expect(html).toContain("Changes");
|
||||
expect(html).toContain("<details");
|
||||
});
|
||||
|
||||
it("keeps the update affordance but removes the dialog when the prompt is closed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateExperience
|
||||
available
|
||||
currentVersion="v2.0.12"
|
||||
latestTag="v9.9.9"
|
||||
open={false}
|
||||
progress={0}
|
||||
releaseNotes="Changes"
|
||||
state="prompt"
|
||||
{...callbacks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("aria-label=\"Update verfügbar\"");
|
||||
expect(html).not.toContain("role=\"dialog\"");
|
||||
});
|
||||
|
||||
it("renders active progress without controls that could close the installation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateExperience
|
||||
available
|
||||
currentVersion="v2.0.12"
|
||||
latestTag="v9.9.9"
|
||||
open
|
||||
progress={{ percent: 47, text: "Update-Download: 47% (47 MB / 100 MB)" }}
|
||||
releaseNotes=""
|
||||
state="downloading"
|
||||
{...callbacks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)");
|
||||
expect(html).toContain("aria-valuenow=\"47\"");
|
||||
expect(html).not.toContain("Später");
|
||||
expect(html).not.toContain("Jetzt aktualisieren");
|
||||
expect(html).not.toContain("aria-label=\"Schließen\"");
|
||||
});
|
||||
|
||||
it("preserves the original installation error in the reusable dialog", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateExperience
|
||||
available
|
||||
currentVersion="v2.0.12"
|
||||
latestTag="v9.9.9"
|
||||
open
|
||||
progress={{ percent: null, text: "Update-Fehler: Originale Prüfsummenmeldung" }}
|
||||
releaseNotes=""
|
||||
state="error"
|
||||
{...callbacks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung");
|
||||
expect(html).toContain("aria-label=\"Schließen\"");
|
||||
});
|
||||
|
||||
it("renders nothing when no update is available and no dialog is active", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateExperience
|
||||
available={false}
|
||||
currentVersion="v2.0.12"
|
||||
latestTag=""
|
||||
open={false}
|
||||
progress={0}
|
||||
releaseNotes=""
|
||||
state="prompt"
|
||||
{...callbacks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("places the update affordance in the accessible global header action group", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppHeader
|
||||
activeView="downloads"
|
||||
actions={(
|
||||
<UpdateExperience
|
||||
available
|
||||
currentVersion="v2.0.12"
|
||||
latestTag="v9.9.9"
|
||||
open={false}
|
||||
progress={0}
|
||||
releaseNotes=""
|
||||
state="prompt"
|
||||
{...callbacks}
|
||||
/>
|
||||
)}
|
||||
onViewChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("role=\"group\"");
|
||||
expect(html).toContain("aria-label=\"Globale Aktionen\"");
|
||||
expect(html).toContain("aria-label=\"Update verfügbar\"");
|
||||
});
|
||||
|
||||
it("uses the specified transient and modal elevation tokens", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.md-update-tooltip\s*\{[^}]*box-shadow:\s*0 4px 12px rgb\(0 0 0 \/ 35%\)/s);
|
||||
expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s);
|
||||
});
|
||||
|
||||
it("keeps forward and reverse tabbing inside the update dialog", () => {
|
||||
expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0);
|
||||
expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3);
|
||||
expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0);
|
||||
expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3);
|
||||
expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull();
|
||||
expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects stale update-check completions without discarding the latest state", () => {
|
||||
expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true);
|
||||
expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false);
|
||||
expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("applies only the latest result when update checks complete out of order", async () => {
|
||||
const generation = { current: 0 };
|
||||
const applied: string[] = [];
|
||||
let finishStartup: ((result: UpdateCheckResult) => void) | undefined;
|
||||
let finishManual: ((result: UpdateCheckResult) => void) | undefined;
|
||||
const startup = new Promise<UpdateCheckResult>((resolve) => { finishStartup = resolve; });
|
||||
const manual = new Promise<UpdateCheckResult>((resolve) => { finishManual = resolve; });
|
||||
const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); };
|
||||
|
||||
const startupRun = runLatestUpdateCheck(generation, () => startup, apply);
|
||||
const manualRun = runLatestUpdateCheck(generation, () => manual, apply);
|
||||
finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" });
|
||||
await manualRun;
|
||||
finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" });
|
||||
await startupRun;
|
||||
|
||||
expect(applied).toEqual(["v9.9.9"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import React, { type ReactElement } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { App } from "../src/renderer/App";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import type { ElectronApi } from "../src/shared/preload-api";
|
||||
import * as visualFixtures from "./visual/fixtures";
|
||||
import * as visualMain from "./visual/main";
|
||||
import { createVisualElectronApi } from "./visual/mock-electron-api";
|
||||
|
||||
const { createVisualFixture } = visualFixtures;
|
||||
|
||||
interface TestVisualRoot {
|
||||
innerText: string;
|
||||
textContent: string | null;
|
||||
dataset: {
|
||||
visualError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TestVisualMarker {
|
||||
visualReady?: string;
|
||||
visualScenario?: string;
|
||||
}
|
||||
|
||||
function createTestVisualBootstrap(
|
||||
search: string,
|
||||
initialInnerText: string,
|
||||
onFrame: (frame: number, rootElement: TestVisualRoot) => void,
|
||||
maxFrames = 2
|
||||
) {
|
||||
const rootElement: TestVisualRoot = {
|
||||
innerText: initialInnerText,
|
||||
textContent: initialInnerText,
|
||||
dataset: {}
|
||||
};
|
||||
const marker: TestVisualMarker = {};
|
||||
const createdRootElements: TestVisualRoot[] = [];
|
||||
const renderedElements: ReactElement[] = [];
|
||||
const assignedApis: ElectronApi[] = [];
|
||||
const markerValuesByFrame: Array<string | undefined> = [];
|
||||
const clockInstalls: string[] = [];
|
||||
let frameCount = 0;
|
||||
|
||||
const runtime = {
|
||||
search,
|
||||
rootElement,
|
||||
marker,
|
||||
maxFrames,
|
||||
installClock(): void {
|
||||
clockInstalls.push(search);
|
||||
},
|
||||
setElectronApi(api: ElectronApi): void {
|
||||
assignedApis.push(api);
|
||||
},
|
||||
createRoot(element: TestVisualRoot) {
|
||||
createdRootElements.push(element);
|
||||
return {
|
||||
render(renderedElement: ReactElement): void {
|
||||
renderedElements.push(renderedElement);
|
||||
}
|
||||
};
|
||||
},
|
||||
requestFrame(callback: FrameRequestCallback): number {
|
||||
frameCount += 1;
|
||||
markerValuesByFrame.push(marker.visualReady);
|
||||
onFrame(frameCount, rootElement);
|
||||
callback(0);
|
||||
return frameCount;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
runtime,
|
||||
rootElement,
|
||||
marker,
|
||||
createdRootElements,
|
||||
renderedElements,
|
||||
assignedApis,
|
||||
markerValuesByFrame,
|
||||
clockInstalls
|
||||
};
|
||||
}
|
||||
|
||||
describe("visual fixtures", () => {
|
||||
it("keeps empty, dense and update states deterministic and distinct", () => {
|
||||
const empty = createVisualFixture("empty");
|
||||
const dense = createVisualFixture("dense");
|
||||
const update = createVisualFixture("update");
|
||||
expect(Object.keys(empty.snapshot.session.packages)).toHaveLength(0);
|
||||
expect(Object.keys(dense.snapshot.session.packages).length).toBeGreaterThan(1);
|
||||
expect(update.update.latestTag).toBe("v9.9.9");
|
||||
expect(createVisualFixture("dense")).toEqual(dense);
|
||||
});
|
||||
|
||||
it("freezes runtime and recurring chart timers across visual frames", async () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
const originalDateNow = Date.now;
|
||||
let timerTicks = 0;
|
||||
const timerTarget = {
|
||||
setInterval(handler: TimerHandler, _timeout?: number): number {
|
||||
if (typeof handler === "function") {
|
||||
handler();
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
const restore = visualFixtures.installVisualClock(timerTarget);
|
||||
|
||||
try {
|
||||
const runtime = (): number => dense.snapshot.stats.sessionRuntimeMs
|
||||
+ Math.max(0, Date.now() - dense.snapshot.stats.runtimeMeasuredAt);
|
||||
const firstRuntime = runtime();
|
||||
const frameTimes: number[] = [];
|
||||
timerTarget.setInterval(() => { timerTicks += 1; }, 250);
|
||||
await visualFixtures.waitForVisualFrames((callback) => {
|
||||
frameTimes.push(Date.now());
|
||||
callback(0);
|
||||
return frameTimes.length;
|
||||
});
|
||||
timerTarget.setInterval(() => { timerTicks += 1; }, 250);
|
||||
timerTarget.setInterval(() => { timerTicks += 1; }, 1000);
|
||||
|
||||
expect(Date.now()).toBe(1786312800000);
|
||||
expect(firstRuntime).toBe(3600000);
|
||||
expect(runtime()).toBe(firstRuntime);
|
||||
expect(frameTimes).toEqual([1786312800000, 1786312800000]);
|
||||
expect(timerTicks).toBe(0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(Date.now).toBe(originalDateNow);
|
||||
});
|
||||
|
||||
it("aligns dense account table values with credential-derived account IDs", async () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
const settings = dense.snapshot.settings;
|
||||
const megaAccountId = getMegaDebridAccountId(settings.megaLogin);
|
||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
|
||||
|
||||
expect(megaAccountId).toBe("mda_2f92guyzhdf6j");
|
||||
expect(debridLinkKeys.map((entry) => entry.id)).toEqual([
|
||||
"dlk_1ix5qlyx6mtm1",
|
||||
"dlk_1ix5pfvlg4nkg"
|
||||
]);
|
||||
expect(settings.debridAccountStatuses[megaAccountId]?.valid).toBe(true);
|
||||
expect(settings.megaDebridAccountDailyLimitBytes[megaAccountId]).toBeGreaterThan(0);
|
||||
expect(settings.megaDebridAccountDailyUsageBytes[megaAccountId]).toBeGreaterThan(0);
|
||||
expect(settings.megaDebridAccountTotalUsageBytes[megaAccountId]).toBeGreaterThan(
|
||||
settings.megaDebridAccountDailyUsageBytes[megaAccountId]
|
||||
);
|
||||
|
||||
for (const entry of debridLinkKeys) {
|
||||
expect(settings.debridAccountStatuses[entry.id]?.valid).toBe(true);
|
||||
expect(settings.debridLinkApiKeyDailyLimitBytes[entry.id]).toBeGreaterThan(0);
|
||||
expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeGreaterThan(0);
|
||||
expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeLessThan(
|
||||
settings.debridLinkApiKeyDailyLimitBytes[entry.id]
|
||||
);
|
||||
expect(settings.debridLinkApiKeyTotalUsageBytes[entry.id]).toBeGreaterThan(
|
||||
settings.debridLinkApiKeyDailyUsageBytes[entry.id]
|
||||
);
|
||||
}
|
||||
|
||||
const debridLinkItem = Object.values(dense.snapshot.session.items).find(
|
||||
(item) => item.provider === "debridlink"
|
||||
);
|
||||
expect(debridLinkItem?.providerAccountId).toBe(debridLinkKeys[0].id);
|
||||
const hostLimits = await createVisualElectronApi(dense).getDebridLinkHostLimits();
|
||||
expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].id);
|
||||
});
|
||||
|
||||
it("stores every mutable bridge state inside the visual fixture", async () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
const api = createVisualElectronApi(dense);
|
||||
|
||||
await api.setTraceEnabled(true);
|
||||
expect(dense).toHaveProperty("traceConfig.enabled", true);
|
||||
|
||||
await api.enableRemoteDiagnostics({
|
||||
hostMode: "network",
|
||||
publicHost: "capture.example.test",
|
||||
port: 8123,
|
||||
allowlist: ["192.0.2.10"],
|
||||
name: "Capture Harness"
|
||||
});
|
||||
expect(dense).toHaveProperty("remoteDiagnostics.status.running", true);
|
||||
expect(dense).toHaveProperty("remoteDiagnostics.status.port", 8123);
|
||||
expect(dense).toHaveProperty("remoteDiagnostics.publicHost", "capture.example.test");
|
||||
|
||||
await api.disableRemoteDiagnostics();
|
||||
expect(dense).toHaveProperty("remoteDiagnostics.status.running", false);
|
||||
});
|
||||
|
||||
it("boots the dense query once and waits for both visible package names", async () => {
|
||||
expect(typeof window).toBe("undefined");
|
||||
const harness = createTestVisualBootstrap(
|
||||
"?scenario=dense",
|
||||
"Dokumentation Staffel 1",
|
||||
(frame, rootElement) => {
|
||||
if (frame === 3) {
|
||||
rootElement.innerText = "Dokumentation Staffel 1 Konzertmitschnitt 2026";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await visualMain.startVisualHarness(harness.runtime);
|
||||
|
||||
expect(harness.clockInstalls).toHaveLength(1);
|
||||
expect(harness.createdRootElements).toEqual([harness.rootElement]);
|
||||
expect(harness.renderedElements).toHaveLength(1);
|
||||
expect(harness.renderedElements[0].type).toBe(App);
|
||||
expect(harness.renderedElements[0].type).not.toBe(React.StrictMode);
|
||||
expect(harness.assignedApis).toHaveLength(1);
|
||||
const snapshot = await harness.assignedApis[0].getSnapshot();
|
||||
expect(Object.values(snapshot.session.packages).map((pkg) => pkg.name)).toEqual([
|
||||
"Dokumentation Staffel 1",
|
||||
"Konzertmitschnitt 2026",
|
||||
"Archiv mit Wiederholung"
|
||||
]);
|
||||
expect(harness.marker.visualScenario).toBe("dense");
|
||||
expect(harness.markerValuesByFrame).toEqual([undefined, undefined, undefined]);
|
||||
expect(harness.marker.visualReady).toBe("true");
|
||||
expect(harness.rootElement.dataset.visualError).toBeUndefined();
|
||||
expect(typeof window).toBe("undefined");
|
||||
});
|
||||
|
||||
it("resolves the update query and waits for visible v9.9.9 evidence", async () => {
|
||||
const harness = createTestVisualBootstrap(
|
||||
"?scenario=update",
|
||||
"Update verfügbar",
|
||||
(frame, rootElement) => {
|
||||
if (frame === 3) {
|
||||
rootElement.innerText = "Update verfügbar v9.9.9";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await visualMain.startVisualHarness(harness.runtime);
|
||||
|
||||
expect(harness.createdRootElements).toHaveLength(1);
|
||||
expect(harness.renderedElements).toHaveLength(1);
|
||||
expect(harness.marker.visualScenario).toBe("update");
|
||||
expect(harness.markerValuesByFrame).toEqual([undefined, undefined, undefined]);
|
||||
expect(harness.marker.visualReady).toBe("true");
|
||||
expect((await harness.assignedApis[0].checkUpdates()).latestTag).toBe("v9.9.9");
|
||||
});
|
||||
|
||||
it("catches missing update evidence inside the bootstrap and exposes a local error", async () => {
|
||||
const harness = createTestVisualBootstrap(
|
||||
"?scenario=update",
|
||||
"Update verfügbar",
|
||||
() => undefined,
|
||||
0
|
||||
);
|
||||
harness.marker.visualReady = "true";
|
||||
|
||||
await expect(visualMain.startVisualHarness(harness.runtime)).resolves.toBeUndefined();
|
||||
|
||||
expect(harness.createdRootElements).toHaveLength(1);
|
||||
expect(harness.renderedElements).toHaveLength(1);
|
||||
expect(harness.marker.visualScenario).toBe("update");
|
||||
expect(harness.markerValuesByFrame).toEqual([undefined, undefined]);
|
||||
expect(harness.marker.visualReady).toBeUndefined();
|
||||
expect(harness.rootElement.dataset.visualError).toBe("true");
|
||||
expect(harness.rootElement.textContent).toBe(
|
||||
'Visual-Harness-Fehler: Visual-Harness-Szenario "update" ist nicht bereit: v9.9.9 fehlt'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VISUAL_SCENARIOS } from "./visual/fixtures";
|
||||
import {
|
||||
prepareVisualCapture,
|
||||
validateVisualCaptureManifest,
|
||||
type VisualCapture
|
||||
} from "./visual/ui-driver";
|
||||
|
||||
const validCapture: VisualCapture = {
|
||||
name: "region-contract",
|
||||
scenario: "dense",
|
||||
viewport: { width: 2560, height: 1369 },
|
||||
activeView: "downloads",
|
||||
interactions: [],
|
||||
assertions: [{ type: "visible", region: "downloads-table-body" }]
|
||||
};
|
||||
|
||||
interface FakeElementOptions {
|
||||
role?: string;
|
||||
name?: string;
|
||||
region?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
readonly nodeType = 1;
|
||||
readonly tagName = "DIV";
|
||||
readonly dataset: Record<string, string> = {};
|
||||
readonly style = { display: "", visibility: "", opacity: "", zIndex: "auto" };
|
||||
readonly className: string;
|
||||
readonly role?: string;
|
||||
readonly name?: string;
|
||||
readonly region?: string;
|
||||
readonly visible: boolean;
|
||||
readonly current: boolean;
|
||||
textContent = "content";
|
||||
hidden = false;
|
||||
readonly classList: { contains: (name: string) => boolean };
|
||||
|
||||
constructor(options: FakeElementOptions) {
|
||||
this.role = options.role;
|
||||
this.name = options.name;
|
||||
this.region = options.region;
|
||||
this.className = options.className ?? "";
|
||||
this.visible = options.visible ?? true;
|
||||
this.current = options.current ?? false;
|
||||
this.classList = {
|
||||
contains: (name: string): boolean => this.className.split(/\s+/).includes(name)
|
||||
};
|
||||
if (this.region) {
|
||||
this.dataset.visualRegion = this.region;
|
||||
}
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === "role") return this.role ?? null;
|
||||
if (name === "aria-label") return this.name ?? null;
|
||||
if (name === "data-visual-region") return this.region ?? null;
|
||||
if (name === "aria-hidden") return this.visible ? null : "true";
|
||||
if (name === "aria-current" && this.current) return "page";
|
||||
return null;
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return name === "hidden" ? this.hidden : this.getAttribute(name) !== null;
|
||||
}
|
||||
|
||||
getClientRects(): { length: number } {
|
||||
return { length: this.visible ? 1 : 0 };
|
||||
}
|
||||
|
||||
querySelectorAll(): FakeElement[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeDocument(
|
||||
elements: FakeElement[],
|
||||
navigationName = "Downloads"
|
||||
): Document {
|
||||
const navigation = new FakeElement({
|
||||
role: "button",
|
||||
name: navigationName,
|
||||
className: "tab",
|
||||
current: true
|
||||
});
|
||||
Object.assign(navigation, {
|
||||
tagName: "BUTTON",
|
||||
click(): void {
|
||||
return undefined;
|
||||
},
|
||||
focus(): void {
|
||||
return undefined;
|
||||
},
|
||||
dispatchEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const all = [navigation, ...elements];
|
||||
class FakeEvent {
|
||||
constructor(readonly type: string) {}
|
||||
}
|
||||
return {
|
||||
defaultView: {
|
||||
Event: FakeEvent,
|
||||
MouseEvent: FakeEvent,
|
||||
KeyboardEvent: FakeEvent,
|
||||
requestAnimationFrame(callback: FrameRequestCallback): number {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
getComputedStyle(element: FakeElement) {
|
||||
return {
|
||||
display: element.visible ? "block" : "none",
|
||||
visibility: element.visible ? "visible" : "hidden",
|
||||
opacity: element.visible ? "1" : "0",
|
||||
zIndex: element.style.zIndex
|
||||
};
|
||||
}
|
||||
},
|
||||
querySelectorAll(selector: string): FakeElement[] {
|
||||
if (selector === "[data-visual-region]") {
|
||||
return all.filter((element) => element.region !== undefined);
|
||||
}
|
||||
return all;
|
||||
},
|
||||
querySelector(): FakeElement | null {
|
||||
return null;
|
||||
}
|
||||
} as unknown as Document;
|
||||
}
|
||||
|
||||
describe("reference capture manifest", () => {
|
||||
const manifest = JSON.parse(readFileSync(new URL("./visual/capture-manifest.json", import.meta.url), "utf8"));
|
||||
|
||||
it("defines executable dense, collector, avatar, context and info captures", () => {
|
||||
expect(VISUAL_SCENARIOS).toEqual(["empty", "dense", "update"]);
|
||||
expect(validateVisualCaptureManifest(manifest)).toEqual([]);
|
||||
expect(manifest.map((entry: { name: string }) => entry.name)).toEqual(expect.arrayContaining([
|
||||
"downloads-dense",
|
||||
"app-navigation-current",
|
||||
"collector-dense",
|
||||
"settings-dense",
|
||||
"history-dense",
|
||||
"statistics-dense",
|
||||
"avatar-menu",
|
||||
"avatar-update-tooltip",
|
||||
"context-downloads",
|
||||
"context-collector",
|
||||
"context-settings",
|
||||
"context-history",
|
||||
"context-statistics",
|
||||
"info-closed",
|
||||
"info-open",
|
||||
"info-absent"
|
||||
]));
|
||||
const collector = manifest.find((entry: { name: string }) => entry.name === "collector-dense");
|
||||
expect(collector.interactions).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: "fill", role: "textbox", name: "Links" })
|
||||
]));
|
||||
expect(collector.assertions).toContainEqual(expect.objectContaining({
|
||||
type: "minimum-row-count",
|
||||
region: "collector-table-body",
|
||||
value: 2
|
||||
}));
|
||||
const avatarUpdate = manifest.find((entry: { name: string }) => entry.name === "avatar-update-tooltip");
|
||||
expect(avatarUpdate.assertions).toContainEqual({
|
||||
type: "visible",
|
||||
role: "button",
|
||||
name: "Update verfügbar"
|
||||
});
|
||||
});
|
||||
|
||||
it("defines representative responsive captures before the final matrix", () => {
|
||||
expect(manifest).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "responsive-downloads-1920",
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "responsive-collector-1366",
|
||||
viewport: { width: 1366, height: 768 }
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "responsive-settings-1120",
|
||||
viewport: { width: 1120, height: 760 }
|
||||
})
|
||||
]));
|
||||
const minimumSettings = manifest.find((entry: { name: string }) => entry.name === "responsive-settings-1120");
|
||||
expect(minimumSettings.interactions.slice(0, 2)).toEqual([
|
||||
{ type: "click", role: "button", name: "Seitenleiste ausklappen" },
|
||||
{ type: "click", role: "button", name: "Accounts" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("covers every primary view at every supported viewport exactly once", () => {
|
||||
const primaryViews = ["downloads", "collector", "settings", "history", "statistics"];
|
||||
const supportedViewports = [
|
||||
{ width: 2560, height: 1369 },
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 1366, height: 768 },
|
||||
{ width: 1120, height: 760 }
|
||||
];
|
||||
const expectedCells = primaryViews.flatMap((activeView) =>
|
||||
supportedViewports.map((viewport) => `${activeView}@${viewport.width}x${viewport.height}`)
|
||||
);
|
||||
const matrixEntries = manifest.filter((entry: {
|
||||
name: string;
|
||||
activeView: string;
|
||||
viewport: { width: number; height: number };
|
||||
}) => primaryViews.includes(entry.activeView) && supportedViewports.some((viewport) =>
|
||||
viewport.width === entry.viewport.width && viewport.height === entry.viewport.height
|
||||
) && (
|
||||
entry.name.endsWith("-dense") ||
|
||||
entry.name.startsWith("responsive-")
|
||||
));
|
||||
const actualCells = matrixEntries.map((entry: {
|
||||
activeView: string;
|
||||
viewport: { width: number; height: number };
|
||||
}) => `${entry.activeView}@${entry.viewport.width}x${entry.viewport.height}`);
|
||||
|
||||
expect(matrixEntries).toHaveLength(20);
|
||||
expect(new Set(matrixEntries.map((entry: { name: string }) => entry.name)).size).toBe(20);
|
||||
expect([...actualCells].sort()).toEqual([...expectedCells].sort());
|
||||
});
|
||||
|
||||
it("reports every invalid required field with its manifest path", () => {
|
||||
expect(validateVisualCaptureManifest([{}])).toEqual([
|
||||
"$[0].name must be a nonempty string",
|
||||
"$[0].scenario must be one of empty, dense, update",
|
||||
"$[0].viewport must be an object",
|
||||
"$[0].activeView must be one of downloads, collector, settings, history, statistics",
|
||||
"$[0].interactions must be an array",
|
||||
"$[0].assertions must be an array"
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects region values outside the exact marker-name pattern", () => {
|
||||
expect(validateVisualCaptureManifest([{
|
||||
...validCapture,
|
||||
assertions: [{ type: "visible", region: "Downloads_Table" }]
|
||||
}])).toContain('$[0].assertions[0].region must match ^[a-z0-9]+(?:-[a-z0-9]+)*$');
|
||||
});
|
||||
|
||||
it("does not use a same-named CSS class as a region fallback", async () => {
|
||||
const document = createFakeDocument([
|
||||
new FakeElement({ className: "downloads-table-body" })
|
||||
]);
|
||||
|
||||
await expect(prepareVisualCapture(validCapture, document)).rejects.toThrow(
|
||||
'region marker "downloads-table-body" is missing'
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves one visible exact region marker", async () => {
|
||||
const document = createFakeDocument([
|
||||
new FakeElement({ region: "downloads-table-body" }),
|
||||
new FakeElement({ region: "downloads-table" })
|
||||
]);
|
||||
|
||||
await expect(prepareVisualCapture(validCapture, document)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects duplicate visible exact region markers as ambiguous", async () => {
|
||||
const document = createFakeDocument([
|
||||
new FakeElement({ region: "downloads-table-body" }),
|
||||
new FakeElement({ region: "downloads-table-body" })
|
||||
]);
|
||||
|
||||
await expect(prepareVisualCapture(validCapture, document)).rejects.toThrow(
|
||||
'region marker "downloads-table-body" is ambiguous'
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate region markers before evaluating absence", async () => {
|
||||
const document = createFakeDocument([
|
||||
new FakeElement({ region: "downloads-table-body" }),
|
||||
new FakeElement({ region: "downloads-table-body" })
|
||||
]);
|
||||
const capture: VisualCapture = {
|
||||
...validCapture,
|
||||
assertions: [{ type: "absent", region: "downloads-table-body" }]
|
||||
};
|
||||
|
||||
await expect(prepareVisualCapture(capture, document)).rejects.toThrow(
|
||||
'region marker "downloads-table-body" is ambiguous'
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the view tab when another button has the same accessible name", async () => {
|
||||
const document = createFakeDocument([
|
||||
new FakeElement({ role: "button", name: "Einstellungen", className: "menu-bar-trigger" })
|
||||
], "Einstellungen");
|
||||
const capture: VisualCapture = {
|
||||
...validCapture,
|
||||
activeView: "settings",
|
||||
assertions: [{ type: "active-view", value: "settings" }]
|
||||
};
|
||||
|
||||
await expect(prepareVisualCapture(capture, document)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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