Files
Multi-Debrid-Downloader/tests/notify-hooks.test.ts
T
Sucukdeluxe 808c2a432c release: harden archive recovery and lifecycle for v2.0.63
Corroborate CRC failures across extraction backends, retry only implicated multipart volumes, and distinguish corruption, missing volumes, I/O failures, and wrong passwords across native, Zip4j, and JBinding paths.

Make disk retries generation-safe, preserve selective run scopes and cooldowns, protect shared output files during cleanup, validate manual extraction batches atomically, and restore interrupted integrity work safely.

Prioritize active package operations in the UI, strengthen extraction IPC validation, compile the JVM sidecar before release builds, and verify shipped JVM resources byte-for-byte in every Windows artifact.
2026-08-23 03:56:24 +02:00

958 lines
38 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { buildRunNotificationEvent, buildRunResult } from "../src/main/notification-events";
import type { NotificationEvent } from "../src/main/notification-outbox";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
const tempDirs: string[] = [];
const sessionRoots = new WeakMap<object, string>();
afterEach(() => {
vi.restoreAllMocks();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
function setup(settings: Partial<AppSettings> = {}): {
manager: DownloadManager;
session: ReturnType<typeof emptySession>;
events: NotificationEvent[];
history: HistoryEntry[];
} {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
tempDirs.push(root);
const session = emptySession();
sessionRoots.set(session, root);
const events: NotificationEvent[] = [];
const history: HistoryEntry[] = [];
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "out"),
extractDir: path.join(root, "extract"),
notifyUrl: "https://discord.com/api/webhooks/123/abc",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true,
notifyOnRunFinished: true,
notifyPackageSuccessMode: "individual",
autoExtract: false,
...settings
},
session,
createStoragePaths(path.join(root, "state")),
{
enqueueNotification: async (event: NotificationEvent) => {
events.push(event);
},
onHistoryEntry: (entry) => history.push(entry)
}
);
return { manager, session, events, history };
}
function addPackage(
session: ReturnType<typeof emptySession>,
statuses: Array<"completed" | "failed" | "cancelled" | "queued"> = ["completed"],
packageId = "pkg-1"
): PackageEntry {
const startedAt = Date.now() - 30_000;
const root = sessionRoots.get(session) || os.tmpdir();
const outputDir = path.join(root, "out", packageId);
const extractDir = path.join(root, "extract", packageId);
fs.mkdirSync(outputDir, { recursive: true });
const pkg: PackageEntry = {
id: packageId,
name: `Test ${packageId}`,
outputDir,
extractDir,
status: "queued",
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
cancelled: false,
enabled: true,
priority: "normal",
downloadStartedAt: startedAt,
downloadCompletedAt: startedAt + 10_000,
downloadEndedAt: startedAt + 10_000,
createdAt: startedAt,
updatedAt: startedAt + 10_000
};
session.packages[packageId] = pkg;
session.packageOrder.push(packageId);
statuses.forEach((status, index) => {
const itemId = `${packageId}-item-${index}`;
const fileName = `${packageId}-${index}.zip`;
const targetPath = path.join(outputDir, fileName);
if (status === "completed") {
const zip = new AdmZip();
zip.addFile("episode.mkv", Buffer.from(`video-${packageId}-${index}`));
zip.writeZip(targetPath);
}
const downloadedBytes = status === "completed" ? fs.statSync(targetPath).size : 0;
session.items[itemId] = {
id: itemId,
packageId,
url: `https://dummy/${packageId}/${index}`,
provider: "realdebrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes,
totalBytes: status === "completed" ? downloadedBytes : 1_000,
progressPercent: status === "completed" ? 100 : 0,
fileName,
targetPath,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "offline" : "",
fullStatus: status === "completed" ? "Fertig" : status === "failed" ? "Offline" : "Wartet",
createdAt: startedAt,
updatedAt: startedAt + 10_000
};
});
return pkg;
}
function internal(manager: DownloadManager): any {
return manager as any;
}
async function flushNotifications(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
describe("authoritative package completion", () => {
it("waits for main, deferred, hybrid and file operations before emitting one package result", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.packagePostProcessTasks.set(pkg.id, Promise.resolve());
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
state.packageHybridPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
state.packageFileOpChain.set(pkg.id, Promise.resolve());
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
expect(history).toHaveLength(0);
state.packagePostProcessTasks.delete(pkg.id);
state.packageDeferredPostProcessTasks.delete(pkg.id);
state.packageHybridPostProcessTasks.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
state.packageFileOpChain.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
expect(history).toHaveLength(1);
expect(pkg.postProcessCompletedAt).toBeGreaterThan(0);
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
});
it("keeps postprocess start unset when a queued package never receives a slot", async () => {
const { manager, session, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
pkg.postProcessQueuedAt = Date.now() - 5000;
state.runPackageIds.add(pkg.id);
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
expect(history).toHaveLength(1);
expect(history[0].postProcessStartedAt).toBe(0);
expect(history[0].postProcessDurationSeconds).toBe(0);
});
it("records exactly one business history entry when a finalized package is manually deleted", async () => {
const { manager, session, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
manager.cancelPackage(pkg.id);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({ id: `hist-${pkg.id}-1`, status: "completed" });
});
it("turns a deferred remux failure into one immediate failed package event", async () => {
const { manager, session, events, history } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
pkg.remuxOperations = [{
id: "remux-1",
fileName: "episode.mkv",
startedAt: 10_000,
completedAt: 14_000,
durationMs: 4_000,
status: "failed",
errorCategory: "ffmpeg"
}];
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
state.packageDeferredPostProcessTasks.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_failed"]);
expect(events[0].priority).toBe("error");
expect(history[0]).toMatchObject({ status: "failed", failurePhase: "remux", failedFiles: 1 });
});
it("emits a partial package result when the terminal downloads are mixed", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session, ["completed", "failed"]);
session.running = true;
internal(manager).runPackageIds.add(pkg.id);
internal(manager).refreshPackageStatus(pkg);
await flushNotifications();
expect(pkg.status).toBe("failed");
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 });
});
it("creates a new result generation when extraction is retried", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
pkg.archiveOperations = [{
id: "archive-1",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 10_000,
completedAt: 12_000,
durationMs: 2_000,
status: "failed",
errorCategory: "crc_error"
}];
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
const firstId = events[0]?.id;
const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
await manager.retryExtraction(pkg.id);
expect(postProcess).toHaveBeenCalledWith(pkg.id);
pkg.archiveOperations = [{
id: "archive-2",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 20_000,
completedAt: 23_000,
durationMs: 3_000,
status: "completed",
errorCategory: ""
}];
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (3.0s)";
pkg.status = "completed";
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(2);
expect(events[1].id).not.toBe(firstId);
expect(events[1].type).toBe("package_completed");
expect(history).toHaveLength(2);
});
it("continues the persisted result generation after an extraction retry following restart", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
pkg.resultGeneration = 7;
pkg.terminalAt = Date.now() - 1_000;
pkg.archiveOperations = [{
id: "archive-restart-failed",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 10_000,
completedAt: 12_000,
durationMs: 2_000,
status: "failed",
errorCategory: "crc_error"
}];
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
await manager.retryExtraction(pkg.id);
expect(pkg.resultGeneration).toBe(8);
pkg.archiveOperations = [{
id: "archive-restart-completed",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 20_000,
completedAt: 23_000,
durationMs: 3_000,
status: "completed",
errorCategory: ""
}];
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (3.0s)";
pkg.status = "completed";
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
expect(events).toHaveLength(1);
expect(events[0].id).toContain(":8:");
expect(history).toHaveLength(1);
expect(history[0].id).toBe(`hist-${pkg.id}-8`);
});
it("moves a pending success digest into the outbox before shutdown", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
await state.flushNotificationsForShutdown?.();
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
it("persists a success digest that finalizes after shutdown flushing has started", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
await state.flushNotificationsForShutdown();
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
it.each([
["digest", 4],
["individual", 80]
] as const)("delivers 80 successful packages in %s mode without loss", async (mode, expectedEvents) => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: mode });
const state = internal(manager);
for (let index = 0; index < 80; index += 1) {
const pkg = addPackage(session, ["completed"], `bulk-package-${String(index).padStart(2, "0")}`);
state.runPackageIds.add(pkg.id);
state.tryFinalizePackageResult(pkg.id);
}
if (mode === "digest") {
state.flushPackageSuccessDigest();
}
await state.notificationEnqueueChain;
expect(events).toHaveLength(expectedEvents);
expect(new Set(events.map((event) => event.id))).toHaveLength(expectedEvents);
if (mode === "digest") {
expect(events.map((event) => event.payload.fields.length)).toEqual([20, 20, 20, 20]);
expect(events.every((event) => event.payload.description === "80 Pakete abgeschlossen")).toBe(true);
}
});
});
describe("authoritative run completion", () => {
it.each([
["successful", 0, "success"],
["failed", 1, "error"]
] as const)("keeps a %s run_completed event for 24 hours", (_label, failedFiles, priority) => {
const notification = buildRunNotificationEvent(buildRunResult({
id: `run-${priority}`,
stopped: false,
startedAt: 1000,
completedAt: 2000,
packages: [],
failedFiles
}));
expect(notification.type).toBe("run_completed");
expect(notification.priority).toBe(priority);
expect(notification.expiresAt - notification.createdAt).toBe(24 * 60 * 60 * 1000);
});
it("emits run_stopped without run_completed for a manual stop", async () => {
const { manager, session, events } = setup();
const pkg = addPackage(session, ["completed", "queued"]);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 10_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
manager.stop();
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["run_stopped"]);
expect(events.some((event) => event.type === "run_completed")).toBe(false);
});
it("builds run_stopped file counters only from finalized package results", async () => {
const { manager, session, events } = setup();
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds = new Set([pkg.id]);
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
events.length = 0;
session.running = true;
session.runStartedAt = Date.now() - 10_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "failed"]]);
manager.stop();
await flushNotifications();
const stopped = events.find((event) => event.type === "run_stopped");
expect(stopped).toBeDefined();
expect(stopped?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
});
it("waits for failed extraction package results before emitting the final run summary", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
pkg.archiveOperations = [{
id: "archive-failed",
name: "episode.part01.rar",
itemIds: [...pkg.itemIds],
partCount: 16,
startedAt: 10_000,
completedAt: 18_000,
durationMs: 8_000,
status: "failed",
errorCategory: "wrong_password"
}];
state.finishRun();
await flushNotifications();
expect(events).toHaveLength(0);
state.packageDeferredPostProcessTasks.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_failed", "run_completed"]);
const runEvent = events[1];
expect(runEvent.payload.fields.some((field) => field.name === "Entpackfehler" && field.value === "1")).toBe(true);
expect(runEvent.payload.fields.some((field) => field.name === "Dateien" && field.value === "0 erfolgreich · 1 fehlgeschlagen · 0 abgebrochen")).toBe(true);
});
it("flushes successful package digests before run_completed", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.finishRun();
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
it("keeps a finalized success in the digest after package_done removes its session package", async () => {
const { manager, session, events } = setup({
notifyPackageSuccessMode: "digest",
completedCleanupPolicy: "package_done"
});
const pkg = addPackage(session);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.tryFinalizePackageResult(pkg.id);
state.applyPackageDoneCleanup(pkg.id);
expect(session.packages[pkg.id]).toBeUndefined();
state.finishRun();
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
it("keeps the active run generation after package_done cleanup removes a generation-seven package", async () => {
const { manager, session, events, history } = setup({ completedCleanupPolicy: "package_done" });
const pkg = addPackage(session);
const state = internal(manager);
pkg.resultGeneration = 7;
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.beginActiveRunContext?.(state.runPackageIds, session.runStartedAt);
state.tryFinalizePackageResult(pkg.id);
const currentResult = state.finalizedPackageResults.get(`${pkg.id}:7`);
expect(currentResult).toBeDefined();
state.finalizedPackageResults.set(`${pkg.id}:1`, {
...currentResult,
status: "failed",
successfulFiles: 0,
failedFiles: 9,
failurePhase: "download",
errorCategory: "stale"
});
state.applyPackageDoneCleanup(pkg.id);
expect(session.packages[pkg.id]).toBeUndefined();
state.finishRun();
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1);
const completedRun = events.find((event) => event.type === "run_completed");
expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
expect(history).toHaveLength(1);
expect(history[0].id).toBe(`hist-${pkg.id}-7`);
});
it("registers a postprocess-only start before a package task without download items completes", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
for (const itemId of pkg.itemIds) {
delete session.items[itemId];
}
pkg.itemIds = [];
pkg.status = "completed";
let releasePostProcess = (): void => {};
const postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
const postProcess = state.runPackagePostProcessing(pkg.id);
await Promise.resolve();
await manager.start();
releasePostProcess();
await postProcess;
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({ name: pkg.name, status: "completed", fileCount: 0 });
});
it("updates an active run from generation one to generation two after resetting the same package", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.beginActiveRunContext(state.runPackageIds, session.runStartedAt);
state.tryFinalizePackageResult(pkg.id);
const generationOne = state.finalizedPackageResults.get(`${pkg.id}:1`);
state.finalizedPackageResults.set(`${pkg.id}:1`, {
...generationOne,
status: "failed",
successfulFiles: 0,
failedFiles: 9,
failurePhase: "download",
errorCategory: "stale"
});
await flushNotifications();
events.length = 0;
history.length = 0;
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.resetPackage(pkg.id);
expect(pkg.resultGeneration).toBe(2);
const item = session.items[pkg.itemIds[0]];
item.status = "completed";
item.downloadedBytes = 1_000;
item.totalBytes = 1_000;
item.progressPercent = 100;
item.fullStatus = "Fertig";
pkg.status = "completed";
state.runOutcomes.set(item.id, "completed");
state.tryFinalizePackageResult(pkg.id);
state.finishRun();
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(events.find((event) => event.type === "package_completed")?.id).toContain(":2:");
const completedRun = events.find((event) => event.type === "run_completed");
expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
expect(history).toHaveLength(1);
expect(history[0].id).toBe(`hist-${pkg.id}-2`);
expect([...state.finalizedPackageResults.keys()].filter((key) => key.startsWith(`${pkg.id}:`))).toEqual([`${pkg.id}:2`]);
});
it("prunes only the removed package result generations and provenance state", () => {
const { manager, session } = setup();
const packageA = addPackage(session, ["completed"], "removed-package");
const packageB = addPackage(session, ["completed"], "retained-package");
packageA.outputCount = 1;
packageA.outputProvenance = ["a".repeat(64)];
packageB.outputCount = 1;
packageB.outputProvenance = ["b".repeat(64)];
const state = internal(manager);
const resultA = { packageId: packageA.id };
const resultB = { packageId: packageB.id };
state.finalizedPackageResults.set(`${packageA.id}:1`, resultA);
state.finalizedPackageResults.set(`${packageA.id}:2`, resultA);
state.finalizedPackageResults.set(`${packageB.id}:1`, resultB);
state.standalonePackageResults.add(`${packageA.id}:2`);
state.standalonePackageResults.add(`${packageB.id}:1`);
state.suppressedPackageResults.add(`${packageA.id}:1`);
state.suppressedPackageResults.add(`${packageB.id}:1`);
state.successDigestResults.set(`${packageA.id}:2`, { generation: 2, result: resultA });
state.successDigestResults.set(`${packageB.id}:1`, { generation: 1, result: resultB });
const context = state.createRunContext([packageA.id, packageB.id], 1_000, false);
state.removePackageFromSession(packageA.id, [...packageA.itemIds]);
expect([...state.finalizedPackageResults.keys()]).toEqual([`${packageB.id}:1`]);
expect([...state.standalonePackageResults]).toEqual([`${packageB.id}:1`]);
expect([...state.suppressedPackageResults]).toEqual([`${packageB.id}:1`]);
expect([...state.successDigestResults.keys()]).toEqual([`${packageB.id}:1`]);
expect(context.packageGenerations).toEqual(new Map([[packageB.id, 1]]));
expect(packageA.outputCount).toBe(0);
expect(packageA.outputProvenance).toEqual([]);
expect(packageB.outputCount).toBe(1);
expect(packageB.outputProvenance).toEqual(["b".repeat(64)]);
});
it("tracks a main postprocess task created by triggerPendingExtractions after start begins", async () => {
const { manager, session, events, history } = setup({ autoExtract: true });
const pkg = addPackage(session);
const state = internal(manager);
pkg.status = "completed";
session.items[pkg.itemIds[0]].fullStatus = "Fertig";
let releasePostProcess = (): void => {};
const postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
await manager.start();
const postProcess = state.packagePostProcessTasks.get(pkg.id);
expect(postProcess).toBeDefined();
releasePostProcess();
await postProcess;
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(history).toHaveLength(1);
expect(history[0].name).toBe(pkg.name);
});
it("tracks a deferred-only startup task at creation without an active run", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
pkg.status = "completed";
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (1.0s)";
state.executeDeferredPostExtraction = vi.fn(async () => undefined);
await state.runDeferredPostExtraction(pkg.id, pkg, 1, 0, true, 1);
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(history).toHaveLength(1);
expect(history[0].name).toBe(pkg.name);
});
it("keeps stopped package postprocessing suppressed when a later start only runs another package", async () => {
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
const packageA = addPackage(session, ["queued"], "stopped-package");
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
const packageAItem = session.items[packageA.itemIds[0]];
packageAItem.status = "completed";
packageAItem.downloadedBytes = 1_000;
packageAItem.totalBytes = 1_000;
packageAItem.progressPercent = 100;
packageAItem.fullStatus = "Fertig";
packageA.status = "completed";
let releasePostProcess = (): void => {};
const postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
const latePostProcess = state.runPackagePostProcessing(packageA.id);
await Promise.resolve();
manager.stop();
await flushNotifications();
const packageB = addPackage(session, ["queued"], "follow-up-package");
await manager.start();
expect(session.running).toBe(false);
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
releasePostProcess();
await latePostProcess;
await vi.waitFor(() => expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "running", pendingStart: false }));
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0);
expect(history).toHaveLength(0);
});
it("keeps an earlier run-owned postprocess result alive when a later run is stopped", async () => {
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
const packageA = addPackage(session, ["queued"], "earlier-run-package");
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
const packageAItem = session.items[packageA.itemIds[0]];
packageAItem.status = "completed";
packageAItem.downloadedBytes = 1_000;
packageAItem.totalBytes = 1_000;
packageAItem.progressPercent = 100;
packageAItem.fullStatus = "Fertig";
packageA.status = "completed";
state.runOutcomes.set(packageAItem.id, "completed");
let releasePostProcess = (): void => {};
const postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
const packageAPostProcess = state.runPackagePostProcessing(packageA.id);
await Promise.resolve();
state.finishRun();
const packageB = addPackage(session, ["queued"], "later-run-package");
await manager.start();
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
manager.stop();
releasePostProcess();
await packageAPostProcess;
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1);
expect(history.map((entry) => entry.name)).toEqual([packageA.name]);
});
it("keeps run A ownership when its real deferred follow-up starts during run B before run B stops", async () => {
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true, maxParallelExtract: 1 });
const packageA = addPackage(session, ["queued"], "deferred-owner-package");
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
const packageAItem = session.items[packageA.itemIds[0]];
packageAItem.status = "completed";
packageAItem.downloadedBytes = 1_000;
packageAItem.totalBytes = 1_000;
packageAItem.progressPercent = 100;
packageAItem.fullStatus = "Fertig";
packageA.status = "completed";
state.runOutcomes.set(packageAItem.id, "completed");
const handlePackagePostProcessing = state.handlePackagePostProcessing.bind(state);
let releaseMainPostProcess = (): void => {};
let markMainPostProcessEntered = (): void => {};
const mainPostProcessGate = new Promise<void>((resolve) => {
releaseMainPostProcess = resolve;
});
const mainPostProcessEntered = new Promise<void>((resolve) => {
markMainPostProcessEntered = resolve;
});
vi.spyOn(state, "handlePackagePostProcessing").mockImplementation(async (...args: unknown[]) => {
const [packageId, signal] = args as [string, AbortSignal?];
markMainPostProcessEntered();
await mainPostProcessGate;
await handlePackagePostProcessing(packageId, signal);
});
let releaseCollection = (): void => {};
const collectionGate = new Promise<void>((resolve) => {
releaseCollection = resolve;
});
const collect = vi.spyOn(state, "collectMkvFilesToLibrary").mockImplementation(async () => collectionGate);
const packageAMainPostProcess = state.runPackagePostProcessing(packageA.id);
await mainPostProcessEntered;
state.finishRun();
const packageB = addPackage(session, ["queued"], "active-run-package");
await manager.start();
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
releaseMainPostProcess();
await packageAMainPostProcess;
await vi.waitFor(() => expect(collect).toHaveBeenCalled());
const deferredTasks = [...(state.packageDeferredPostProcessTasks.get(packageA.id) || [])];
expect(deferredTasks).toHaveLength(1);
manager.stop();
releaseCollection();
await Promise.allSettled(deferredTasks);
await flushNotifications();
expect(packageA.cleanupErrorCategory || "").toBe("");
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1);
expect(history.map((entry) => entry.name)).toEqual([packageA.name]);
});
it("does not reactivate a suppressed foreign package when another start recovers it from disk", async () => {
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
const packageA = addPackage(session, ["queued"], "suppressed-recovery-package");
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
manager.stop();
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-recovery-"));
tempDirs.push(recoveryDir);
const recoveredPath = path.join(recoveryDir, "recovered-package.rar");
fs.writeFileSync(recoveredPath, Buffer.alloc(1_000, 7));
const packageAItem = session.items[packageA.itemIds[0]];
packageA.enabled = false;
packageA.status = "failed";
packageAItem.status = "failed";
packageAItem.targetPath = recoveredPath;
packageAItem.downloadedBytes = 0;
packageAItem.totalBytes = 1_000;
packageAItem.progressPercent = 0;
packageAItem.fullStatus = "Resume-Link erneuern";
packageAItem.lastError = "download_underflow";
const packageB = addPackage(session, ["queued"], "recovery-run-package");
await manager.start();
await Promise.allSettled([...state.packagePostProcessTasks.values()]);
await flushNotifications();
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0);
expect(history).toHaveLength(0);
manager.stop();
});
it("suppresses a stopped postprocess-only generation and allows an explicit package retry", async () => {
const { manager, session, events, history } = setup({ autoExtract: true, autoExtractWhenStopped: true });
const pkg = addPackage(session, ["completed"], "postprocess-only-package");
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
let releasePostProcess = (): void => {};
let postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
await manager.start();
const stoppedPostProcess = state.packagePostProcessTasks.get(pkg.id);
expect(stoppedPostProcess).toBeDefined();
expect(state.runItemIds.size).toBe(0);
manager.stop();
releasePostProcess();
await stoppedPostProcess;
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0);
expect(history).toHaveLength(0);
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
await manager.retryExtraction(pkg.id);
const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id);
expect(retriedPostProcess).toBeDefined();
releasePostProcess();
await retriedPostProcess;
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(history.map((entry) => entry.name)).toEqual([pkg.name]);
});
it("finalizes overlapping runs independently when the earlier run finishes deferred work last", async () => {
const { manager, session, events, history } = setup();
const packageA = addPackage(session, ["completed"], "package-a");
const packageB = addPackage(session, ["completed"], "package-b");
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(packageA.itemIds);
state.runPackageIds = new Set([packageA.id]);
state.runOutcomes = new Map([[packageA.itemIds[0], "completed"]]);
state.packageDeferredPostProcessTasks.set(packageA.id, new Set([Promise.resolve()]));
state.finishRun();
session.running = true;
session.runStartedAt = Date.now() - 5_000;
state.runItemIds = new Set(packageB.itemIds);
state.runPackageIds = new Set([packageB.id]);
state.runOutcomes = new Map([[packageB.itemIds[0], "completed"]]);
state.finishRun();
state.packageDeferredPostProcessTasks.delete(packageA.id);
state.tryFinalizePackageResult(packageA.id);
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(2);
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(2);
expect(new Set(events.filter((event) => event.type === "package_completed").map((event) => event.id)).size).toBe(2);
expect(new Set(events.filter((event) => event.type === "run_completed").map((event) => event.id)).size).toBe(2);
expect(history.map((entry) => entry.name).sort()).toEqual([packageA.name, packageB.name].sort());
});
});