From 50d77489864067d0c574c7e31cd9bfaac25e094d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 23 Aug 2026 17:15:33 +0200 Subject: [PATCH] fix(extraction): block stale multipart runs in v2.0.66 Resolve complete child multipart selections as a single extraction set and report missing or incomplete local parts precisely. Stop manual, hybrid, and normal automatic extraction before invoking a backend when persisted completion no longer matches disk state, while retaining the explicit startup recovery path for intentionally cleaned sources. Keep never-started parent package statuses empty without hiding real retry or lifecycle activity, and stabilize the visual pointer test against its existing readiness budget. --- CHANGELOG.md | 11 + src/main/download-manager.ts | 143 +++++++++-- .../views/downloads/package-presentation.ts | 23 ++ tests/download-manager.test.ts | 230 ++++++++++++++++++ tests/package-presentation.test.ts | 64 +++++ .../download-disclosure-transition.test.tsx | 2 +- 6 files changed, 445 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4ed62..6e1aca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,17 @@ All notable changes to Multi-Debrid Downloader are documented in this file. - Treat invalid, missing, blocked, banned, server-restricted, and IP-restricted credentials as terminal authentication failures without provider cooldowns. - Preserve official AllDebrid error codes alongside their messages so retry and cooldown decisions remain precise. +### Archive extraction reliability + +- Resolve multiple selected child rows from the same complete multipart archive as one extraction set. +- Stop before launching an extractor when a completed archive part is missing, incomplete, or no longer matches the persisted download state. +- Report the affected archive file instead of returning a generic no-extractable-set error for stale completed selections. +- Preserve startup recovery for packages whose source archives were intentionally cleaned after a completed extraction. + +### Download list clarity + +- Keep the status column empty for parent packages that have never started while retaining meaningful queued, active, and terminal statuses after the first run. + ## [2.0.65] - 2026-08-23 ### Extract now behavior diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 4f25fe7..36ba99d 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -9098,11 +9098,12 @@ export class DownloadManager extends EventEmitter { return fixedCount; } - private async waitForCompletedArchiveFilesToSettle( - pkg: PackageEntry, - items: DownloadItem[], - signal: AbortSignal | undefined, - scope: "hybrid" | "full" + private async waitForCompletedArchiveFilesToSettle( + pkg: PackageEntry, + items: DownloadItem[], + signal: AbortSignal | undefined, + scope: "hybrid" | "full", + allowMissingSourceRecovery = false ): Promise { const archiveItems = items.filter((item) => item.status === "completed" && isArchiveLikePath(item.targetPath || item.fileName || "") @@ -9209,13 +9210,24 @@ export class DownloadManager extends EventEmitter { `Extract-Settle (${scope}) Timeout: pkg=${pkg.name}, archiveItems=${archiveItems.length}, ` + `waitMs=${settleMs}, pending=${lastPending || "none"}` ); - this.logPackageForPackage(pkg, "WARN", "Archiv-Stabilisierung Timeout", { - scope, - archiveItems: archiveItems.length, - waitMs: settleMs, - pending: lastPending || "none" - }); - } + this.logPackageForPackage(pkg, "WARN", "Archiv-Stabilisierung Timeout", { + scope, + archiveItems: archiveItems.length, + waitMs: settleMs, + pending: lastPending || "none" + }); + const hasAnySourceArchive = archiveItems.some((item) => inspectPackageItemDiskState(pkg, item).exists); + if (!hasAnySourceArchive && allowMissingSourceRecovery) { + const sourceDirectoryExists = await fs.promises.stat(pkg.outputDir) + .then((stat) => stat.isDirectory(), () => false); + const hasExtractedOutput = this.getPackageOutputScope(pkg).completeFiles() + .some((filePath) => isPathInsideDir(filePath, pkg.extractDir) && fs.existsSync(filePath)); + if (!sourceDirectoryExists || hasExtractedOutput) { + return; + } + } + throw new Error(`Archivdateien nicht bereit: ${lastPending || "unbekannt"}`); + } private fixDuplicateSuffixFiles(): void { const SUFFIX_RE = /^(.+) \(\d+\)(\.[^.]+)$/; @@ -9382,7 +9394,10 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } - private runPackagePostProcessing(packageId: string): Promise { + private runPackagePostProcessing( + packageId: string, + options: { allowMissingSourceRecovery?: boolean } = {} + ): Promise { this.trackPackagePostProcessResult(packageId); const existing = this.packagePostProcessTasks.get(packageId); if (existing) { @@ -9416,7 +9431,11 @@ export class DownloadManager extends EventEmitter { this.hybridExtractRequeue.delete(packageId); const roundStart = nowMs(); try { - await this.handlePackagePostProcessing(packageId, abortController.signal); + await this.handlePackagePostProcessing( + packageId, + abortController.signal, + options.allowMissingSourceRecovery === true + ); } catch (error) { if (this.isExpectedPostProcessAbort(error, abortController.signal)) { logger.info(`Post-Processing für Paket abgebrochen: ${compactErrorText(error)}`); @@ -9564,7 +9583,7 @@ export class DownloadManager extends EventEmitter { } } changed = true; - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`)); + void this.runPackagePostProcessing(packageId, { allowMissingSourceRecovery: true }).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`)); } else if (pkg.status !== "completed") { pkg.status = "completed"; pkg.updatedAt = nowMs(); @@ -9754,6 +9773,62 @@ export class DownloadManager extends EventEmitter { }; } + private describeManualExtractionDiskFailure( + packageId: string, + selectedItemIds?: ReadonlySet + ): string | null { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return null; + } + const packageItems = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter((item): item is DownloadItem => Boolean(item)); + const seeds = selectedItemIds + ? packageItems.filter((item) => selectedItemIds.has(item.id)) + : packageItems; + const pathlessItems = packageItems.filter((item) => !String(item.targetPath || "").trim()); + const pathlessNameCounts = new Map(); + for (const item of pathlessItems) { + const key = path.basename(item.fileName || "").toLocaleLowerCase("en-US"); + pathlessNameCounts.set(key, (pathlessNameCounts.get(key) || 0) + 1); + } + const relatedItems = new Map(); + for (const seed of seeds) { + const filePath = String(seed.targetPath || seed.fileName || "").trim(); + if (!isArchiveLikePath(filePath)) { + continue; + } + const qualified = resolveArchiveItemsFromList(path.basename(filePath), packageItems, seed.targetPath || ""); + const legacy = resolveArchiveItemsFromList(path.basename(filePath), pathlessItems) + .filter((item) => (pathlessNameCounts.get(path.basename(item.fileName || "").toLocaleLowerCase("en-US")) || 0) === 1); + const resolved = [...new Map([...qualified, ...legacy].map((item) => [item.id, item])).values()]; + for (const item of resolved.length > 0 ? resolved : [seed]) { + relatedItems.set(item.id, item); + } + } + for (const item of relatedItems.values()) { + if (item.status !== "completed") { + continue; + } + const state = inspectPackageItemDiskState(pkg, item); + if (state.reason === "ok") { + continue; + } + const fileName = path.basename(item.targetPath || item.fileName || item.id); + if (state.reason === "missing_file" || state.reason === "missing_path") { + return `Archivdatei fehlt: ${fileName}`; + } + if (state.reason === "too_small") { + return `Archivdatei unvollständig: ${fileName} (${humanSize(state.size)} von mindestens ${humanSize(state.minBytes)})`; + } + if (state.reason === "persisted_shortfall") { + return `Archivstatus unvollständig: ${fileName}`; + } + } + return null; + } + private isManualExtractionPlanCurrent(plan: ManualExtractionPlan): boolean { const pkg = this.session.packages[plan.packageId]; if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) { @@ -9861,18 +9936,27 @@ export class DownloadManager extends EventEmitter { } } const plans: ManualExtractionPlan[] = []; + const rejectionDetails: string[] = []; for (const packageId of packageIds) { const plan = await this.resolveManualExtractionPlan(packageId); if (plan) plans.push(plan); - else rejected += 1; + else { + rejected += 1; + const detail = this.describeManualExtractionDiskFailure(packageId); + if (detail) rejectionDetails.push(detail); + } } for (const [packageId, itemIds] of itemIdsByPackage) { const plan = await this.resolveManualExtractionPlan(packageId, itemIds); if (plan) plans.push(plan); - else rejected += 1; + else { + rejected += 1; + const detail = this.describeManualExtractionDiskFailure(packageId, itemIds); + if (detail) rejectionDetails.push(detail); + } } if (plans.length === 0) { - throw new Error("Kein entpackbarer Archivsatz ausgewählt"); + throw new Error(rejectionDetails[0] || "Kein entpackbarer Archivsatz ausgewählt"); } if (rejected > 0) { logger.info(`Jetzt entpacken: ${plans.length} Entpackvorgang/Vorgänge bereit, ${rejected} Auswahl(en) ohne vollständigen Archivsatz übersprungen`); @@ -13994,8 +14078,8 @@ export class DownloadManager extends EventEmitter { const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); const candidateStem = path.basename(candidate).toLowerCase(); - const hasUnreadyPendingPart = packageItems.some((item) => item.status !== "completed" - && this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem) + const hasUnreadyPendingPart = packageItems.some((item) => + this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem) && inspectPackageItemDiskState(pkg, item).reason !== "ok"); if (hasUnreadyPendingPart) { continue; @@ -14509,7 +14593,11 @@ export class DownloadManager extends EventEmitter { return 0; } - private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise { + private async handlePackagePostProcessing( + packageId: string, + signal?: AbortSignal, + allowMissingSourceRecovery = false + ): Promise { const handleStart = nowMs(); const postProcessVersion = this.getPackagePostProcessVersion(packageId); const pkg = this.session.packages[packageId]; @@ -14748,12 +14836,13 @@ export class DownloadManager extends EventEmitter { const fullStartTimes = new Map(); let fullLastProgressCurrent: number | null = null; - await this.waitForCompletedArchiveFilesToSettle( - pkg, - completedItems, - extractAbortController.signal, - "full" - ); + await this.waitForCompletedArchiveFilesToSettle( + pkg, + completedItems, + extractAbortController.signal, + "full", + allowMissingSourceRecovery && !manualExtraction + ); if (extractAbortController.signal.aborted) { throw new Error(String(extractAbortController.signal.reason || "aborted:extract")); } diff --git a/src/renderer/views/downloads/package-presentation.ts b/src/renderer/views/downloads/package-presentation.ts index d7bd72b..d93f87b 100644 --- a/src/renderer/views/downloads/package-presentation.ts +++ b/src/renderer/views/downloads/package-presentation.ts @@ -166,6 +166,29 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen status = "Entpacken"; } + const hasLifecycleTimestamp = [ + row.package.downloadStartedAt, + row.package.downloadCompletedAt, + row.package.downloadEndedAt, + row.package.postProcessQueuedAt, + row.package.postProcessStartedAt, + row.package.postProcessCompletedAt, + row.package.terminalAt + ].some((value) => Number(value || 0) > 0); + const hasItemActivity = row.allItems.some((item) => + item.status !== "queued" + || Number(item.attempts || 0) > 0 + || Number(item.retries || 0) > 0 + || Number(item.downloadedBytes || 0) > 0 + || Number(item.progressPercent || 0) > 0 + ); + const neverStarted = (row.package.status === "queued" || row.package.status === "paused") + && !hasLifecycleTimestamp + && !hasItemActivity; + if (neverStarted) { + status = ""; + } + return { progress: { done, failed, cancelled, total, value }, status, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index a5ce88a..90ae0a9 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -2689,6 +2689,91 @@ describe("download manager", () => { expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual(["episode.e01.part1.rar"]); }); + it("extractNow accepts every selected child of one complete multipart archive", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-multipart-")); + tempDirs.push(root); + const session = emptySession(); + const packageId = "extract-selected-multipart"; + const outputDir = path.join(root, "downloads", "Switched at Birth"); + const extractDir = path.join(root, "extract", "Switched at Birth"); + fs.mkdirSync(outputDir, { recursive: true }); + const createdAt = Date.now(); + const specs = [ + ["part-1", "tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar", 128], + ["part-2", "tvs-sab-dd51-dl-7p-azhd-avc-405.part2.rar", 128] + ] as const; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Switched.at.Birth.S04.German.DD+51.DL.720p.AmazonHD.AVC-TVS", + outputDir, + extractDir, + status: "failed", + itemIds: specs.map(([id]) => id), + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + for (const [id, fileName, totalBytes] 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: "alldebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: totalBytes, + totalBytes, + progressPercent: 100, + fileName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + }; + } + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: false, hybridExtract: true }, + session, + createStoragePaths(path.join(root, "state")) + ); + const postProcess = vi.fn(async () => {}); + const internal = manager as any; + internal.runPackagePostProcessing = postProcess; + internal.ensureScheduler = vi.fn(async () => {}); + session.running = true; + session.paused = false; + + const candidates = [...await internal.findReadyArchiveSets(session.packages[packageId])]; + expect(candidates.map((filePath) => path.basename(filePath))).toEqual([ + "tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar" + ]); + const resolved = resolveSelectedArchiveSetsFromCandidates( + candidates, + specs.map(([id]) => session.items[id]), + new Set(["part-1", "part-2"]) + ); + expect([...resolved.itemIds].sort()).toEqual(["part-1", "part-2"]); + + await manager.extractNow({ packageIds: [], itemIds: ["part-1", "part-2"] }); + + expect(postProcess).toHaveBeenCalledWith(packageId); + expect(internal.ensureScheduler).not.toHaveBeenCalled(); + expect(session.items["part-1"].fullStatus).toBe("Entpacken - Ausstehend"); + expect(session.items["part-2"].fullStatus).toBe("Entpacken - Ausstehend"); + const filter = internal.manualExtractArchiveFilters.get(packageId) as Set; + expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual([ + "tvs-sab-dd51-dl-7p-azhd-avc-405.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); @@ -17161,6 +17246,151 @@ describe("post-processing lifecycle audit", () => { return { manager, session }; } + it("extractNow reports the missing file behind a stale completed multipart selection", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-stale-multipart-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "stale-multipart", itemId: "stale-part-1", fileName: "show.part1.rar" }, + { packageId: "stale-multipart", itemId: "stale-part-2", fileName: "show.part2.rar" } + ]); + fs.rmSync(session.items["stale-part-2"].targetPath); + + await expect(manager.extractNow({ + packageIds: [], + itemIds: ["stale-part-1", "stale-part-2"] + })).rejects.toThrow("Archivdatei fehlt: show.part2.rar"); + }); + + it("extractNow reports a pathless missing sibling behind one selected multipart child", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-stale-pathless-multipart-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "stale-pathless", itemId: "pathless-part-1", fileName: "show.part1.rar" }, + { packageId: "stale-pathless", itemId: "pathless-part-2", fileName: "show.part2.rar" } + ]); + fs.rmSync(session.items["pathless-part-2"].targetPath); + session.items["pathless-part-2"].targetPath = ""; + + await expect(manager.extractNow({ + packageIds: [], + itemIds: ["pathless-part-1"] + })).rejects.toThrow("Archivdatei fehlt: show.part2.rar"); + }); + + it("stops before extraction when a completed archive file never stabilizes on disk", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-settle-missing-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "settle-missing", itemId: "settle-part-1", fileName: "show.part1.rar" }, + { packageId: "settle-missing", itemId: "settle-part-2", fileName: "show.part2.rar" } + ]); + fs.rmSync(session.items["settle-part-2"].targetPath); + + await expect((manager as any).waitForCompletedArchiveFilesToSettle( + session.packages["settle-missing"], + [session.items["settle-part-1"], session.items["settle-part-2"]], + undefined, + "full" + )).rejects.toThrow("Archivdateien nicht bereit: show.part2.rar:missing_file"); + }, 8_000); + + it("does not invoke the extractor after a multipart source disappears during post-processing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-postprocess-missing-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "postprocess-missing", itemId: "postprocess-part-1", fileName: "show.part1.rar" }, + { packageId: "postprocess-missing", itemId: "postprocess-part-2", fileName: "show.part2.rar" } + ]); + fs.rmSync(session.items["postprocess-part-2"].targetPath); + const internal = manager as any; + const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" })); + internal.runCoordinatedExtraction = runCoordinatedExtraction; + internal.manualExtractPackages.add("postprocess-missing"); + + await internal.handlePackagePostProcessing("postprocess-missing"); + + expect(runCoordinatedExtraction).not.toHaveBeenCalled(); + expect(session.packages["postprocess-missing"].status).toBe("failed"); + expect(session.items["postprocess-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar"); + expect(session.items["postprocess-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar"); + }, 8_000); + + it("does not treat a fully vanished manual multipart source as a successful extraction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-postprocess-all-missing-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "postprocess-all-missing", itemId: "all-missing-part-1", fileName: "show.part1.rar" }, + { packageId: "postprocess-all-missing", itemId: "all-missing-part-2", fileName: "show.part2.rar" } + ]); + fs.rmSync(session.items["all-missing-part-1"].targetPath); + fs.rmSync(session.items["all-missing-part-2"].targetPath); + const internal = manager as any; + const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" })); + internal.runCoordinatedExtraction = runCoordinatedExtraction; + internal.manualExtractPackages.add("postprocess-all-missing"); + + await internal.handlePackagePostProcessing("postprocess-all-missing"); + + expect(runCoordinatedExtraction).not.toHaveBeenCalled(); + expect(session.packages["postprocess-all-missing"].status).toBe("failed"); + expect(session.items["all-missing-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar"); + expect(session.items["all-missing-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar"); + }, 8_000); + + it("does not treat a fully vanished automatic runtime source as startup recovery", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-runtime-all-missing-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "runtime-all-missing", itemId: "runtime-missing-part-1", fileName: "show.part1.rar" }, + { packageId: "runtime-all-missing", itemId: "runtime-missing-part-2", fileName: "show.part2.rar" } + ]); + fs.rmSync(session.items["runtime-missing-part-1"].targetPath); + fs.rmSync(session.items["runtime-missing-part-2"].targetPath); + fs.rmSync(session.packages["runtime-all-missing"].outputDir, { recursive: true }); + const internal = manager as any; + const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" })); + internal.runCoordinatedExtraction = runCoordinatedExtraction; + internal.settings.autoExtract = true; + + await internal.handlePackagePostProcessing("runtime-all-missing"); + + expect(runCoordinatedExtraction).not.toHaveBeenCalled(); + expect(session.packages["runtime-all-missing"].status).toBe("failed"); + expect(session.items["runtime-missing-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar"); + expect(session.items["runtime-missing-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar"); + }, 8_000); + + it("allows startup recovery after every source archive was intentionally cleaned", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-settle-cleaned-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "settle-cleaned", itemId: "cleaned-part-1", fileName: "show.part1.rar" }, + { packageId: "settle-cleaned", itemId: "cleaned-part-2", fileName: "show.part2.rar" } + ]); + fs.rmSync(session.items["cleaned-part-1"].targetPath); + fs.rmSync(session.items["cleaned-part-2"].targetPath); + fs.writeFileSync(path.join(session.packages["settle-cleaned"].outputDir, "release.nfo"), "release"); + const extractedPath = path.join(session.packages["settle-cleaned"].extractDir, "episode.mkv"); + fs.mkdirSync(path.dirname(extractedPath), { recursive: true }); + fs.writeFileSync(extractedPath, "video"); + (manager as any).getPackageOutputScope(session.packages["settle-cleaned"]).add({ + version: 1, + archivePath: session.items["cleaned-part-1"].targetPath, + entryPath: "episode.mkv", + outputPath: extractedPath, + state: "complete", + disposition: "written" + }); + + await expect((manager as any).waitForCompletedArchiveFilesToSettle( + session.packages["settle-cleaned"], + [session.items["cleaned-part-1"], session.items["cleaned-part-2"]], + undefined, + "full", + true + )).resolves.toBeUndefined(); + }, 8_000); + it("requeues an interrupted integrity check and clears transient package progress on restart", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-")); tempDirs.push(root); diff --git a/tests/package-presentation.test.ts b/tests/package-presentation.test.ts index de88d55..b6831a9 100644 --- a/tests/package-presentation.test.ts +++ b/tests/package-presentation.test.ts @@ -46,6 +46,70 @@ function row(items: DownloadItem[], overrides: Partial = {}): Down } describe("download package presentation", () => { + it("keeps the status empty for an over-package that has never started", () => { + const presentation = buildPackagePresentation(row([ + item("queued", "Wartet", { + status: "queued", + attempts: 0, + retries: 0, + downloadedBytes: 0, + progressPercent: 0 + }) + ], { + status: "queued", + downloadStartedAt: 0, + downloadCompletedAt: 0, + downloadEndedAt: 0, + postProcessQueuedAt: 0, + postProcessStartedAt: 0, + postProcessCompletedAt: 0, + terminalAt: 0 + })); + + expect(presentation.status).toBe(""); + }); + + it("keeps the status empty for a disabled never-started over-package", () => { + const presentation = buildPackagePresentation(row([ + item("queued", "Paket gestoppt", { + status: "queued", + attempts: 0, + retries: 0, + downloadedBytes: 0, + progressPercent: 0 + }) + ], { status: "paused", enabled: false })); + + expect(presentation.status).toBe(""); + }); + + it("keeps retry activity visible when an older queued package lacks lifecycle timestamps", () => { + const presentation = buildPackagePresentation(row([ + item("queued", "Link-Umwandlung erneut, Versuch 1/...", { + status: "queued", + attempts: 1, + retries: 1, + downloadedBytes: 0, + progressPercent: 0 + }) + ], { status: "queued", downloadStartedAt: 0 })); + + expect(presentation.status).toBe("Link-Umwandlung erneut"); + }); + + it("keeps a queued status visible after the over-package has started once", () => { + const presentation = buildPackagePresentation(row([ + item("queued", "Link-Umwandlung erneut, Versuch 1/...", { + status: "queued", + retries: 1, + downloadedBytes: 0, + progressPercent: 0 + }) + ], { status: "queued", downloadStartedAt: 1 })); + + expect(presentation.status).toBe("Link-Umwandlung erneut"); + }); + it("reserves 90 percent for completed downloads and 10 percent for extraction", () => { expect(buildPackagePresentation(row([ item("a", "Entpack-Fehler: Passwort"), diff --git a/tests/visual/download-disclosure-transition.test.tsx b/tests/visual/download-disclosure-transition.test.tsx index 72daee5..e0fd8b2 100644 --- a/tests/visual/download-disclosure-transition.test.tsx +++ b/tests/visual/download-disclosure-transition.test.tsx @@ -301,7 +301,7 @@ describe("download disclosure in the headless visual harness", () => { expect(ascending.ariaSort).toBe("ascending"); expect(descending.names.length).toBeGreaterThan(1); expect(ascending.names).toEqual([...descending.names].reverse()); - }); + }, 25_000); async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise { if (!client) throw new Error("Chrome DevTools client is missing");