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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user