Files
Multi-Debrid-Downloader/tests/package-telemetry.test.ts
Sucukdeluxe 91b9596d6f fix: retain complete package failure telemetry
Include immediately cleaned successful files and byte aggregates when finalizing package results so mixed cleanup states remain partial instead of failed.

Carry additive download, offline, extraction, remux, and cleanup failure counters alongside the prioritized display phase, aggregate them into run results, and expose cleanup failures in the final summary.
2026-08-22 12:24:18 +02:00

413 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type {
ArchiveOperationMetric,
DownloadItem,
PackageEntry,
PackageTelemetry,
RemuxOperationMetric
} from "../src/shared/types";
import {
durationMsToSeconds,
durationSecondsBetween,
finalizePackageResult
} from "../src/main/package-telemetry";
import { buildRunResult } from "../src/main/notification-events";
import { normalizeLoadedSession } from "../src/main/storage";
function packageEntry(overrides: Partial<PackageEntry> = {}): PackageEntry {
return {
id: "pkg-1",
name: "Paket",
outputDir: "C:\\Downloads\\Paket",
extractDir: "C:\\Downloads\\Paket",
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
downloadStartedAt: 1_000,
downloadCompletedAt: 121_000,
downloadEndedAt: 121_000,
postProcessQueuedAt: 122_000,
postProcessStartedAt: 130_000,
postProcessCompletedAt: 160_000,
terminalAt: 166_000,
createdAt: 1_000,
updatedAt: 166_000,
...overrides
};
}
function downloadItem(id: string, status: DownloadItem["status"] = "completed"): DownloadItem {
return {
id,
packageId: "pkg-1",
url: `https://example.test/${id}`,
provider: "realdebrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: status === "completed" ? 1_000 : 0,
totalBytes: 1_000,
progressPercent: status === "completed" ? 100 : 0,
fileName: `${id}.rar`,
targetPath: `C:\\Downloads\\Paket\\${id}.rar`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "download-error" : "",
fullStatus: "",
createdAt: 1_000,
updatedAt: 121_000
};
}
function archiveOperation(overrides: Partial<ArchiveOperationMetric> = {}): ArchiveOperationMetric {
return {
id: "archive-1",
name: "Paket.part01.rar",
itemIds: ["item-1"],
partCount: 1,
startedAt: 130_000,
completedAt: 160_000,
durationMs: 30_000,
status: "completed",
errorCategory: "",
...overrides
};
}
function remuxOperation(overrides: Partial<RemuxOperationMetric> = {}): RemuxOperationMetric {
return {
id: "remux-1",
fileName: "episode.mkv",
startedAt: 150_000,
completedAt: 155_000,
durationMs: 5_000,
status: "completed",
errorCategory: "",
...overrides
};
}
function telemetry(overrides: Partial<PackageTelemetry> = {}): PackageTelemetry {
const items = [downloadItem("item-1")];
return {
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [],
remuxOperations: [],
outputCount: 0,
cleanupErrorCategory: "",
...overrides
};
}
describe("package lifecycle telemetry", () => {
it("finalizes a successful package from explicit timestamps and operation durations", () => {
const items = [downloadItem("item-1"), downloadItem("item-2")];
const result = finalizePackageResult(telemetry({
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [archiveOperation({ itemIds: items.map((item) => item.id), partCount: 2 })],
remuxOperations: [remuxOperation()],
outputCount: 2
}));
expect(result).toEqual(expect.objectContaining({
packageId: "pkg-1",
status: "completed",
downloadDurationSeconds: 120,
extractionDurationSeconds: 30,
remuxDurationSeconds: 5,
postProcessDurationSeconds: 30,
totalDurationSeconds: 165,
successfulFiles: 2,
failedFiles: 0,
cancelledFiles: 0,
archiveCount: 1,
partCount: 2,
outputCount: 2,
failurePhase: null,
averageDownloadSpeedBps: 16
}));
});
it("counts a failed 16-part archive as one failed file and produces a partial result", () => {
const items = Array.from({ length: 16 }, (_, index) => downloadItem(`item-${index + 1}`));
const result = finalizePackageResult(telemetry({
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [archiveOperation({
itemIds: items.map((item) => item.id),
partCount: 16,
status: "failed",
errorCategory: "checksum"
})]
}));
expect(result).toEqual(expect.objectContaining({
status: "partial",
downloadDurationSeconds: 120,
extractionDurationSeconds: 30,
totalDurationSeconds: 165,
successfulFiles: 15,
failedFiles: 1,
partCount: 16,
archiveCount: 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({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result).toEqual(expect.objectContaining({
status: "failed",
successfulFiles: 0,
failedFiles: 1,
cancelledFiles: 0,
failurePhase: "download",
errorCategory: "Download"
}));
});
it("includes immediately cleaned successes and bytes in a mixed package result", () => {
const failedItem = downloadItem("item-3", "failed");
const result = finalizePackageResult(telemetry({
package: packageEntry({
status: "failed",
itemIds: [failedItem.id],
cleanedCompletedItemCount: 2,
cleanedDownloadedBytes: 2_000,
cleanedTotalBytes: 2_000
}),
items: [failedItem]
}));
expect(result).toEqual(expect.objectContaining({
status: "partial",
successfulFiles: 2,
failedFiles: 1,
downloadedBytes: 2_000,
totalBytes: 3_000
}));
});
it("classifies a failed download without error text as a download-phase failure", () => {
const item = { ...downloadItem("item-1", "failed"), lastError: "", fullStatus: "" };
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result).toEqual(expect.objectContaining({
status: "failed",
failedFiles: 1,
failurePhase: "download",
errorCategory: "Download"
}));
});
it("projects private download failure details to a fixed category", () => {
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
const item = {
...downloadItem("item-1", "failed"),
lastError: privateDetails,
fullStatus: privateDetails
};
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result.errorCategory).toBe("Download");
expect(result.errorCategory).not.toContain("private.example.test");
expect(result.errorCategory).not.toContain("C:/Private/target");
expect(result.errorCategory).not.toContain("alice@example.test");
expect(result.errorCategory).not.toContain("SUPERSECRET");
});
it.each([
["Host ist offline", "Offline"],
["Request ETIMEDOUT", "Timeout"],
["socket ECONNRESET", "Netzwerk"],
["ENOSPC: no space left on device", "Speicherplatz"],
["EACCES: permission denied", "Berechtigung"],
["nicht näher klassifizierbar", "Download"]
])("maps download failure detail %s to %s", (detail, expectedCategory) => {
const item = { ...downloadItem("item-1", "failed"), lastError: detail };
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result.errorCategory).toBe(expectedCategory);
});
it("classifies a package with only cancelled work as cancelled", () => {
const item = downloadItem("item-1", "cancelled");
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "cancelled", cancelled: true, itemIds: [item.id] }),
items: [item]
}));
expect(result).toEqual(expect.objectContaining({
status: "cancelled",
successfulFiles: 0,
failedFiles: 0,
cancelledFiles: 1,
failurePhase: null
}));
});
it("reports zero postprocess durations when no postprocess phase ran", () => {
const result = finalizePackageResult(telemetry({
package: packageEntry({
downloadCompletedAt: 61_000,
downloadEndedAt: 61_000,
postProcessQueuedAt: undefined,
postProcessStartedAt: undefined,
postProcessCompletedAt: undefined,
terminalAt: 61_000,
updatedAt: 61_000
})
}));
expect(result).toEqual(expect.objectContaining({
downloadDurationSeconds: 60,
extractionDurationSeconds: 0,
remuxDurationSeconds: 0,
postProcessDurationSeconds: 0,
totalDurationSeconds: 60
}));
});
it("retains legacy download timing after session normalization", () => {
const item = { ...downloadItem("item-1"), downloadedBytes: 240_000, totalBytes: 240_000 };
const legacyPackage = packageEntry({
itemIds: [item.id],
downloadStartedAt: 1_000,
downloadCompletedAt: 121_000,
terminalAt: 121_000,
updatedAt: 121_000
});
delete legacyPackage.downloadEndedAt;
const normalized = normalizeLoadedSession({
version: 2,
packageOrder: [legacyPackage.id],
packages: { [legacyPackage.id]: legacyPackage },
items: { [item.id]: item },
runStartedAt: 1_000,
totalDownloadedBytes: 240_000,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 121_000
});
const normalizedPackage = normalized.packages[legacyPackage.id];
const result = finalizePackageResult({
package: normalizedPackage,
items: normalizedPackage.itemIds.map((itemId) => normalized.items[itemId])
});
expect(result).toEqual(expect.objectContaining({
downloadEndedAt: 121_000,
downloadDurationSeconds: 120,
averageDownloadSpeedBps: 2_000
}));
});
it("uses cleanup, remux, extract, and download as deterministic failure precedence", () => {
const failedItem = downloadItem("item-1", "failed");
const failedArchive = archiveOperation({ status: "failed", errorCategory: "archive-error" });
const failedRemux = remuxOperation({ status: "failed", errorCategory: "remux-error" });
const failures = [
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux], cleanupErrorCategory: "cleanup-error" })),
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux] })),
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive] })),
finalizePackageResult(telemetry({ items: [failedItem] }))
];
expect(failures.map(({ failurePhase, errorCategory }) => ({ failurePhase, errorCategory }))).toEqual([
{ failurePhase: "cleanup", errorCategory: "Cleanup" },
{ failurePhase: "remux", errorCategory: "Remux" },
{ failurePhase: "extract", errorCategory: "Entpacken" },
{ failurePhase: "download", errorCategory: "Download" }
]);
});
it("retains additive failure counters when cleanup is the primary failure phase", () => {
const failedItem = { ...downloadItem("item-1", "failed"), lastError: "Host ist offline" };
const packageResult = finalizePackageResult(telemetry({
items: [failedItem],
archiveOperations: [archiveOperation({ status: "failed", errorCategory: "archive-error" })],
remuxOperations: [remuxOperation({ status: "failed", errorCategory: "remux-error" })],
cleanupErrorCategory: "cleanup-error"
}));
const runResult = buildRunResult({
id: "mixed-failures",
stopped: false,
startedAt: 1_000,
completedAt: 166_000,
packages: [packageResult]
});
expect(packageResult).toEqual(expect.objectContaining({
failurePhase: "cleanup",
downloadFailures: 1,
offlineFailures: 1,
extractionFailures: 1,
remuxFailures: 1,
cleanupFailures: 1
}));
expect(runResult).toEqual(expect.objectContaining({
downloadFailures: 1,
offlineFailures: 1,
extractionFailures: 1,
remuxFailures: 1,
cleanupFailures: 1
}));
});
it("uses audio-strip outcomes when no individual remux operation was recorded", () => {
const items = [downloadItem("item-1"), downloadItem("item-2")];
const result = finalizePackageResult(telemetry({
package: packageEntry({
itemIds: items.map((item) => item.id),
audioStripSummary: {
at: 160_000,
candidates: 2,
remuxed: 1,
keptSingle: 0,
skippedNoGerman: 0,
skippedNoTool: 0,
failed: 1,
files: []
}
}),
items
}));
expect(result).toEqual(expect.objectContaining({
status: "partial",
successfulFiles: 1,
failedFiles: 1,
failurePhase: "remux"
}));
});
it("clamps invalid and reversed durations instead of producing negative or non-finite values", () => {
expect(durationMsToSeconds(1_999)).toBe(1);
expect(durationMsToSeconds(-1)).toBe(0);
expect(durationMsToSeconds(Number.NaN)).toBe(0);
expect(durationSecondsBetween(5_000, 4_000)).toBe(0);
expect(durationSecondsBetween(undefined, 5_000)).toBe(0);
});
});