diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx
index 3536747..5e40ec4 100644
--- a/src/renderer/views/downloads/DownloadsView.tsx
+++ b/src/renderer/views/downloads/DownloadsView.tsx
@@ -149,10 +149,12 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
-
+
-
+
+
+
);
}
diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css
index 920a17a..425b313 100644
--- a/src/renderer/views/downloads/downloads.css
+++ b/src/renderer/views/downloads/downloads.css
@@ -204,8 +204,10 @@
.downloads-toolbar {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 6px;
min-height: 52px;
+ overflow-x: hidden;
padding: 8px 10px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface);
@@ -237,7 +239,10 @@
background: var(--ui-border);
}
-.downloads-toolbar-toggle-all {
+.downloads-toolbar-tail {
+ display: flex;
+ flex: 1 0 auto;
+ justify-content: flex-end;
margin-left: auto;
}
@@ -945,7 +950,6 @@
}
@media (max-width: 1120px) {
- .downloads-toolbar,
.downloads-footer {
overflow-x: auto;
}
diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx
index 3e203a6..b32803b 100644
--- a/tests/downloads-view.test.tsx
+++ b/tests/downloads-view.test.tsx
@@ -1108,13 +1108,23 @@ describe("downloads view", () => {
visitElements(toolbar, (element) => {
if (element.type === "button") toolbarButtons.push(element);
});
+ const tail = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-toolbar-tail"));
expect(sidebar).not.toContain("Alle ein-/ausklappen");
+ expect(tail.props.children).toBeTruthy();
expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen");
findButton(toolbar, "Alle ein-/ausklappen").props.onClick();
expect(toggles).toBe(1);
});
+ it("disables the global package disclosure action for a truly empty queue", () => {
+ const model = withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false });
+ const toolbar = DownloadsToolbar({ actions: createActions(), model });
+
+ expect(model.empty).toBe(true);
+ expect(findButton(toolbar, "Alle ein-/ausklappen").props.disabled).toBe(true);
+ });
+
it("blocks native package dragging while preserving explicit reorder actions", () => {
const model = withRuntime(createInput());
const component = PackageCardContent({
diff --git a/tests/visual/download-disclosure-transition.test.tsx b/tests/visual/download-disclosure-transition.test.tsx
index eece668..53452e0 100644
--- a/tests/visual/download-disclosure-transition.test.tsx
+++ b/tests/visual/download-disclosure-transition.test.tsx
@@ -1,107 +1,351 @@
-import { describe, expect, it } from "vitest";
-import type { DownloadLogicalRow } from "../../src/renderer/views/downloads/downloads-model";
-import {
- activateDownloadDisclosureTransition,
- prepareDownloadDisclosureTransition,
- stableDownloadDisclosureRows,
- type DownloadDisclosureRow
-} from "../../src/renderer/views/downloads/download-disclosure-transition";
-import {
- DOWNLOAD_FILE_ROW_HEIGHT,
- DOWNLOAD_PACKAGE_ROW_HEIGHT
-} from "../../src/renderer/views/downloads/download-virtualizer";
+import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync } from "node:fs";
+import { createServer } from "node:net";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
-type DisclosureFrameRow = {
- id: string;
- top: number;
- bottom: number;
- opacity: number;
+type CdpResponse = {
+ id?: number;
+ error?: unknown;
+ result?: {
+ result?: {
+ value?: unknown;
+ };
+ exceptionDetails?: unknown;
+ };
};
-function packageRow(id: string, collapsed: boolean, itemIds: readonly string[]): DownloadLogicalRow {
- return {
- type: "package",
- id,
- packageId: id,
- height: DOWNLOAD_PACKAGE_ROW_HEIGHT,
- packageRow: {
- id,
- collapsed,
- items: itemIds.map((itemId) => ({ id: itemId, packageId: id }))
+type DisclosureSample = {
+ elapsed: number;
+ groupBottom: number;
+ followingTop: number;
+ cssHeight: number;
+ cssTransform: string;
+ cssOpacity: number;
+};
+
+type ToolbarMeasurement = {
+ expanded: string | null;
+ clientWidth: number;
+ scrollWidth: number;
+ scrollLeft: number;
+ toolbar: DOMRect;
+ tail: DOMRect | null;
+ toggle: DOMRect;
+ schedule: DOMRect;
+};
+
+class CdpClient {
+ private nextId = 0;
+ private readonly pending = new Map void;
+ reject: (error: Error) => void;
+ }>();
+
+ constructor(private readonly socket: WebSocket) {
+ socket.addEventListener("message", (event) => {
+ const response = JSON.parse(String(event.data)) as CdpResponse;
+ if (!response.id) return;
+ const pending = this.pending.get(response.id);
+ if (!pending) return;
+ this.pending.delete(response.id);
+ if (response.error) pending.reject(new Error(JSON.stringify(response.error)));
+ else pending.resolve(response.result);
+ });
+ }
+
+ static async connect(url: string): Promise {
+ const socket = new WebSocket(url);
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("Chrome DevTools connection timed out")), 10_000);
+ socket.addEventListener("open", () => {
+ clearTimeout(timer);
+ resolve();
+ }, { once: true });
+ socket.addEventListener("error", () => {
+ clearTimeout(timer);
+ reject(new Error("Chrome DevTools connection failed"));
+ }, { once: true });
+ });
+ return new CdpClient(socket);
+ }
+
+ send(method: string, params: Record = {}): Promise {
+ const id = ++this.nextId;
+ this.socket.send(JSON.stringify({ id, method, params }));
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ });
+ }
+
+ async evaluate(expression: string): Promise {
+ const response = await this.send("Runtime.evaluate", {
+ expression,
+ awaitPromise: true,
+ returnByValue: true
+ });
+ if (response?.exceptionDetails) {
+ throw new Error(JSON.stringify(response.exceptionDetails));
}
- } as unknown as DownloadLogicalRow;
+ return response?.result?.value as T;
+ }
+
+ close(): void {
+ this.socket.close();
+ }
}
-function itemRow(id: string, packageId: string): DownloadLogicalRow {
- return {
- type: "item",
- id,
- packageId,
- height: DOWNLOAD_FILE_ROW_HEIGHT,
- item: { id, packageId }
- } as unknown as DownloadLogicalRow;
+function delay(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
-function logicalRows(firstCollapsed: boolean): DownloadLogicalRow[] {
- return [
- packageRow("package-a", firstCollapsed, ["a-1", "a-2"]),
- ...(firstCollapsed ? [] : [itemRow("a-1", "package-a"), itemRow("a-2", "package-a")]),
- packageRow("package-b", false, ["b-1"]),
- itemRow("b-1", "package-b")
- ];
-}
-
-function disclosureFrame(
- startRows: readonly DownloadDisclosureRow[],
- targetRows: readonly DownloadDisclosureRow[],
- progress: number
-): DisclosureFrameRow[] {
- const targetById = new Map(targetRows.map((row) => [row.id, row]));
- let top = 0;
- return startRows.map((row) => {
- const target = targetById.get(row.id) ?? row;
- const height = row.height + (target.height - row.height) * progress;
- const opacity = row.disclosureOpacity + (target.disclosureOpacity - row.disclosureOpacity) * progress;
- const frameRow = { id: row.id, top, bottom: top + height, opacity };
- top += height;
- return frameRow;
+function reservePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = createServer();
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ reject(new Error("Could not reserve a local port"));
+ return;
+ }
+ server.close((error) => {
+ if (error) reject(error);
+ else resolve(address.port);
+ });
+ });
});
}
-function prepareFrameRows(beforeCollapsed: boolean, afterCollapsed: boolean): {
- start: DownloadDisclosureRow[];
- target: DownloadDisclosureRow[];
-} {
- const prepared = prepareDownloadDisclosureTransition(
- stableDownloadDisclosureRows(logicalRows(beforeCollapsed)),
- logicalRows(afterCollapsed)
- );
- expect(prepared.animated).toBe(true);
- return {
- start: prepared.rows,
- target: activateDownloadDisclosureTransition(prepared.rows)
- };
+async function waitForJson(url: string, timeoutMs = 20_000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ let lastError: unknown;
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) return await response.json() as T;
+ } catch (error) {
+ lastError = error;
+ }
+ await delay(50);
+ }
+ throw new Error(`Endpoint did not become ready: ${url} ${String(lastError ?? "")}`);
}
-describe("download disclosure render contract", () => {
- it.each([
- { beforeCollapsed: true, afterCollapsed: false, startOpacity: 0, targetOpacity: 1 },
- { beforeCollapsed: false, afterCollapsed: true, startOpacity: 1, targetOpacity: 0 }
- ])("keeps the shared clipping group ahead of the following package in every measured frame", ({ beforeCollapsed, afterCollapsed, startOpacity, targetOpacity }) => {
- const { start, target } = prepareFrameRows(beforeCollapsed, afterCollapsed);
- const measuredFrames = [0, 0.2, 0.5, 0.8, 1].map((progress) => disclosureFrame(start, target, progress));
+function resolveChromeExecutable(): string {
+ const candidates = [
+ process.env.CHROME_PATH,
+ process.env.PROGRAMFILES ? path.join(process.env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe") : undefined,
+ process.env["PROGRAMFILES(X86)"] ? path.join(process.env["PROGRAMFILES(X86)"], "Microsoft", "Edge", "Application", "msedge.exe") : undefined,
+ "/usr/bin/google-chrome",
+ "/usr/bin/google-chrome-stable",
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser"
+ ].filter((candidate): candidate is string => Boolean(candidate));
+ const executable = candidates.find(existsSync);
+ if (!executable) throw new Error("Chrome or Chromium executable is missing");
+ return executable;
+}
- for (const frame of measuredFrames) {
- const group = frame.find((row) => row.id === "package-a:items");
- const following = frame.find((row) => row.id === "package-b");
- expect(group).toBeDefined();
- expect(following).toBeDefined();
- expect(group!.bottom).toBeLessThanOrEqual(following!.top + 1);
+describe("download disclosure in the headless visual harness", () => {
+ let visualPort = 0;
+ let chromePort = 0;
+ let viteProcess: ChildProcess | null = null;
+ let chromeProcess: ChildProcess | null = null;
+ let chromeProfile = "";
+ let client: CdpClient | null = null;
+
+ beforeAll(async () => {
+ visualPort = await reservePort();
+ chromePort = await reservePort();
+ const viteExecutable = path.join(process.cwd(), "node_modules", "vite", "bin", "vite.js");
+ viteProcess = spawn(process.execPath, [
+ viteExecutable,
+ "--config",
+ "tests/visual/vite.config.mts",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ String(visualPort),
+ "--strictPort"
+ ], {
+ cwd: process.cwd(),
+ stdio: "ignore",
+ windowsHide: true
+ });
+ await waitForJson(`http://127.0.0.1:${visualPort}/capture-manifest.json`);
+
+ chromeProfile = mkdtempSync(path.join(tmpdir(), "mdd-task4-chrome-"));
+ chromeProcess = spawn(resolveChromeExecutable(), [
+ "--headless=new",
+ "--disable-background-networking",
+ "--disable-default-apps",
+ "--disable-extensions",
+ "--disable-gpu",
+ "--no-default-browser-check",
+ "--no-first-run",
+ "--remote-debugging-address=127.0.0.1",
+ `--remote-debugging-port=${chromePort}`,
+ `--user-data-dir=${chromeProfile}`,
+ `http://127.0.0.1:${visualPort}/?scenario=dense&motion=1`
+ ], {
+ stdio: "ignore",
+ windowsHide: true
+ });
+ const targets = await waitForJson>(
+ `http://127.0.0.1:${chromePort}/json`
+ );
+ const target = targets.find((entry) => entry.type === "page" && entry.url.includes(`127.0.0.1:${visualPort}`));
+ if (!target) throw new Error("Visual harness page target is missing");
+ client = await CdpClient.connect(target.webSocketDebuggerUrl);
+ }, 30_000);
+
+ afterAll(async () => {
+ if (client) {
+ try {
+ await Promise.race([client.send("Browser.close"), delay(1_000)]);
+ } catch {
+ }
+ client.close();
+ }
+ if (chromeProcess && chromeProcess.exitCode === null) chromeProcess.kill();
+ if (viteProcess && viteProcess.exitCode === null) viteProcess.kill();
+ await delay(250);
+ if (chromeProfile) {
+ const resolvedProfile = path.resolve(chromeProfile);
+ const resolvedTempRoot = `${path.resolve(tmpdir())}${path.sep}`;
+ if (!resolvedProfile.startsWith(resolvedTempRoot)) {
+ throw new Error(`Chrome profile escaped the temporary directory: ${resolvedProfile}`);
+ }
+ rmSync(resolvedProfile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ }
+ }, 10_000);
+
+ async function loadDenseDownloads(width: number): Promise {
+ if (!client) throw new Error("Chrome DevTools client is missing");
+ await client.send("Emulation.setDeviceMetricsOverride", {
+ width,
+ height: 760,
+ deviceScaleFactor: 1,
+ mobile: false
+ });
+ await client.send("Page.navigate", {
+ url: `http://127.0.0.1:${visualPort}/?scenario=dense&motion=1&run=${Date.now()}`
+ });
+ const deadline = Date.now() + 20_000;
+ while (Date.now() < deadline) {
+ try {
+ const ready = await client.evaluate("document.documentElement.dataset.visualReady === 'true'");
+ if (ready) return;
+ } catch {
+ }
+ await delay(50);
+ }
+ throw new Error("Visual harness did not reach its ready state");
+ }
+
+ async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise {
+ if (!client) throw new Error("Chrome DevTools client is missing");
+ return client.evaluate(`(async () => {
+ const button = [...document.querySelectorAll(".downloads-collapse-button")]
+ .find((element) => element.getAttribute("aria-label")?.endsWith(" ${action}"));
+ if (!(button instanceof HTMLButtonElement)) throw new Error("Disclosure button is missing");
+ button.click();
+ const samples = [];
+ const startedAt = performance.now();
+ await new Promise((resolve) => {
+ const measure = (now) => {
+ const rows = [...document.querySelectorAll(".downloads-virtual-row")];
+ const group = rows.find((row) => row.classList.contains("is-disclosure-group"));
+ const groupIndex = rows.indexOf(group);
+ const following = rows.slice(groupIndex + 1).find((row) => !row.classList.contains("is-disclosure-group"));
+ if (group instanceof HTMLElement && following instanceof HTMLElement) {
+ const groupRect = group.getBoundingClientRect();
+ const followingRect = following.getBoundingClientRect();
+ const style = getComputedStyle(group);
+ samples.push({
+ elapsed: now - startedAt,
+ groupBottom: groupRect.bottom,
+ followingTop: followingRect.top,
+ cssHeight: Number.parseFloat(style.height),
+ cssTransform: style.transform,
+ cssOpacity: Number.parseFloat(style.opacity)
+ });
+ }
+ if (now - startedAt < 1_650) requestAnimationFrame(measure);
+ else resolve();
+ };
+ requestAnimationFrame(measure);
+ });
+ return samples;
+ })()`);
+ }
+
+ it("measures real collapse and expand frames without overlap", async () => {
+ await loadDenseDownloads(1120);
+ const collapse = await measureDisclosure("einklappen");
+ const expand = await measureDisclosure("ausklappen");
+
+ for (const samples of [collapse, expand]) {
+ expect(samples.length).toBeGreaterThan(20);
+ expect(samples.every((sample) => sample.groupBottom <= sample.followingTop + 1)).toBe(true);
+ expect(samples.some((sample) => sample.cssHeight > 1 && sample.cssHeight < 75)).toBe(true);
+ expect(samples.some((sample) => sample.cssOpacity > 0.01 && sample.cssOpacity < 0.99)).toBe(true);
+ expect(samples.every((sample) => /^matrix/.test(sample.cssTransform))).toBe(true);
}
- const groupOpacities = measuredFrames.map((frame) => frame.find((row) => row.id === "package-a:items")!.opacity);
- expect(groupOpacities[0]).toBe(startOpacity);
- expect(groupOpacities.at(-1)).toBe(targetOpacity);
- expect(groupOpacities[2]).toBe((startOpacity + targetOpacity) / 2);
- });
+ expect(collapse[0].cssHeight).toBeGreaterThan(75);
+ expect(collapse[0].cssOpacity).toBeGreaterThan(0.99);
+ expect(collapse.at(-1)!.cssHeight).toBeLessThan(1);
+ expect(collapse.at(-1)!.cssOpacity).toBeLessThan(0.01);
+ expect(expand[0].cssHeight).toBeLessThan(1);
+ expect(expand[0].cssOpacity).toBeLessThan(0.01);
+ expect(expand.at(-1)!.cssHeight).toBeGreaterThan(75);
+ expect(expand.at(-1)!.cssOpacity).toBeGreaterThan(0.99);
+ }, 30_000);
+
+ it("keeps the open schedule and toolbar tail visible at 1120 pixels without horizontal scrolling", async () => {
+ await loadDenseDownloads(1120);
+ if (!client) throw new Error("Chrome DevTools client is missing");
+ const measurement = await client.evaluate(`(async () => {
+ const toolbar = document.querySelector(".downloads-toolbar");
+ const trigger = toolbar?.querySelector("button[aria-expanded]");
+ if (!(toolbar instanceof HTMLElement) || !(trigger instanceof HTMLButtonElement)) {
+ throw new Error("Download toolbar or schedule trigger is missing");
+ }
+ trigger.click();
+ await new Promise((resolve) => setTimeout(resolve, 260));
+ const tail = toolbar.querySelector(".downloads-toolbar-tail");
+ const toggle = [...toolbar.querySelectorAll("button")]
+ .find((button) => button.textContent === "Alle ein-/ausklappen");
+ const schedule = toolbar.querySelector(".downloads-schedule-slot");
+ if (!(toggle instanceof HTMLButtonElement) || !(schedule instanceof HTMLElement)) {
+ throw new Error("Toolbar toggle or schedule slot is missing");
+ }
+ toolbar.scrollLeft = 10_000;
+ return {
+ expanded: trigger.getAttribute("aria-expanded"),
+ clientWidth: toolbar.clientWidth,
+ scrollWidth: toolbar.scrollWidth,
+ scrollLeft: toolbar.scrollLeft,
+ toolbar: toolbar.getBoundingClientRect().toJSON(),
+ tail: tail?.getBoundingClientRect().toJSON() ?? null,
+ toggle: toggle.getBoundingClientRect().toJSON(),
+ schedule: schedule.getBoundingClientRect().toJSON()
+ };
+ })()`);
+
+ expect(measurement.expanded).toBe("true");
+ expect(measurement.tail).not.toBeNull();
+ expect(measurement.scrollWidth).toBeLessThanOrEqual(measurement.clientWidth + 1);
+ expect(measurement.scrollLeft).toBe(0);
+ expect(measurement.schedule.width).toBeGreaterThan(0);
+ expect(measurement.tail!.left).toBeGreaterThanOrEqual(measurement.toolbar.left - 1);
+ expect(measurement.tail!.right).toBeLessThanOrEqual(measurement.toolbar.right + 1);
+ expect(measurement.toggle.left).toBeGreaterThanOrEqual(measurement.toolbar.left - 1);
+ expect(measurement.toggle.right).toBeLessThanOrEqual(measurement.toolbar.right + 1);
+ expect(measurement.toolbar.right - measurement.toggle.right).toBeLessThanOrEqual(12);
+ }, 30_000);
});