feat(history): add package lifecycle telemetry
This commit is contained in:
@@ -53,6 +53,13 @@ describe("revealHistoryEntry", () => {
|
||||
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||
});
|
||||
|
||||
it.each(["partial", "failed", "cancelled"] as const)("opens a %s history result from the same authoritative directory", async (status) => {
|
||||
const deps = dependencies({ loadHistory: () => [historyEntry({ status })] });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
|
||||
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||
});
|
||||
|
||||
it("ignores every renderer-supplied field except entryId", async () => {
|
||||
const deps = dependencies();
|
||||
|
||||
|
||||
@@ -133,10 +133,19 @@ describe("history model", () => {
|
||||
|
||||
for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) {
|
||||
expect(filterHistoryRows(entries, filter, "", now).map((row) => row.id)).toEqual(ids);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("labels partial and cancelled package results", () => {
|
||||
const rows = filterHistoryRows([
|
||||
entry({ id: "partial", name: "Teilweise", status: "partial" }),
|
||||
entry({ id: "cancelled", name: "Abgebrochen", status: "cancelled" })
|
||||
], "all", "", now);
|
||||
|
||||
expect(rows.map((row) => row.statusLabel)).toEqual(["Teilweise", "Abgebrochen"]);
|
||||
});
|
||||
|
||||
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
|
||||
const springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime();
|
||||
const springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime();
|
||||
const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime();
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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";
|
||||
|
||||
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: "checksum"
|
||||
}));
|
||||
});
|
||||
|
||||
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-error"
|
||||
}));
|
||||
});
|
||||
|
||||
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("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" });
|
||||
|
||||
expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux], cleanupErrorCategory: "cleanup-error" })).failurePhase).toBe("cleanup");
|
||||
expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux] })).failurePhase).toBe("remux");
|
||||
expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive] })).failurePhase).toBe("extract");
|
||||
expect(finalizePackageResult(telemetry({ items: [failedItem] })).failurePhase).toBe("download");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+199
-5
@@ -6,7 +6,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||
@@ -981,7 +981,7 @@ describe("settings storage", () => {
|
||||
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults history retention to permanent and normalizes invalid values", () => {
|
||||
it("defaults history retention to permanent and normalizes invalid values", () => {
|
||||
expect(defaultSettings().historyRetentionMode).toBe("permanent");
|
||||
|
||||
const normalized = normalizeSettings({
|
||||
@@ -989,9 +989,203 @@ describe("settings storage", () => {
|
||||
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
|
||||
});
|
||||
|
||||
expect(normalized.historyRetentionMode).toBe("permanent");
|
||||
});
|
||||
|
||||
expect(normalized.historyRetentionMode).toBe("permanent");
|
||||
});
|
||||
|
||||
it("loads legacy history without inventing structured durations", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||
id: "legacy",
|
||||
name: "Altbestand",
|
||||
totalBytes: 1_000,
|
||||
downloadedBytes: 1_000,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid",
|
||||
completedAt: 10_000,
|
||||
durationSeconds: 9,
|
||||
status: "completed",
|
||||
outputDir: "C:\\Downloads\\Altbestand"
|
||||
}]), "utf8");
|
||||
|
||||
const [loaded] = loadHistory(paths);
|
||||
|
||||
expect(loaded).toEqual(expect.objectContaining({
|
||||
durationSeconds: 9,
|
||||
status: "completed"
|
||||
}));
|
||||
expect(loaded.downloadDurationSeconds).toBeUndefined();
|
||||
expect(loaded.totalDurationSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["completed", "partial", "failed", "cancelled", "deleted"] as const)("preserves the %s history status", (status) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||
id: `history-${status}`,
|
||||
name: "Paket",
|
||||
completedAt: 10_000,
|
||||
durationSeconds: 1,
|
||||
status,
|
||||
outputDir: "C:\\Downloads\\Paket"
|
||||
}]), "utf8");
|
||||
|
||||
expect(loadHistory(paths)[0]?.status).toBe(status);
|
||||
});
|
||||
|
||||
it("clamps expanded history metrics and preserves normalized operations", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||
id: "structured",
|
||||
name: "Paket",
|
||||
completedAt: 50_000,
|
||||
durationSeconds: 1,
|
||||
status: "failed",
|
||||
outputDir: "C:\\Downloads\\Paket",
|
||||
startedAt: -10,
|
||||
downloadEndedAt: 20_000,
|
||||
postProcessStartedAt: 25_000,
|
||||
downloadDurationSeconds: -120,
|
||||
extractionDurationSeconds: 30,
|
||||
remuxDurationSeconds: 5,
|
||||
postProcessDurationSeconds: 35,
|
||||
totalDurationSeconds: 50,
|
||||
successfulFiles: -1,
|
||||
failedFiles: 1,
|
||||
cancelledFiles: 0,
|
||||
archiveCount: 1,
|
||||
partCount: 16,
|
||||
outputCount: 15,
|
||||
failurePhase: "extract",
|
||||
archiveOperations: [{
|
||||
id: "archive-1",
|
||||
name: "Paket.part01.rar",
|
||||
itemIds: ["item-1", "", "item-2"],
|
||||
partCount: -16,
|
||||
startedAt: 25_000,
|
||||
completedAt: 50_000,
|
||||
durationMs: -30_000,
|
||||
status: "failed",
|
||||
errorCategory: "checksum"
|
||||
}],
|
||||
remuxOperations: [{
|
||||
id: "remux-1",
|
||||
fileName: "episode.mkv",
|
||||
startedAt: 30_000,
|
||||
completedAt: 35_000,
|
||||
durationMs: 5_000,
|
||||
status: "cancelled",
|
||||
errorCategory: "cancelled"
|
||||
}]
|
||||
}]), "utf8");
|
||||
|
||||
expect(loadHistory(paths)[0]).toEqual(expect.objectContaining({
|
||||
status: "failed",
|
||||
startedAt: 0,
|
||||
downloadEndedAt: 20_000,
|
||||
postProcessStartedAt: 25_000,
|
||||
downloadDurationSeconds: 0,
|
||||
extractionDurationSeconds: 30,
|
||||
remuxDurationSeconds: 5,
|
||||
postProcessDurationSeconds: 35,
|
||||
totalDurationSeconds: 50,
|
||||
successfulFiles: 0,
|
||||
failedFiles: 1,
|
||||
cancelledFiles: 0,
|
||||
archiveCount: 1,
|
||||
partCount: 16,
|
||||
outputCount: 15,
|
||||
failurePhase: "extract",
|
||||
archiveOperations: [{
|
||||
id: "archive-1",
|
||||
name: "Paket.part01.rar",
|
||||
itemIds: ["item-1", "item-2"],
|
||||
partCount: 0,
|
||||
startedAt: 25_000,
|
||||
completedAt: 50_000,
|
||||
durationMs: 0,
|
||||
status: "failed",
|
||||
errorCategory: "checksum"
|
||||
}],
|
||||
remuxOperations: [{
|
||||
id: "remux-1",
|
||||
fileName: "episode.mkv",
|
||||
startedAt: 30_000,
|
||||
completedAt: 35_000,
|
||||
durationMs: 5_000,
|
||||
status: "cancelled",
|
||||
errorCategory: "cancelled"
|
||||
}]
|
||||
}));
|
||||
});
|
||||
|
||||
it("preserves package lifecycle timestamps and operation metrics when loading a session", () => {
|
||||
const normalized = normalizeLoadedSession({
|
||||
version: 2,
|
||||
packageOrder: ["pkg-1"],
|
||||
packages: {
|
||||
"pkg-1": {
|
||||
id: "pkg-1",
|
||||
name: "Paket",
|
||||
outputDir: "C:\\Downloads\\Paket",
|
||||
extractDir: "C:\\Downloads\\Paket",
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
downloadStartedAt: 1_000,
|
||||
downloadCompletedAt: 10_000,
|
||||
downloadEndedAt: 12_000,
|
||||
postProcessQueuedAt: 13_000,
|
||||
postProcessStartedAt: 14_000,
|
||||
postProcessCompletedAt: 20_000,
|
||||
terminalAt: 21_000,
|
||||
archiveOperations: [{
|
||||
id: "archive-1",
|
||||
name: "Paket.rar",
|
||||
itemIds: [],
|
||||
partCount: 1,
|
||||
startedAt: 14_000,
|
||||
completedAt: 18_000,
|
||||
durationMs: 4_000,
|
||||
status: "completed",
|
||||
errorCategory: ""
|
||||
}],
|
||||
remuxOperations: [],
|
||||
outputCount: 1,
|
||||
cleanupErrorCategory: "",
|
||||
createdAt: 1_000,
|
||||
updatedAt: 21_000
|
||||
}
|
||||
},
|
||||
items: {},
|
||||
runStartedAt: 1_000,
|
||||
totalDownloadedBytes: 0,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: false,
|
||||
updatedAt: 21_000
|
||||
});
|
||||
|
||||
expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({
|
||||
downloadEndedAt: 12_000,
|
||||
postProcessQueuedAt: 13_000,
|
||||
postProcessStartedAt: 14_000,
|
||||
postProcessCompletedAt: 20_000,
|
||||
terminalAt: 21_000,
|
||||
archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })],
|
||||
remuxOperations: [],
|
||||
outputCount: 1,
|
||||
cleanupErrorCategory: ""
|
||||
}));
|
||||
});
|
||||
|
||||
it("skips adding persisted history entries when history retention is never", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
Reference in New Issue
Block a user