release: ship v2.0.23 queue and account reliability fixes

Keep active packages in activation order, remove status-driven package expansion, refresh parked Mega-Debrid work after account-pool changes, normalize RapidGator aliases, aggregate missed update notes, isolate new account credentials, and update regression coverage and release documentation.
This commit is contained in:
Sucukdeluxe
2026-08-11 17:34:31 +02:00
parent b5b1ff88e5
commit f865a1e415
22 changed files with 852 additions and 395 deletions
+2 -2
View File
@@ -430,7 +430,7 @@ describe("debug-server", () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
headers: {
"X-Forwarded-For": "159.195.63.46"
"X-Forwarded-For": "203.0.113.46"
}
});
expect(response.ok).toBe(true);
@@ -439,7 +439,7 @@ describe("debug-server", () => {
const traceLogPath = getTraceLogPath();
expect(traceLogPath).toBeTruthy();
const traceText = fs.readFileSync(traceLogPath!, "utf8");
expect(traceText).toContain("clientIp=159.195.63.46");
expect(traceText).toContain("clientIp=203.0.113.46");
});
it("serves package details and package log by package query", async () => {
+160
View File
@@ -252,6 +252,166 @@ describe("download manager", () => {
expect(failures.has("realdebrid")).toBe(true);
});
it("releases only Mega-Debrid reset parks when a newly usable account appears", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
const previousSettings = {
...defaultSettings(),
megaCredentials: "old@example.test:old-secret",
megaDebridApiEnabled: true
};
const session = emptySession();
const megaPackageId = "mega-refresh-package";
const otherPackageId = "other-refresh-package";
const megaItemId = "mega-refresh-item";
const otherItemId = "other-refresh-item";
const createdAt = Date.now();
session.packageOrder = [megaPackageId, otherPackageId];
session.packages[megaPackageId] = {
id: megaPackageId,
name: "Mega refresh",
status: "downloading",
itemIds: [megaItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
} as any;
session.packages[otherPackageId] = {
id: otherPackageId,
name: "Other refresh",
status: "queued",
itemIds: [otherItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
} as any;
session.items[megaItemId] = {
id: megaItemId,
packageId: megaPackageId,
url: "https://rapidgator.net/file/mega-refresh",
provider: "megadebrid-api",
status: "queued",
retries: 1,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "mega-refresh.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "limit",
fullStatus: "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s",
createdAt,
updatedAt: createdAt
} as any;
session.items[otherItemId] = {
...session.items[megaItemId],
id: otherItemId,
packageId: otherPackageId,
url: "https://ddownload.com/file/other-refresh",
provider: "realdebrid",
fileName: "other-refresh.rar",
fullStatus: "Netzwerk-Retry in 60s"
} as any;
session.running = true;
const manager = new DownloadManager(previousSettings, session, storagePaths);
session.running = true;
session.packages[megaPackageId].status = "downloading";
session.items[megaItemId].status = "queued";
session.items[megaItemId].fullStatus = "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s";
session.items[megaItemId].provider = "megadebrid-api";
session.items[otherItemId].status = "queued";
session.items[otherItemId].fullStatus = "Netzwerk-Retry in 60s";
session.items[otherItemId].provider = "realdebrid";
const retryAfter = (manager as any).retryAfterByItem as Map<string, number>;
const retryState = (manager as any).retryStateByItem as Map<string, unknown>;
retryAfter.set(megaItemId, Date.now() + 3_600_000);
retryAfter.set(otherItemId, Date.now() + 60_000);
retryState.set(megaItemId, { unrestrictRetries: 1 });
retryState.set(otherItemId, { genericErrorRetries: 1 });
const scheduler = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupExistingExtractedArchives").mockResolvedValue(0);
manager.setSettings({
...previousSettings,
megaCredentials: "old@example.test:old-secret\nnew@example.test:new-secret",
megaDebridDisabledAccountIds: [getMegaDebridAccountId("old@example.test")]
});
expect(retryAfter.has(megaItemId)).toBe(false);
expect(retryState.has(megaItemId)).toBe(false);
expect(session.items[megaItemId].fullStatus).toBe("Wartet");
expect(session.packages[megaPackageId].status).toBe("queued");
expect(retryAfter.has(otherItemId)).toBe(true);
expect(retryState.has(otherItemId)).toBe(true);
expect(session.items[otherItemId].fullStatus).toBe("Netzwerk-Retry in 60s");
expect(scheduler).toHaveBeenCalledTimes(1);
});
it("updates the package status atomically when an active item is queued for retry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-package-status-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "retry-status-package";
const itemId = "retry-status-item";
const createdAt = Date.now();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Retry status",
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
} as any;
session.items[itemId] = {
id: itemId,
packageId,
url: "https://rapidgator.net/file/retry-status",
provider: "megadebrid-api",
status: "downloading",
retries: 1,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "retry-status.rar",
targetPath: "",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt,
updatedAt: createdAt
} as any;
const manager = new DownloadManager(defaultSettings(), session, storagePaths);
session.packages[packageId].status = "downloading";
const active = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0
};
(manager as any).queueRetry(session.items[itemId], active, 60_000, "Mega-Debrid bis Tagesreset gesperrt, Pause 60s");
expect(session.items[itemId].status).toBe("queued");
expect(session.packages[packageId].status).toBe("queued");
});
it("records history duration from the first actual package start", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
tempDirs.push(root);
+28 -2
View File
@@ -36,7 +36,12 @@ import {
getPackageProgress,
getPackageSizeProgress
} from "../src/renderer/views/downloads/DownloadsTable";
import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format";
import {
compactDownloadServiceLabel,
extractHoster,
formatHosterLabel,
normalizeDownloadServiceLabel
} from "../src/renderer/download-format";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
@@ -71,6 +76,13 @@ describe("Downloadtabellen-Spalten", () => {
expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s);
});
it("changes package collapse state only through user actions", () => {
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/App.tsx"), "utf8");
expect(source).not.toContain("autoExpandedPkgsRef");
expect(source).not.toMatch(/isExtracting[\s\S]{0,800}setCollapsedPackages/);
});
});
describe("rollende Downloadkennzahlen", () => {
@@ -175,12 +187,26 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => {
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%");
expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%");
expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%");
expect(compactDownloadStatus("Passwort knacken: 75% (3/4) · sau-geheim.part1.rar")).toBe("Passwort knacken: 75% (3/4)");
expect(compactDownloadStatus("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden");
expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend");
expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler");
expect(compactDownloadStatus("Extraction error [archive.part1.rar]: Unexpected end of file")).toBe("Extraction error");
});
it("normalizes every supported RapidGator domain to one hoster identity", () => {
const hosters = [
extractHoster("https://rapidgator.net/file/one"),
extractHoster("https://rg.to/file/two"),
extractHoster("https://cdn.rg.to/file/three"),
extractHoster("https://rapidgator.asia/file/four")
];
expect(hosters).toEqual(["rapidgator", "rapidgator", "rapidgator", "rapidgator"]);
expect(new Set(hosters).size).toBe(1);
expect(formatHosterLabel(hosters[1])).toEqual(expect.objectContaining({ compact: "RG", title: "RapidGator", iconSrc: expect.any(String) }));
});
it("removes duplicated access-mode wording from service labels", () => {
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)");
@@ -690,7 +716,7 @@ describe("downloads view", () => {
expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s);
expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s);
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*block;/s);
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*block;/s);
+38 -8
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import { sortPackagesForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[]): PackageEntry {
import { sortPackagesForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
const now = Date.now();
return {
id,
@@ -15,7 +15,8 @@ function createPackage(id: string, itemIds: string[]): PackageEntry {
enabled: true,
priority: "normal",
createdAt: now,
updatedAt: now
updatedAt: now,
downloadStartedAt
};
}
@@ -90,7 +91,7 @@ describe("sortPackagesForDisplay", () => {
expect(orderAfter).toEqual(orderBefore);
});
it("keeps package order untouched when auto sort is disabled", () => {
it("keeps package order untouched when auto sort is disabled", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
@@ -104,6 +105,35 @@ describe("sortPackagesForDisplay", () => {
const sorted = sortPackagesForDisplay(packages, items, true, false);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
});
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("keeps every active package in activation order when a new package starts", () => {
const packages = [
createPackage("pkg-new", ["new-item"], 200),
createPackage("pkg-existing", ["existing-item"], 100)
];
const items: Record<string, DownloadItem> = {
"new-item": createItem("new-item", "pkg-new", "downloading", 100),
"existing-item": createItem("existing-item", "pkg-existing", "downloading", 200)
};
const sorted = sortPackagesForDisplay(packages, items, true, true);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-existing", "pkg-new"]);
});
it("keeps queue order for active packages without a recorded start time", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
createPackage("pkg-c", ["c1"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "completed", 500),
b1: createItem("b1", "pkg-b", "downloading", 200),
c1: createItem("c1", "pkg-c", "downloading", 100)
};
expect(sortPackagesForDisplay(packages, items, true, true).map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-c", "pkg-a"]);
});
});
+19
View File
@@ -3,6 +3,7 @@ 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 { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App";
import {
applyAccountEdit,
createAccountEditState,
@@ -905,6 +906,24 @@ describe("account workspace", () => {
});
describe("settings App integration", () => {
it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret",
debridLinkApiKeys: "existing-debrid-link-key"
};
const megaDialog = createAccountDialogState("create", "megadebrid-api", settings);
const megaFields = buildAccountAddFields(megaDialog);
const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", settings));
expect(megaDialog.megaNewLogin).toBe("");
expect(megaDialog.megaNewPassword).toBe("");
expect(megaDialog.token).toBe("");
expect(megaFields.map((field) => field.id)).toEqual(["megaNewLogin", "megaNewPassword", "dailyLimitGb"]);
expect(megaFields.map((field) => field.label)).not.toContain("Token / API-Key");
expect(debridLinkFields.find((field) => field.id === "token")).toEqual(expect.objectContaining({ value: "" }));
});
it("keeps specific persistence revision-safe when the draft changes in flight", () => {
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
expect(block).toContain("revisionAtStart");
+1 -1
View File
@@ -438,7 +438,7 @@ describe("bandwidth chart palette", () => {
const collector = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
expect(theme).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--ui-focus\);/s);
expect(shell).toContain("color: var(--ui-primary-text);");
expect(shell).toContain("color: var(--ui-update-text);");
expect(collector.match(/color:\s*var\(--ui-primary-text\);/g)).toHaveLength(3);
});
+186 -174
View File
@@ -1,144 +1,144 @@
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\"");
});
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");
@@ -146,37 +146,49 @@ describe("update experience", () => {
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("uses a light-blue update affordance and a bounded scrollable changelog", () => {
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
expect(theme).toMatch(/--ui-update:\s*#BAD0FC;/);
expect(theme).toMatch(/--ui-update-hover:\s*#8AA5DC;/);
expect(theme).toMatch(/--ui-update-text:\s*#181A1F;/);
expect(css).toMatch(/\.md-update-trigger\s*\{[^}]*background:\s*var\(--ui-update\);[^}]*color:\s*var\(--ui-update-text\);/s);
expect(css).toMatch(/\.md-update-trigger:hover\s*\{[^}]*background:\s*var\(--ui-update-hover\);/s);
expect(css).toMatch(/\.md-update-release-notes pre\s*\{[^}]*max-height:\s*min\(360px, 45vh\);[^}]*overflow-y:\s*auto;/s);
});
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"]);
});
});
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"]);
});
});
+48 -2
View File
@@ -79,7 +79,7 @@ describe("update", () => {
expect(result.updateAvailable).toBe(false);
});
it("picks setup executable asset from release list", async () => {
it("picks setup executable asset from release list", async () => {
globalThis.fetch = (async (): Promise<Response> => new Response(
JSON.stringify({
tag_name: "v9.9.9",
@@ -106,7 +106,53 @@ describe("update", () => {
expect(result.updateAvailable).toBe(true);
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe");
expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe");
});
});
it("combines every stable release note newer than the installed version", async () => {
const [major = 2, minor = 0, patch = 0] = parseVersionParts(APP_VERSION);
const version = (offset: number): string => `${major}.${minor}.${patch + offset}`;
const requestedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
requestedUrls.push(url);
if (url.endsWith("/releases/latest")) {
return new Response(JSON.stringify({
tag_name: `v${version(3)}`,
html_url: `https://github.com/owner/repo/releases/tag/v${version(3)}`,
body: "Latest changes",
assets: []
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify([
{ tag_name: `v${version(3)}`, body: "Latest changes", draft: false, prerelease: false },
{ tag_name: `v${version(2)}`, body: "Middle changes", draft: false, prerelease: false },
{ tag_name: `v${version(1)}`, body: "First missed changes", draft: false, prerelease: false },
{ tag_name: `v${version(4)}`, body: "Draft changes", draft: true, prerelease: false },
{ tag_name: `v${version(5)}`, body: "Prerelease changes", draft: false, prerelease: true },
{ tag_name: `v${version(0)}`, body: "Installed changes", draft: false, prerelease: false },
{ tag_name: `v${version(-1)}`, body: "Older changes", draft: false, prerelease: false }
]), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
const result = await checkGitHubUpdate("owner/repo");
expect(requestedUrls).toEqual([
"https://api.github.com/repos/owner/repo/releases/latest",
"https://api.github.com/repos/owner/repo/releases?per_page=100&page=1"
]);
expect(result.releaseNotes).toBe([
`v${version(3)}`,
"Latest changes",
"",
`v${version(2)}`,
"Middle changes",
"",
`v${version(1)}`,
"First missed changes"
].join("\n"));
});
it("uses silent NSIS install flags with auto-run after update", () => {
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);