fix(downloads): restore actionable package states and extraction controls

Resolve child archive selections to complete multipart sets, support bulk package extraction, refresh extraction passwords at execution time, and keep archive failures scoped by their full paths.

Restore sortable download columns, preserve verified availability, classify package retry and extraction states, secure link copying through the preload bridge, and release cancelled provider work without stale cooldowns.
This commit is contained in:
Sucukdeluxe
2026-08-22 22:52:49 +02:00
parent a5920869a3
commit 55b8911e94
33 changed files with 2187 additions and 561 deletions
+3 -2
View File
@@ -12,9 +12,10 @@ describe("desktop shell", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3);
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(1);
expect(source).not.toContain("navigator.clipboard.writeText(key.token)");
expect(source).toContain("navigator.clipboard.writeText(key.masked)");
expect(source).not.toContain("navigator.clipboard.writeText");
expect(source).toContain("window.rd.writeClipboardText(key.masked)");
expect(source).toContain("Maskierte Kennung kopiert");
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { CLIPBOARD_WRITE_MAX_BYTES, validateClipboardWriteText } from "../src/main/clipboard-write";
describe("clipboard write validation", () => {
it("accepts complete large link packages up to one MiB", () => {
const text = "x".repeat(CLIPBOARD_WRITE_MAX_BYTES);
expect(validateClipboardWriteText(text)).toBe(text);
});
it("rejects empty, non-string and oversized payloads", () => {
expect(() => validateClipboardWriteText(" \n ")).toThrow(/leer/i);
expect(() => validateClipboardWriteText(4)).toThrow(/String/i);
expect(() => validateClipboardWriteText("x".repeat(CLIPBOARD_WRITE_MAX_BYTES + 1))).toThrow(/zu groß/i);
});
});
+110 -32
View File
@@ -656,8 +656,7 @@ describe("debrid service", () => {
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
});
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
it("does not cool down a Debrid-Link key when the caller aborts after more than eight seconds", async () => {
const settings = {
...defaultSettings(),
token: "",
@@ -672,13 +671,16 @@ describe("debrid service", () => {
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const controller = new AbortController();
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/downloader/add")) {
controller.abort();
throw new Error("aborted");
};
const controller = new AbortController();
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/downloader/add")) {
now += 9_000;
controller.abort("stop");
throw new Error("aborted");
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
@@ -689,9 +691,48 @@ describe("debrid service", () => {
service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal)
).rejects.toThrow();
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000);
});
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
});
it("cools down a Debrid-Link key after an internal timeout", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "",
megaPassword: "",
megaCredentials: "",
debridLinkApiKeys: "dl-key-one",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const callerController = new AbortController();
const timeoutController = new AbortController();
const signal = AbortSignal.any([callerController.signal, timeoutController.signal]);
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/downloader/add")) {
now += 9_000;
timeoutController.abort(new DOMException("The operation timed out", "TimeoutError"));
throw new Error("aborted");
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
const service = new DebridService(settings);
await expect(
service.unrestrictLink("https://rapidgator.net/file/dl-internal-timeout", signal)
).rejects.toThrow();
expect(getDebridLinkKeyCooldownStateForTests(keyId)?.remainingMs ?? 0).toBeGreaterThan(60_000);
});
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
const settings = {
@@ -2158,13 +2199,11 @@ describe("debrid service", () => {
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const controller = new AbortController();
let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1;
if (calls === 1) {
controller.abort("simulated-60s-timeout");
return Promise.reject(new Error("aborted"));
let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1;
if (calls <= REQUEST_RETRIES) {
return Promise.reject(new Error("aborted"));
}
return Promise.resolve({
fileName: "healthy.rar",
@@ -2176,7 +2215,7 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e);
expect(err).toBeTruthy();
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
@@ -2328,7 +2367,7 @@ describe("debrid service", () => {
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
}, 20000);
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
const settings = {
...defaultSettings(),
@@ -2345,13 +2384,17 @@ describe("debrid service", () => {
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
if (account?.login === "user1") {
throw new Error("aborted:debrid");
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const callerController = new AbortController();
const timeoutController = new AbortController();
const signal = AbortSignal.any([callerController.signal, timeoutController.signal]);
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
if (account?.login === "user1") {
timeoutController.abort(new DOMException("The operation timed out", "TimeoutError"));
throw new Error("aborted:debrid");
}
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
});
@@ -2359,7 +2402,7 @@ describe("debrid service", () => {
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1", signal)).rejects.toThrow();
expect(loginsSeen).toContain("user1");
expect(loginsSeen).not.toContain("user2");
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
@@ -2372,7 +2415,7 @@ describe("debrid service", () => {
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
const settings = {
...defaultSettings(),
@@ -2396,8 +2439,43 @@ describe("debrid service", () => {
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000);
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000);
it("does not cool down a Mega-Web account when the caller aborts after more than eight seconds", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const controller = new AbortController();
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => {
now += 9_000;
controller.abort("stop");
throw new Error("aborted:debrid");
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
await expect(
service.unrestrictLink("https://rapidgator.net/file/long-caller-cancel", controller.signal)
).rejects.toThrow(/aborted/i);
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000);
it("respects provider selection and does not append hidden providers", async () => {
const settings = {
+236 -31
View File
@@ -997,7 +997,7 @@ describe("deterministic stop and restart lifecycle", () => {
expect(internal.activeTasks.get(itemId)).toBe(newOwner);
});
it("emits an idle snapshot when the earliest provider cooldown expires", async () => {
it("keeps Start available while a configured account is temporarily cooling down", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z"));
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-"));
@@ -1025,15 +1025,14 @@ describe("deterministic stop and restart lifecycle", () => {
const waiting = manager.getSnapshot();
expect(waiting).toMatchObject({
canStart: false,
canStart: true,
lifecycle: {
phase: "waiting_provider",
phase: "idle",
retryAt: Date.parse("2026-08-22T08:00:01.000Z")
}
});
await vi.advanceTimersByTimeAsync(999);
expect(events.some((snapshot) => snapshot.canStart)).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(events.at(-1)).toMatchObject({
canStart: true,
@@ -2312,7 +2311,7 @@ describe("download manager", () => {
expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false);
});
it("extractNow only re-arms completed items that are not already extracted", () => {
it("extractNow only re-arms completed items that are not already extracted", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-"));
tempDirs.push(root);
@@ -2387,8 +2386,191 @@ describe("download manager", () => {
expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)");
expect((manager as any).session.items["extract-now-item-2"].fullStatus).toBe("Entpackt - Done (1.2s)");
expect((manager as any).session.items["extract-now-item-3"].fullStatus).toBe("Entpacken - Ausstehend");
expect((manager as any).session.packages[packageId].status).toBe("queued");
});
expect((manager as any).session.packages[packageId].status).toBe("queued");
});
it("extractNow on one multipart child arms only its complete archive set", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-child-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "extract-child-pkg";
const outputDir = path.join(root, "downloads", "Extract Child");
const extractDir = path.join(root, "extract", "Extract Child");
fs.mkdirSync(outputDir, { recursive: true });
const createdAt = Date.now();
const specs = [
["e01-1", "Episode.E01.part1.rar"],
["e01-2", "Episode.E01.part2.rar"],
["e02-1", "Episode.E02.part1.rar"],
["e02-2", "Episode.E02.part2.rar"]
] as const;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Extract Child",
outputDir,
extractDir,
status: "failed",
itemIds: specs.map(([id]) => id),
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
for (const [id, fileName] of specs) {
const targetPath = path.join(outputDir, fileName);
fs.writeFileSync(targetPath, Buffer.alloc(128, 3));
session.items[id] = {
id,
packageId,
url: `https://example.invalid/${fileName}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 128,
totalBytes: 128,
progressPercent: 100,
fileName,
targetPath,
resumable: true,
attempts: 1,
lastError: "Keine entpackten Dateien erkannt",
fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true, hybridExtract: true },
session,
createStoragePaths(path.join(root, "state"))
);
const postProcess = vi.fn(async () => {});
(manager as any).runPackagePostProcessing = postProcess;
manager.extractNow({ packageIds: [], itemIds: ["e01-2"] });
await waitFor(() => postProcess.mock.calls.length === 1);
expect((manager as any).session.items["e01-1"].fullStatus).toBe("Entpacken - Ausstehend");
expect((manager as any).session.items["e01-2"].fullStatus).toBe("Entpacken - Ausstehend");
expect((manager as any).session.items["e02-1"].fullStatus).toMatch(/^Entpack-Fehler/);
expect((manager as any).session.items["e02-2"].fullStatus).toMatch(/^Entpack-Fehler/);
const filter = (manager as any).manualExtractArchiveFilters.get(packageId) as Set<string>;
expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual(["episode.e01.part1.rar"]);
});
it("extractNow item selection runs only the selected archive through real post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-real-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "Selected");
const extractDir = path.join(root, "extract", "Selected");
fs.mkdirSync(outputDir, { recursive: true });
const firstArchive = path.join(outputDir, "Episode.E01.zip");
const secondArchive = path.join(outputDir, "Episode.E02.zip");
const firstZip = new AdmZip();
firstZip.addFile("Episode.E01.mkv", Buffer.from("episode-one"));
firstZip.writeZip(firstArchive);
const secondZip = new AdmZip();
secondZip.addFile("Episode.E02.mkv", Buffer.from("episode-two"));
secondZip.writeZip(secondArchive);
const createdAt = Date.now();
const session = emptySession();
const packageId = "selected-real-package";
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Selected",
outputDir,
extractDir,
status: "failed",
itemIds: ["selected-e01", "selected-e02"],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
for (const [id, archivePath] of [["selected-e01", firstArchive], ["selected-e02", secondArchive]] as const) {
const size = fs.statSync(archivePath).size;
session.items[id] = {
id,
packageId,
url: `https://example.invalid/${path.basename(archivePath)}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: size,
totalBytes: size,
progressPercent: 100,
fileName: path.basename(archivePath),
targetPath: archivePath,
resumable: true,
attempts: 1,
lastError: "Keine entpackten Dateien erkannt",
fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir,
extractDir,
autoExtract: false,
hybridExtract: true,
cleanupMode: "none",
removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false,
autoRename4sf4sj: false,
keepGermanAudioOnly: false
},
session,
createStoragePaths(path.join(root, "state"))
);
manager.extractNow({ packageIds: [], itemIds: ["selected-e01"] });
await waitFor(() => fs.existsSync(path.join(extractDir, "Episode.E01.mkv")), 10_000);
await waitFor(() => !(manager as any).packagePostProcessTasks.has(packageId), 10_000);
await waitFor(() => !(manager as any).packageDeferredPostProcessTasks.has(packageId), 10_000);
const snapshot = manager.getSnapshot().session;
expect(snapshot.items["selected-e01"].fullStatus).toMatch(/^Entpackt/);
expect(snapshot.items["selected-e02"].fullStatus).toMatch(/^Entpack-Fehler/);
expect(snapshot.packages[packageId].status).toBe("failed");
expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false);
}, 15_000);
it("assigns same-named archive failures only to the matching directory", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const firstPath = path.join(root, "Season 01", "release.part1.rar");
const secondPath = path.join(root, "Season 02", "release.part1.rar");
const items = [
{ id: "season-1", status: "completed", fullStatus: "Entpacken - Error", fileName: "release.part1.rar", targetPath: firstPath, downloadedBytes: 100 },
{ id: "season-2", status: "completed", fullStatus: "Entpack-Fehler: Previous", fileName: "release.part1.rar", targetPath: secondPath, downloadedBytes: 100 }
] as unknown as DownloadItem[];
const failures = new Map([[firstPath.toLowerCase(), {
archiveName: "release.part1.rar",
archivePath: firstPath,
errorText: "CRC failed"
}]]);
(manager as any).applyPackageExtractFailureStatuses(
items,
(archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, items, archivePath),
failures,
"Entpacken fehlgeschlagen",
new Map(items.map((item) => [item.id, item.fullStatus])),
Date.now()
);
expect(items[0].fullStatus).toMatch(/^Entpack-Fehler/);
expect(items[1].fullStatus).toBe("Entpack-Fehler: Previous");
});
it("merges duplicate-suffixed completed startup items back into the canonical queued item", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-"));
@@ -6831,9 +7013,10 @@ describe("download manager", () => {
const changed = (manager as any).autoRecoverArchiveCrcFailure(
session.packages[packageId],
itemIds.map((itemId) => session.items[itemId]!),
{
archiveName: "show.s01e01.part1.rar",
errorText: "Checksum error in the encrypted file",
{
archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file",
category: "crc_error",
suggestRedownload: true,
jvmFailureReason: "Can not open the file as archive"
@@ -6924,9 +7107,10 @@ describe("download manager", () => {
const changed = (manager as any).autoRecoverArchiveCrcFailure(
session.packages[packageId],
itemIds.map((itemId) => session.items[itemId]!),
{
archiveName: "show.s01e01.part1.rar",
errorText: "Checksum error in the encrypted file",
{
archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file",
category: "crc_error",
suggestRedownload: true,
jvmFailureReason: "Can not open the file as archive"
@@ -7017,9 +7201,10 @@ describe("download manager", () => {
const changed = (manager as any).autoRecoverArchiveCrcFailure(
session.packages[packageId],
itemIds.map((itemId) => session.items[itemId]!),
{
archiveName: "show.s01e01.part1.rar",
errorText: "Checksum error in the encrypted file",
{
archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file",
category: "crc_error",
suggestRedownload: true,
jvmFailureReason: "Can not open the file as archive"
@@ -7358,7 +7543,7 @@ describe("download manager", () => {
}
completedItems[0].fullStatus = "Entpacken - Error";
completedItems[1].fullStatus = "Entpacken - Error";
const resolveArchiveItems = (archiveName: string) => {
const resolveArchiveItems = (archiveName: string, _archivePath?: string) => {
const base = archiveName.replace(/\.part0*1\.rar$/i, "");
return completedItems.filter((item: any) => String(item.fileName || "").toLowerCase().startsWith(`${base}.part`));
};
@@ -7367,7 +7552,11 @@ describe("download manager", () => {
{},
completedItems,
resolveArchiveItems,
new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]),
new Map([["show.s01e01.part1.rar", {
archiveName: "show.s01e01.part1.rar",
archivePath: path.resolve("show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file"
}]]),
"Checksum error in the encrypted file",
previousStatuses,
createdAt + 5_000
@@ -7415,8 +7604,12 @@ describe("download manager", () => {
(DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call(
{},
completedItems,
(archiveName: string) => resolveArchiveItemsFromList(archiveName, completedItems),
new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]),
(archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, completedItems, archivePath),
new Map([["show.s01e01.part1.rar", {
archiveName: "show.s01e01.part1.rar",
archivePath: path.resolve("show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file"
}]]),
"Checksum error in the encrypted file",
previousStatuses,
createdAt + 5_000
@@ -8228,17 +8421,21 @@ describe("download manager", () => {
autoExtract: false
},
session,
createStoragePaths(path.join(root, "state"))
);
manager.clearAll();
const snapshot = manager.getSnapshot();
createStoragePaths(path.join(root, "state"))
);
(manager as any).manualExtractArchiveFilters.set(packageId, new Set([targetPath]));
(manager as any).manualExtractPackages.add(packageId);
manager.clearAll();
const snapshot = manager.getSnapshot();
expect(snapshot.stats.totalPackages).toBe(0);
expect(snapshot.stats.totalFiles).toBe(0);
expect(snapshot.stats.totalDownloaded).toBe(0);
expect(snapshot.session.totalDownloadedBytes).toBe(0);
expect(snapshot.session.runStartedAt).toBe(0);
});
expect(snapshot.session.totalDownloadedBytes).toBe(0);
expect(snapshot.session.runStartedAt).toBe(0);
expect((manager as any).manualExtractArchiveFilters.size).toBe(0);
expect((manager as any).manualExtractPackages.size).toBe(0);
});
it("keeps cumulative session totals when completed items are removed from the queue", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
@@ -9237,7 +9434,7 @@ describe("download manager", () => {
expect(snap.settings.providerDailyUsageBytes || {}).toEqual({});
});
it("resets extraction state atomically for selected package items", () => {
it("resets extraction state without discarding definitive link availability", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
@@ -9293,7 +9490,9 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state"))
);
manager.resetItems(itemIds);
(manager as any).manualExtractArchiveFilters.set(packageId, new Set(["stale-archive"]));
await manager.resetItems(itemIds);
expect((manager as any).manualExtractArchiveFilters.has(packageId)).toBe(false);
const snapshot = manager.getSnapshot().session;
expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({
@@ -9309,9 +9508,15 @@ describe("download manager", () => {
progressPercent: 0,
lastError: "",
fullStatus: "Wartet",
onlineStatus: undefined
onlineStatus: "online"
}));
}
await manager.resetPackage(packageId);
const packageSnapshot = manager.getSnapshot().session;
for (const itemId of itemIds) {
expect(packageSnapshot.items[itemId].onlineStatus).toBe("online");
}
});
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { sortPackageOrderByService } from "../src/renderer/App";
describe("download package sorting", () => {
it("sorts the Service column by its visible provider labels", () => {
const packages = {
a: { id: "a", itemIds: ["item-a"] },
b: { id: "b", itemIds: ["item-b"] }
} as any;
const items = {
"item-a": { provider: "realdebrid", providerLabel: "Real-Debrid" },
"item-b": { provider: "debridlink", providerLabel: "Debrid-Link" }
} as any;
expect(sortPackageOrderByService(["a", "b"], packages, items, false)).toEqual(["b", "a"]);
expect(sortPackageOrderByService(["a", "b"], packages, items, true)).toEqual(["a", "b"]);
});
it("uses filtered visible services instead of hidden package items", () => {
const packages = {
a: { id: "a", itemIds: ["a-hidden", "a-visible"] },
b: { id: "b", itemIds: ["b-visible"] }
} as any;
const items = {
"a-visible": { provider: "debridlink", providerLabel: "ZZZ Visible" },
"a-hidden": { provider: "realdebrid", providerLabel: "AAA Hidden" },
"b-visible": { provider: "realdebrid", providerLabel: "Real-Debrid" }
} as any;
expect(sortPackageOrderByService(["b", "a"], packages, items, false, {
a: [items["a-visible"]],
b: [items["b-visible"]]
})).toEqual(["b", "a"]);
});
});
+127 -5
View File
@@ -777,6 +777,36 @@ function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label);
}
function dispatchColumnSortPointerGesture(header: ReactElement, label: string, clientXs: readonly number[], deliverPointerClick: boolean): void {
const startX = clientXs[0];
const endX = clientXs[clientXs.length - 1];
if (startX === undefined || endX === undefined) throw new Error("Pointer gesture requires coordinates");
const columnHeader = findElement(header, (element) => element.props["data-download-column"] === "name");
const sortButton = findElement(columnHeader, (element) => element.type === "button" && String(element.props.children).startsWith(label));
const capturedPointers = new Set<number>();
const currentTarget = {
closest: () => null,
hasPointerCapture: (pointerId: number) => capturedPointers.has(pointerId),
releasePointerCapture: (pointerId: number) => capturedPointers.delete(pointerId),
setPointerCapture: (pointerId: number) => capturedPointers.add(pointerId)
};
const target = { closest: (selector: string) => selector === ".downloads-column-sort" ? {} : null };
const event = (clientX: number) => ({
button: 0,
clientX,
currentTarget,
isPrimary: true,
pointerId: 7,
preventDefault: () => {},
target
});
columnHeader.props.onPointerDown(event(startX));
clientXs.slice(1, -1).forEach((clientX) => columnHeader.props.onPointerMove(event(clientX)));
columnHeader.props.onPointerUp(event(endX));
if (deliverPointerClick) sortButton.props.onClick({ detail: 1 });
}
function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsViewModel> = {}): DownloadsViewModel {
return {
...buildDownloadsViewModel(input),
@@ -1523,6 +1553,9 @@ describe("download table row contracts", () => {
expect(getAvailabilitySummary([
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
])).toEqual({ online: 0, total: 1, state: "checking" });
expect(getAvailabilitySummary([
item("active-a", "package-a", "downloading", { onlineStatus: undefined })
])).toEqual({ online: 1, total: 1, state: "online" });
});
it("shows reset package availability as one compact unchecked label", () => {
@@ -1549,6 +1582,20 @@ describe("download table row contracts", () => {
expect(html).not.toContain(">online</span>");
});
it("shows an actively downloading item as online even without a stored availability result", () => {
const html = renderToStaticMarkup(ItemRowContent({
actions: createActions(),
columnOrder: ["name", "availability"],
gridTemplate: "200px 150px",
item: item("active-availability", "package-a", "downloading", { onlineStatus: undefined }),
selected: false
}));
expect(html).toContain(">Online</span>");
expect(html).not.toContain(">Ungeprüft</span>");
expect(html).toContain('class="downloads-link-state online"');
});
it("renders availability for package and file rows", () => {
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
const packageHtml = renderToStaticMarkup(PackageCardContent({
@@ -1613,7 +1660,7 @@ describe("download table row contracts", () => {
selectedVersion: 0
}));
expect(html).toContain(">70%</b>");
expect(html).toContain(">94%</b>");
});
it("never exposes archive filenames as the visible package status", () => {
@@ -1779,7 +1826,7 @@ describe("download table row contracts", () => {
expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/);
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/);
expect(html).not.toMatch(/aria-sort="[^"]+"[^>]*data-download-column="account"/);
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="account"/);
expect(moveLeft.props.type).toBe("button");
expect(calls).toEqual([
["down", "size", 250],
@@ -1788,6 +1835,81 @@ describe("download table row contracts", () => {
]);
});
it.each([
{ clientXs: [100, 100], deliverPointerClick: false },
{ clientXs: [100, 104, 104], deliverPointerClick: true }
])("sorts exactly once when a captured pointer gesture stays below the drag threshold", ({ clientXs, deliverPointerClick }) => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["name", "size"],
gridTemplate: "200px 100px",
selectedCount: 0,
sortColumn: "name",
sortDirection: "asc",
visibleIds: []
});
dispatchColumnSortPointerGesture(header, "Name", clientXs, deliverPointerClick);
expect(sorted).toEqual(["name"]);
});
it("never sorts when a pointer gesture reaches the drag threshold", () => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["name", "size"],
gridTemplate: "200px 100px",
selectedCount: 0,
sortColumn: "name",
sortDirection: "asc",
visibleIds: []
});
dispatchColumnSortPointerGesture(header, "Name", [100, 105, 101], true);
expect(sorted).toEqual([]);
});
it("keeps sortable headers keyboard operable", () => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["name", "size"],
gridTemplate: "200px 100px",
selectedCount: 0,
sortColumn: "name",
sortDirection: "asc",
visibleIds: []
});
const sortButton = findElement(header, (element) => element.type === "button" && String(element.props.children).startsWith("Name"));
sortButton.props.onClick({ detail: 0 });
expect(sorted).toEqual(["name"]);
});
it("exposes Service as a sortable column header", () => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["account"],
gridTemplate: "100px",
selectedCount: 0,
sortColumn: "service",
sortDirection: "desc",
visibleIds: []
});
const serviceHeader = findElement(header, (element) => element.props["data-download-column"] === "account");
const sortButton = findElement(serviceHeader, (element) => element.type === "button");
sortButton.props.onClick({ detail: 0 });
expect(serviceHeader.props["aria-sort"]).toBe("descending");
expect(sorted).toEqual(["service"]);
});
it("opens the column menu without letting the same context event close it again", () => {
const calls: Array<[string, number, number]> = [];
const header = DownloadsTableHeader({
@@ -2088,7 +2210,7 @@ describe("download table row contracts", () => {
selectedVersion: 0
}));
expect(html).toMatch(/title="0\/1 · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
expect(html).toMatch(/title="0\/1 fertig · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
});
it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => {
@@ -2163,7 +2285,7 @@ describe("download table row contracts", () => {
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/);
expect(errorHtml).toMatch(/>Download fertig · 1 Entpackfehler<\/span>/);
expect(errorHtml).not.toMatch(/>2\/2<\/span>/);
});
@@ -2182,7 +2304,7 @@ describe("download table row contracts", () => {
}));
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
expect(html).toContain('title="0/1"');
expect(html).toContain('title="0/1 fertig"');
});
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import { buildExtractNowContextAction } from "../src/renderer/views/downloads/extract-action";
function item(id: string, packageId: string, status: DownloadItem["status"], fullStatus: string): DownloadItem {
return {
id,
packageId,
url: `https://example.invalid/${id}`,
provider: "realdebrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: status === "completed" ? 100 : 0,
totalBytes: 100,
progressPercent: status === "completed" ? 100 : 0,
fileName: `${id}.part1.rar`,
targetPath: `C:\\Downloads\\${id}.part1.rar`,
resumable: true,
attempts: 0,
lastError: "",
fullStatus,
createdAt: 1,
updatedAt: 1
};
}
function pkg(id: string, itemIds: string[]): PackageEntry {
return {
id,
name: id,
outputDir: `C:\\Downloads\\${id}`,
extractDir: `C:\\Downloads\\_entpackt\\${id}`,
itemIds,
enabled: true,
cancelled: false,
status: "completed",
priority: "normal",
createdAt: 1,
updatedAt: 1
};
}
describe("extract now context action", () => {
it("targets one completed child item so the manager can resolve its complete archive set", () => {
const items = { part2: item("part2", "pkg-1", "completed", "Fertig") };
const action = buildExtractNowContextAction({
contextItemId: "part2",
selectedPackageIds: [],
selectedItemIds: ["part2"],
packages: { "pkg-1": pkg("pkg-1", ["part2"]) },
items
});
expect(action).toEqual({
label: "Jetzt entpacken",
request: { packageIds: [], itemIds: ["part2"] },
targetCount: 1
});
});
it("targets every selected package that has completed unextracted files", () => {
const items = {
a: item("a", "pkg-a", "completed", "Entpack-Fehler: Passwort"),
b: item("b", "pkg-b", "completed", "Entpacken - Ausstehend"),
c: item("c", "pkg-c", "queued", "Wartet")
};
const action = buildExtractNowContextAction({
selectedPackageIds: ["pkg-a", "pkg-b", "pkg-c"],
selectedItemIds: [],
packages: {
"pkg-a": pkg("pkg-a", ["a"]),
"pkg-b": pkg("pkg-b", ["b"]),
"pkg-c": pkg("pkg-c", ["c"])
},
items
});
expect(action).toEqual({
label: "Jetzt entpacken (2)",
request: { packageIds: ["pkg-a", "pkg-b"], itemIds: [] },
targetCount: 2
});
});
it("hides the action for extracted or incomplete selections", () => {
const items = {
extracted: item("extracted", "pkg-1", "completed", "Entpackt in 4s"),
queued: item("queued", "pkg-2", "queued", "Wartet")
};
expect(buildExtractNowContextAction({
selectedPackageIds: ["pkg-1", "pkg-2"],
selectedItemIds: [],
packages: {
"pkg-1": pkg("pkg-1", ["extracted"]),
"pkg-2": pkg("pkg-2", ["queued"])
},
items
})).toBeNull();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { normalizeExtractNowRequest } from "../src/shared/extract-now";
describe("extract now request", () => {
it("deduplicates package and item targets while preserving their order", () => {
expect(normalizeExtractNowRequest({
packageIds: ["pkg-2", "pkg-1", "pkg-2"],
itemIds: ["item-2", "item-1", "item-2"]
})).toEqual({
packageIds: ["pkg-2", "pkg-1"],
itemIds: ["item-2", "item-1"]
});
});
it("rejects empty, malformed and oversized selections", () => {
expect(() => normalizeExtractNowRequest({ packageIds: [], itemIds: [] })).toThrow(/mindestens/i);
expect(() => normalizeExtractNowRequest({ packageIds: ["pkg"], itemIds: [4] })).toThrow(/itemIds/i);
expect(() => normalizeExtractNowRequest({ packageIds: Array.from({ length: 2001 }, (_, index) => `pkg-${index}`), itemIds: [] })).toThrow(/höchstens/i);
expect(() => normalizeExtractNowRequest({ packageIds: Array.from({ length: 2001 }, () => "pkg"), itemIds: [] })).toThrow(/höchstens/i);
expect(() => normalizeExtractNowRequest({ packageIds: ["p".repeat(257)], itemIds: [] })).toThrow(/Länge/i);
expect(() => normalizeExtractNowRequest({ packageIds: ["pkg"], itemIds: [], extra: true })).toThrow(/unbekannt/i);
expect(() => normalizeExtractNowRequest(null)).toThrow(/Objekt/i);
});
});
+9
View File
@@ -171,6 +171,15 @@ describe("renderer localization", () => {
["Geplant: Heute 22:15", "Scheduled: Today 22:15"],
["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"],
["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"],
["7 Entpackfehler · 1 Wiederholung", "7 extraction errors · 1 retry"],
["Download fertig · 1 Entpackfehler", "Download complete · 1 extraction error"],
["Jetzt entpacken (2)", "Extract now (2)"],
["1 Entpackfehler", "1 extraction error"],
["2 Wiederholungen", "2 retries"],
["Download fertig", "Download complete"],
["1 Fehler · 2 abgebrochen", "1 error · 2 cancelled"],
["1 Entpackfehler · 2 Fehler", "1 extraction error · 2 errors"],
["Warte auf Festplatte", "Waiting for disk"],
["Entpacken 52%", "Extracting 52%"],
["Fehlgeschlagen nach 3 Versuchen: HTTP 503 von https://host.test/a", "Failed after 3 attempts: HTTP 503 von https://host.test/a"],
["Update-Check fehlgeschlagen: ECONNRESET https://api.test/v1", "Update check failed: ECONNRESET https://api.test/v1"],
+119
View File
@@ -0,0 +1,119 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { LinkAddressesDialog, type LinkAddressesDialogProps } from "../src/renderer/ui/LinkAddressesDialog";
function findElements(node: ReactNode, predicate: (element: ReactElement<Record<string, unknown>>) => boolean): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) {
return node.flatMap((child) => findElements(child, predicate));
}
if (!isValidElement<Record<string, unknown>>(node)) {
return [];
}
const matches = predicate(node) ? [node] : [];
return [...matches, ...findElements(node.props.children as ReactNode, predicate)];
}
function createDialog(overrides: Partial<LinkAddressesDialogProps> = {}): ReactElement {
return LinkAddressesDialog({
title: "Testpaket",
links: [
{ name: "Erste Datei.mkv", url: "https://example.com/first" },
{ name: "Zweite Datei.mkv", url: "https://example.com/second" }
],
isPackage: true,
onClose: vi.fn(),
writeClipboardText: vi.fn(async () => true),
onToast: vi.fn(),
...overrides
});
}
function buttonByText(tree: ReactElement, label: string): ReactElement<Record<string, unknown>> {
const button = findElements(tree, (element) => element.type === "button" && element.props.children === label)[0];
expect(button, `Button ${label} fehlt`).toBeDefined();
return button;
}
async function click(button: ReactElement<Record<string, unknown>>): Promise<void> {
const onClick = button.props.onClick as (() => void | Promise<void>) | undefined;
expect(onClick).toBeTypeOf("function");
await onClick?.();
}
describe("LinkAddressesDialog", () => {
it("kopiert einzelne Namen und URLs ausschließlich über den sicheren Writer", async () => {
const writeClipboardText = vi.fn(async () => true);
const onToast = vi.fn();
const tree = createDialog({ writeClipboardText, onToast });
const firstName = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Erste Datei.mkv kopieren")[0];
const firstUrl = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Link kopieren")[0];
await click(firstName);
await click(firstUrl);
expect(writeClipboardText).toHaveBeenNthCalledWith(1, "Erste Datei.mkv");
expect(writeClipboardText).toHaveBeenNthCalledWith(2, "https://example.com/first");
expect(onToast).toHaveBeenNthCalledWith(1, "Name kopiert");
expect(onToast).toHaveBeenNthCalledWith(2, "Link kopiert");
});
it("meldet Erfolg nur bei true und behandelt false sowie Ablehnungen als Fehler", async () => {
const writeClipboardText = vi.fn()
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error("clipboard unavailable"));
const onToast = vi.fn();
const tree = createDialog({ writeClipboardText, onToast });
await click(buttonByText(tree, "Alle Namen kopieren"));
await click(buttonByText(tree, "Alle Links kopieren"));
expect(onToast).toHaveBeenNthCalledWith(1, "Kopieren fehlgeschlagen");
expect(onToast).toHaveBeenNthCalledWith(2, "Kopieren fehlgeschlagen");
expect(onToast).not.toHaveBeenCalledWith("Alle Namen kopiert");
expect(onToast).not.toHaveBeenCalledWith("Alle Links kopiert");
});
it("übergibt große Pakettexte ohne Kürzung oder Normalisierung", async () => {
const longName = `Groß-${"n".repeat(300_000)}`;
const longUrl = `https://example.com/${"u".repeat(300_000)}`;
const writeClipboardText = vi.fn(async () => true);
const onToast = vi.fn();
const tree = createDialog({
links: [
{ name: longName, url: longUrl },
{ name: " Zeilenende ", url: "https://example.com/trailing " }
],
writeClipboardText,
onToast
});
await click(buttonByText(tree, "Alle Namen kopieren"));
await click(buttonByText(tree, "Alle Links kopieren"));
expect(writeClipboardText).toHaveBeenNthCalledWith(1, `${longName}\n Zeilenende `);
expect(writeClipboardText).toHaveBeenNthCalledWith(2, `${longUrl}\nhttps://example.com/trailing `);
expect(onToast).toHaveBeenNthCalledWith(1, "Alle Namen kopiert");
expect(onToast).toHaveBeenNthCalledWith(2, "Alle Links kopiert");
});
it("behält Dialogdesign, Paketaktionen und Schließen-Verhalten bei", async () => {
const onClose = vi.fn();
const packageTree = createDialog({ onClose });
const singleTree = createDialog({ isPackage: false });
const dialog = findElements(packageTree, (element) => typeof element.type === "function")[0];
expect(dialog.props.className).toBe("link-popup");
expect(dialog.props.size).toBe("wide");
expect(dialog.props.title).toBe("Linkadressen anzeigen");
expect(findElements(packageTree, (element) => element.props.className === "link-popup-row")).toHaveLength(2);
expect(findElements(packageTree, (element) => element.props.className === "link-popup-name link-popup-click")).toHaveLength(2);
expect(findElements(packageTree, (element) => element.props.className === "link-popup-url link-popup-click")).toHaveLength(2);
expect(buttonByText(packageTree, "Alle Namen kopieren")).toBeDefined();
expect(buttonByText(packageTree, "Alle Links kopieren")).toBeDefined();
expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Namen kopieren")).toHaveLength(0);
expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Links kopieren")).toHaveLength(0);
await click(buttonByText(packageTree, "Schließen"));
expect(onClose).toHaveBeenCalledOnce();
});
});
+55 -5
View File
@@ -346,7 +346,7 @@ describe("mega-web-fallback", () => {
expect(maxActiveLogins).toBe(2);
}, 10000);
it("aborts pending Mega-Web polling when signal is cancelled", async () => {
it("aborts pending Mega-Web polling when signal is cancelled", async () => {
globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
const urlStr = String(url);
@@ -394,10 +394,60 @@ describe("mega-web-fallback", () => {
await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i);
} finally {
clearTimeout(timer);
}
});
it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => {
}
});
it("starts an already queued account job after caller abort even when the old raw job ignores its signal", async () => {
let releaseFirstLogin: () => void = () => {};
let markFirstLoginStarted: () => void = () => {};
const firstLoginGate = new Promise<void>((resolve) => {
releaseFirstLogin = resolve;
});
const firstLoginStarted = new Promise<void>((resolve) => {
markFirstLoginStarted = resolve;
});
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
const internals = fallback as unknown as {
login: (login: string, password: string) => Promise<string>;
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string }>;
sessions: Map<string, { cookie: string; setAt: number }>;
};
let loginCount = 0;
vi.spyOn(internals, "login").mockImplementation(async () => {
loginCount += 1;
if (loginCount === 1) {
markFirstLoginStarted();
await firstLoginGate;
return "stale-cookie";
}
return "fresh-cookie";
});
vi.spyOn(internals, "generate").mockImplementation(async (link, cookie) => ({
directUrl: `https://mega.direct/${cookie}/${link.endsWith("second") ? "second" : "first"}`,
fileName: "result.bin"
}));
const firstController = new AbortController();
const first = fallback.unrestrict("https://mega.debrid/first", firstController.signal, { login: "same", password: "pw" });
await firstLoginStarted;
const second = fallback.unrestrict("https://mega.debrid/second", undefined, { login: "same", password: "pw" });
firstController.abort("stop");
await expect(first).rejects.toThrow(/aborted/i);
try {
const outcome = await Promise.race([
second.then((result) => result?.directUrl || "missing"),
new Promise<string>((resolve) => setTimeout(() => resolve("blocked"), 150))
]);
expect(outcome).toBe("https://mega.direct/fresh-cookie/second");
} finally {
releaseFirstLogin();
}
await new Promise((resolve) => setTimeout(resolve, 20));
expect(internals.sessions.get("same")?.cookie).toBe("fresh-cookie");
});
it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => {
let releaseLogin: () => void = () => {};
const loginGate = new Promise<void>((resolve) => { releaseLogin = resolve; });
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import type { DownloadPackageRow } from "../src/renderer/views/downloads/downloads-model";
import { buildPackagePresentation } from "../src/renderer/views/downloads/package-presentation";
function item(id: string, fullStatus: string, overrides: Partial<DownloadItem> = {}): DownloadItem {
return {
id,
packageId: "pkg",
url: `https://example.invalid/${id}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 100,
totalBytes: 100,
progressPercent: 100,
fileName: `${id}.rar`,
targetPath: `C:\\Downloads\\${id}.rar`,
resumable: true,
attempts: 0,
lastError: "",
fullStatus,
createdAt: 1,
updatedAt: 1,
...overrides
};
}
function row(items: DownloadItem[], overrides: Partial<PackageEntry> = {}): DownloadPackageRow {
const entry = {
id: "pkg",
name: "Paket",
outputDir: "C:\\Downloads\\Paket",
extractDir: "C:\\Downloads\\_entpackt\\Paket",
itemIds: items.map((entry) => entry.id),
enabled: true,
cancelled: false,
status: "completed",
priority: "normal",
createdAt: 1,
updatedAt: 1,
...overrides
} as PackageEntry;
return { package: entry, items, allItems: items, collapsed: true };
}
describe("download package presentation", () => {
it("reserves 90 percent for completed downloads and 10 percent for extraction", () => {
expect(buildPackagePresentation(row([
item("a", "Entpack-Fehler: Passwort"),
item("b", "Entpack-Fehler: CRC")
])).progress.value).toBe(90);
expect(buildPackagePresentation(row([
item("a", "Entpackt in 4s"),
item("b", "Entpack-Fehler: CRC")
])).progress.value).toBe(95);
expect(buildPackagePresentation(row([
item("a", "Entpackt in 4s"),
item("b", "Entpackt in 5s")
])).progress.value).toBe(100);
});
it("keeps ordinary completed downloads at 100 percent when no extraction phase exists", () => {
const presentation = buildPackagePresentation(row([
item("a", "Fertig"),
item("b", "Fertig")
]));
expect(presentation.progress.value).toBe(100);
expect(presentation.status).toBe("Fertig");
});
it("does not move backwards when an archive download enters extraction", () => {
const active = item("archive", "Download läuft", {
status: "downloading",
downloadedBytes: 99,
progressPercent: 99
});
const before = buildPackagePresentation(row([active], { status: "downloading" }));
const after = buildPackagePresentation(row([{ ...active, status: "completed", downloadedBytes: 100, progressPercent: 100, fullStatus: "Entpacken - Ausstehend" }], { status: "extracting" }));
expect(before.progress.value).toBe(89);
expect(after.progress.value).toBe(90);
});
it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => {
const items = [
...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")),
item("retry", "Link-Umwandlung erneut, Versuch 6/...", {
status: "validating",
retries: 6,
downloadedBytes: 0,
progressPercent: 0
})
];
const presentation = buildPackagePresentation(row(items, { status: "queued" }));
expect(presentation.status).toBe("7 Entpackfehler · 1 Wiederholung");
expect(presentation.details).toContain("7 Entpackfehler");
expect(presentation.details).toContain("1 Wiederholung");
});
it("keeps a single normal active download compact", () => {
const presentation = buildPackagePresentation(row([
item("active", "Download läuft", {
status: "downloading",
downloadedBytes: 50,
progressPercent: 50
})
], { status: "downloading" }));
expect(presentation.status).toBe("Download läuft");
});
});
+18
View File
@@ -158,6 +158,24 @@ describe("package lifecycle telemetry", () => {
}));
});
it("keeps a completed download with an extraction error out of successful package results", () => {
const item = { ...downloadItem("item-1"), fullStatus: "Entpack-Fehler: falsches Passwort", lastError: "falsches Passwort" };
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item],
archiveOperations: []
}));
expect(result).toEqual(expect.objectContaining({
status: "failed",
successfulFiles: 0,
failedFiles: 1,
extractionFailures: 1,
failurePhase: "extract",
errorCategory: "Entpacken"
}));
});
it("classifies a package with no successful files and a download failure as failed", () => {
const item = downloadItem("item-1", "failed");
const result = finalizePackageResult(telemetry({
+110 -6
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveArchiveItemsFromList } from "../src/main/download-manager";
import {
markPlannedHybridArchiveItemsPending,
resolveArchiveItemsFromList,
resolveSelectedArchiveSetsFromCandidates,
} from "../src/main/download-manager";
type MinimalItem = {
targetPath?: string;
@@ -16,7 +20,7 @@ function makeItems(names: string[]): MinimalItem[] {
}));
}
describe("resolveArchiveItemsFromList", () => {
describe("resolveArchiveItemsFromList", () => {
it("matches multipart .part1.rar archives", () => {
const items = makeItems([
@@ -139,7 +143,7 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2);
});
it("does not cross-match different archive groups", () => {
it("does not cross-match different archive groups", () => {
const items = makeItems([
"Episode.S01E01.part1.rar",
"Episode.S01E01.part2.rar",
@@ -152,6 +156,106 @@ describe("resolveArchiveItemsFromList", () => {
const result2 = resolveArchiveItemsFromList("Episode.S01E02.part1.rar", items as any);
expect(result2).toHaveLength(2);
expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true);
});
});
expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true);
});
it("resolves every multipart volume beside the selected non-first part without crossing directories", () => {
const items = [
{
targetPath: "C:\\Downloads\\Package\\Disc A\\Movie.part1.rar",
fileName: "Movie.part1.rar",
id: "disc-a-part-1",
status: "completed",
},
{
targetPath: "C:\\Downloads\\Package\\Disc A\\Movie.part2.rar",
fileName: "Movie.part2.rar",
id: "disc-a-part-2",
status: "completed",
},
{
targetPath: "C:\\Downloads\\Package\\Disc B\\Movie.part1.rar",
fileName: "Movie.part1.rar",
id: "disc-b-part-1",
status: "completed",
},
{
targetPath: "C:\\Downloads\\Package\\Disc B\\Movie.part2.rar",
fileName: "Movie.part2.rar",
id: "disc-b-part-2",
status: "completed",
},
];
const result = resolveArchiveItemsFromList(
"Movie.part2.rar",
items as any,
"C:\\Downloads\\Package\\Disc A\\Movie.part2.rar"
);
expect(result.map((item: any) => item.id)).toEqual([
"disc-a-part-1",
"disc-a-part-2",
]);
});
});
describe("resolveSelectedArchiveSetsFromCandidates", () => {
it("maps a selected non-first part to its canonical archive and complete multipart set", () => {
const items = [
{ id: "e01-1", fileName: "Episode.E01.part1.rar", targetPath: "C:\\Downloads\\Episode.E01.part1.rar", status: "completed" },
{ id: "e01-2", fileName: "Episode.E01.part2.rar", targetPath: "C:\\Downloads\\Episode.E01.part2.rar", status: "completed" },
{ id: "e02-1", fileName: "Episode.E02.part1.rar", targetPath: "C:\\Downloads\\Episode.E02.part1.rar", status: "completed" },
{ id: "e02-2", fileName: "Episode.E02.part2.rar", targetPath: "C:\\Downloads\\Episode.E02.part2.rar", status: "completed" }
];
const selected = resolveSelectedArchiveSetsFromCandidates(
["C:\\Downloads\\Episode.E01.part1.rar", "C:\\Downloads\\Episode.E02.part1.rar"],
items as any,
new Set(["e01-2"])
);
expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.part1.rar"]);
expect([...selected.itemIds].sort()).toEqual(["e01-1", "e01-2"]);
});
});
describe("markPlannedHybridArchiveItemsPending", () => {
it("keeps unplanned incomplete archive groups waiting", () => {
const items = [
{
id: "planned-part-1",
status: "completed",
fullStatus: "Entpacken - Warten auf Parts",
updatedAt: 1,
},
{
id: "foreign-part-1",
status: "completed",
fullStatus: "Entpacken - Warten auf Parts",
updatedAt: 2,
},
];
const changed = markPlannedHybridArchiveItemsPending(
items as any,
new Set(["planned-part-1"]),
100
);
expect(changed).toBe(true);
expect(items).toEqual([
{
id: "planned-part-1",
status: "completed",
fullStatus: "Entpacken - Ausstehend",
updatedAt: 100,
},
{
id: "foreign-part-1",
status: "completed",
fullStatus: "Entpacken - Warten auf Parts",
updatedAt: 2,
},
]);
});
});
@@ -274,6 +274,35 @@ describe("download disclosure in the headless visual harness", () => {
throw new Error(`Visual driver capture did not reach its ready state: ${name}`);
}
it("sorts package rows through a real captured pointer click", async () => {
await loadDenseDownloads(1500);
if (!client) throw new Error("Chrome DevTools client is missing");
const point = await client.evaluate<{ x: number; y: number }>(`(() => {
const button = [...document.querySelectorAll('.downloads-column-sort')].find((entry) => entry.textContent?.startsWith('Name'));
if (!(button instanceof HTMLElement)) throw new Error('Name sort button missing');
const rect = button.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
})()`);
const readState = (): Promise<{ ariaSort: string | null; names: string[] }> => client!.evaluate(`(() => ({
ariaSort: document.querySelector('[data-download-column="name"]')?.getAttribute('aria-sort') || null,
names: [...document.querySelectorAll('.downloads-package-row .downloads-name-cell strong')].map((entry) => entry.textContent || '')
}))()`);
await client.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 });
await client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1 });
await delay(120);
const descending = await readState();
await client.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 });
await client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1 });
await delay(120);
const ascending = await readState();
expect(descending.ariaSort).toBe("descending");
expect(ascending.ariaSort).toBe("ascending");
expect(descending.names.length).toBeGreaterThan(1);
expect(ascending.names).toEqual([...descending.names].reverse());
});
async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise<DisclosureSample[]> {
if (!client) throw new Error("Chrome DevTools client is missing");
return client.evaluate<DisclosureSample[]>(`(async () => {
+13 -4
View File
@@ -359,10 +359,19 @@ export function createVisualElectronApi(
entry.status = "extracting";
}
},
extractNow: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.status = "extracting";
extractNow: async (request) => {
for (const packageId of request.packageIds) {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.status = "extracting";
}
}
for (const itemId of request.itemIds) {
const item = fixture.snapshot.session.items[itemId];
const entry = item ? fixture.snapshot.session.packages[item.packageId] : undefined;
if (entry) {
entry.status = "extracting";
}
}
},
resetPackage: async (packageId) => {