fix: strengthen live recovery and support diagnostics

Apply account and key changes to active queues without a restart and isolate provider attempt cancellation so fallback accounts remain usable. Preserve pause ownership, bound persisted HTTP 416 recovery, reconcile resets with authoritative state, and stabilize package ordering and live update cadence. Correlate rotation, conversion, resume, disk, queue-control, clipboard, and support-export events while redacting sensitive data at every persistent boundary and again in generated bundles. Release as v2.0.31 with updated English documentation and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-13 11:36:51 +02:00
parent 88399c5dd0
commit 25ebc55f4f
44 changed files with 5223 additions and 959 deletions
+703 -5
View File
@@ -6,18 +6,39 @@ import { afterEach, describe, expect, it } from "vitest";
import {
buildSupportBundle,
createSupportBundleExportRunner,
type SupportBundleExportLifecycleEvent,
writeSupportBundleAtomically
} from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
import { configureLogger, flushLoggerSync, logger } from "../src/main/logger";
import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
import { ensureItemLog, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import {
primeDebridLinkRuntimeCooldownForTests,
primeMegaDebridInFlightForTests,
primeMegaDebridRuntimeCooldownForTests,
resetDebridLinkRuntimeStateForTests,
resetMegaDebridRuntimeStateForTests
} from "../src/main/debrid";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
afterEach(() => {
shutdownTraceLog();
shutdownItemLogs();
shutdownPackageLogs();
shutdownSessionLog();
shutdownAccountRotationLog();
resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests();
flushLoggerSync();
configureLogger(process.cwd());
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
}
@@ -149,17 +170,336 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(buffer.length).toBeGreaterThan(0);
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
expect(entries).toContain("overview/meta.json");
expect(entries).toContain("overview/meta.json");
expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/debug-setup.json");
expect(entries).not.toContain("overview/self-check.json");
expect(entries).toContain("runtime/debug_host.txt");
expect(entries).toContain("runtime/debug_support_manifest.json");
expect(entries).toContain("overview/support-manifest.json");
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
});
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
const meta = JSON.parse(new AdmZip(buffer).getEntry("overview/meta.json")!.getData().toString("utf8"));
expect(meta.limits).toMatchObject({
directoryLogDiscoveryWindowHours: 8,
currentAndRelevantLogsIgnoreAgeFilter: true
});
expect(meta.limits).not.toHaveProperty("logWindowHours");
});
it("replaces overview clear names with stable bundle-local aliases while retaining extension, size, status and correlation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-aliases-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "rd_history.json"), JSON.stringify([{
id: "history-private-id",
name: "Private Linux Collection.iso",
totalBytes: 8_000,
downloadedBytes: 8_000,
fileCount: 1,
provider: "megadebrid-web",
completedAt: 4,
durationSeconds: 5,
status: "completed",
outputDir: "C:\\Private\\History",
urls: ["https://example.invalid/private"]
}]), "utf8");
const snapshot = {
stats: {},
session: {
version: 1,
packageOrder: ["package-private-id"],
packages: {
"package-private-id": {
id: "package-private-id",
name: "Private Series Collection.zip",
outputDir: "C:\\Private\\Output",
extractDir: "C:\\Private\\Extract",
status: "downloading",
itemIds: ["item-private-id"],
cancelled: false,
enabled: true,
cleanedDownloadedBytes: 500,
cleanedTotalBytes: 500,
createdAt: 1,
updatedAt: 2
}
},
items: {
"item-private-id": {
id: "item-private-id",
packageId: "package-private-id",
url: "https://rapidgator.net/file/example",
provider: "megadebrid-web",
status: "downloading",
retries: 0,
speedBps: 100,
downloadedBytes: 250,
totalBytes: 1_000,
progressPercent: 25,
fileName: "Private.Show.S01E01.part1.rar",
targetPath: "C:\\Private\\Output\\Private.Show.S01E01.part1.rar",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 750,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 100 B/s",
etaText: "ETA: 1m",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const zip = new AdmZip(buffer);
const packages = JSON.parse(zip.getEntry("overview/packages.json")!.getData().toString("utf8"));
const items = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
const history = JSON.parse(zip.getEntry("overview/history.json")!.getData().toString("utf8"));
const overviewText = [packages, items, history].map((value) => JSON.stringify(value)).join("\n");
expect(packages.packages[0]).toMatchObject({
id: "package-private-id",
name: "package-001.zip",
status: "downloading",
downloadedBytes: 750,
totalBytes: 1_500
});
expect(items.items[0]).toMatchObject({
id: "item-private-id",
packageId: "package-private-id",
fileName: "item-001.rar",
status: "downloading",
downloadedBytes: 250,
totalBytes: 1_000
});
expect(history.entries[0]).toMatchObject({
id: "history-private-id",
name: "history-001.iso",
status: "completed",
downloadedBytes: 8_000,
totalBytes: 8_000
});
expect(overviewText).not.toContain("Private Series Collection.zip");
expect(overviewText).not.toContain("Private.Show.S01E01.part1.rar");
expect(overviewText).not.toContain("Private Linux Collection.iso");
});
it("adds provider runtime diagnostics with pool-local aliases and no internal account or key identifiers", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-provider-runtime-"));
tempDirs.push(root);
const disabledApiAccountId = getMegaDebridAccountId("beta-login");
const disabledDebridKeyId = getDebridLinkApiKeyId("debrid-token-two");
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
megaDebridApiCredentials: "alpha-login:alpha-password\nbeta-login:beta-password",
megaDebridWebCredentials: "gamma-login:gamma-password",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
megaDebridApiDisabledAccountIds: [disabledApiAccountId],
debridLinkApiKeys: "debrid-token-one,debrid-token-two",
debridLinkDisabledKeyIds: [disabledDebridKeyId]
}), "utf8");
const apiAccountKey = `${getMegaDebridAccountId("alpha-login")}:api`;
const debridKeyId = getDebridLinkApiKeyId("debrid-token-one");
primeMegaDebridRuntimeCooldownForTests(apiAccountKey, 60_000, "private account cooldown detail");
primeMegaDebridInFlightForTests(apiAccountKey, 2);
primeDebridLinkRuntimeCooldownForTests(debridKeyId, 45_000, "private key cooldown detail");
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const runtime = JSON.parse(new AdmZip(buffer).getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
const providerText = JSON.stringify(runtime.providerRuntime);
expect(runtime.providerRuntime).toMatchObject({
megaDebrid: {
rotationCursor: 0,
pools: {
api: {
configuredCount: 2,
activeCount: 1,
disabledCount: 1,
inFlight: 2,
accounts: [{
account: "Account 1/2",
inFlight: 2,
cooldown: {
category: "temporary"
}
}]
},
web: {
configuredCount: 1,
activeCount: 0,
enabled: false,
inFlight: 0
}
}
},
debridLink: {
configuredCount: 2,
activeCount: 1,
disabledCount: 1,
keys: [{
account: "Key 1/2",
cooldown: {
category: "temporary"
}
}]
}
});
expect(runtime.providerRuntime.megaDebrid.pools.api.accounts[0].cooldown.remainingMs).toBeGreaterThan(0);
expect(runtime.providerRuntime.debridLink.keys[0].cooldown.remainingMs).toBeGreaterThan(0);
for (const forbidden of [
"alpha-login",
"beta-login",
"gamma-login",
"debrid-token-one",
"debrid-token-two",
getMegaDebridAccountId("alpha-login"),
debridKeyId
]) {
expect(providerText).not.toContain(forbidden);
}
});
it("includes runtime rotation, disk-wait, export-phase and resume-recovery diagnostics", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
const snapshot = {
stats: {},
rotationEvents: [{
id: "rotation-1",
at: 1_234,
level: "WARN",
provider: "Mega-Debrid Web",
accountLabel: "Account 2/3 (be***ta)",
event: "FAILED",
reason: "timeout",
next: "Account 3/3 (ga***ma)"
}],
diskWaitEvents: [{
phase: "download",
ownerId: "item-resume",
itemId: "item-resume",
packageId: "package-resume",
volumeKey: "C:",
requiredBytes: 2_048,
availableBytes: 1_024,
deficitBytes: 1_024,
retryAt: 2_000
}],
session: {
version: 1,
packageOrder: ["package-resume"],
packages: {
"package-resume": {
id: "package-resume",
name: "Resume",
status: "queued",
itemIds: ["item-resume"],
cancelled: false,
enabled: true,
createdAt: 1,
updatedAt: 2
}
},
items: {
"item-resume": {
id: "item-resume",
packageId: "package-resume",
url: "https://rapidgator.net/file/example",
provider: "megadebrid-web",
status: "queued",
retries: 2,
speedBps: 0,
downloadedBytes: 1_024,
totalBytes: 2_048,
progressPercent: 50,
fileName: "resume.bin",
targetPath: "C:\\Downloads\\resume.bin",
resumable: true,
attempts: 1,
lastError: "range_ignored_on_resume:1024/2048",
fullStatus: "Warte auf Teildatei-Freigabe",
resumeLinkRenewalFailures: 2,
resumeHardResetUsed: false,
resumeResetPending: true,
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 1_024,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 0 B/s",
etaText: "ETA: --",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const zip = new AdmZip(buffer);
const runtimeDiagnostics = JSON.parse(zip.getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
const itemDiagnostics = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
expect(runtimeDiagnostics).toMatchObject({
bundleBuild: {
state: "building",
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
},
rotationEvents: [{
provider: "Mega-Debrid Web",
accountLabel: "Account 2/3 (<redacted-account>)",
event: "FAILED",
reason: "timeout"
}],
diskWaitEvents: [{
phase: "download",
itemId: "item-resume",
deficitBytes: 1_024
}]
});
expect(runtimeDiagnostics.bundleBuild.startedAt).toEqual(expect.any(String));
expect(itemDiagnostics.items[0]).toMatchObject({
id: "item-resume",
resumeLinkRenewalFailures: 2,
resumeHardResetUsed: false,
resumeResetPending: true
});
});
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
@@ -215,6 +555,53 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(sessionEntries).toHaveLength(1);
});
it("flushes every pending logger before reading bundle files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-flush-"));
tempDirs.push(root);
flushLoggerSync();
configureLogger(root);
initSessionLog(root);
initPackageLogs(root);
initItemLogs(root);
initTraceLog(root);
setTraceEnabled(true, "bundle-flush-test", 0);
ensurePackageLog({
packageId: "package-flush",
name: "Flush Package",
outputDir: path.join(root, "output"),
extractDir: path.join(root, "extract")
});
ensureItemLog({
itemId: "item-flush",
packageId: "package-flush",
packageName: "Flush Package",
fileName: "flush.bin",
targetPath: path.join(root, "output", "flush.bin")
});
logger.info("main-buffer-marker");
logPackageEvent("package-flush", "INFO", "package-buffer-marker");
logItemEvent("item-flush", "INFO", "item-buffer-marker");
logTraceEvent("INFO", "support", "trace-buffer-marker");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const zip = new AdmZip(buffer);
const packageEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/package-logs/"));
const itemEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/item-logs/"));
const entryNames = zip.getEntries().map((entry) => entry.entryName);
expect(zip.getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
expect(zip.getEntry("logs/session.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
expect(zip.getEntry("logs/trace.log")?.getData().toString("utf8") || "").toContain("trace-buffer-marker");
expect(packageEntry, entryNames.join("\n")).toBeDefined();
expect(itemEntry, entryNames.join("\n")).toBeDefined();
expect(packageEntry?.getData().toString("utf8") || "").toContain("package-buffer-marker");
expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker");
});
it("bounds recent item logs to the newest diagnostic files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
@@ -240,6 +627,113 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(itemEntries).toContain("logs/item-logs/item-364.txt");
});
it("prioritizes active package and item logs beyond the bounded directory scan", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-priority-"));
tempDirs.push(root);
const packageLogs = path.join(root, "package-logs");
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(packageLogs, { recursive: true });
fs.mkdirSync(itemLogs, { recursive: true });
for (let index = 0; index < 2_048; index += 1) {
const name = `${String(index).padStart(4, "0")}-filler.log`;
fs.writeFileSync(path.join(packageLogs, name), "package filler", "utf8");
fs.writeFileSync(path.join(itemLogs, name), "item filler", "utf8");
}
initPackageLogs(root);
initItemLogs(root);
ensurePackageLog({
packageId: "zzzz-active-package",
name: "Active Package",
outputDir: path.join(root, "output"),
extractDir: path.join(root, "extract")
});
ensureItemLog({
itemId: "zzzz-active-item",
packageId: "zzzz-active-package",
packageName: "Active Package",
fileName: "active.bin",
targetPath: path.join(root, "output", "active.bin")
});
logPackageEvent("zzzz-active-package", "INFO", "active-package-marker");
logItemEvent("zzzz-active-item", "INFO", "active-item-marker");
const snapshot = {
stats: {},
session: {
version: 1,
packageOrder: ["zzzz-active-package"],
packages: {
"zzzz-active-package": {
id: "zzzz-active-package",
name: "Active Package",
status: "downloading",
itemIds: ["zzzz-active-item"],
cancelled: false,
enabled: true,
createdAt: 1,
updatedAt: 2
}
},
items: {
"zzzz-active-item": {
id: "zzzz-active-item",
packageId: "zzzz-active-package",
url: "https://files.example.test/active",
status: "downloading",
retries: 0,
speedBps: 1,
downloadedBytes: 1,
totalBytes: 2,
progressPercent: 50,
fileName: "active.bin",
targetPath: path.join(root, "output", "active.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Lädt",
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 1,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 1 B/s",
etaText: "ETA: 1s",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => { throw new Error("bundle export must not create package logs"); },
getItemLogPath: () => { throw new Error("bundle export must not create item logs"); }
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const zip = new AdmZip(buffer);
const packageEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/package-logs/"));
const itemEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
const packageText = packageEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
const itemText = itemEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
expect(packageText).toContain("active-package-marker");
expect(itemText).toContain("active-item-marker");
expect(packageEntries.length).toBeLessThanOrEqual(8);
expect(itemEntries.length).toBeLessThanOrEqual(16);
}, 15_000);
it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
tempDirs.push(root);
@@ -344,6 +838,91 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(itemOverview.items?.[0]).not.toHaveProperty("url");
});
it("redacts slash-escaped URLs at the ZIP boundary after credentials change", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-escaped-url-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(
path.join(itemLogs, "escaped-url.log"),
String.raw`{"url":"https:\/\/legacy-user:p!7@escaped-private-host.invalid\/secret?value=private"}`,
"utf8"
);
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const text = new AdmZip(buffer).getEntry("logs/item-logs/escaped-url.log")?.getData().toString("utf8") || "";
expect(text).toContain("<redacted-url>");
expect(text).not.toContain("legacy-user");
expect(text).not.toContain("p!7");
expect(text).not.toContain("escaped-private-host.invalid");
});
it("redacts historical comma-style account labels at the ZIP boundary", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-account-label-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "historical-labels.log"), [
"Mega-Debrid (Account 1/3, Hi*******cal): uebersprungen",
"Debrid-Link (Key 2/4, old********value): fehlgeschlagen"
].join("\n"), "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const text = new AdmZip(buffer).getEntry("logs/item-logs/historical-labels.log")?.getData().toString("utf8") || "";
expect(text).toContain("Account 1/3, <redacted-account>");
expect(text).toContain("Key 2/4, <redacted-account>");
expect(text).not.toContain("Hi*******cal");
expect(text).not.toContain("old********value");
});
it("keeps static archive directories stable when a short credential matches their name", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-static-path-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
megaDebridWebCredentials: "archive-user:logs"
}), "utf8");
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "recent.log"), "diagnostic", "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
expect(entries).toContain("logs/item-logs/recent.log");
});
it("keeps separately redacted log filenames distinct", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-archive-names-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-one.log"), "first-log-marker", "utf8");
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-two.log"), "second-log-marker", "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
const text = entries.map((entry) => entry.getData().toString("utf8")).join("\n");
expect(entries).toHaveLength(2);
expect(new Set(entries.map((entry) => entry.entryName)).size).toBe(2);
expect(text).toContain("first-log-marker");
expect(text).toContain("second-log-marker");
});
it("bounds active DTOs and recent log tails while keeping the event loop responsive", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-load-"));
tempDirs.push(root);
@@ -394,6 +973,99 @@ describe("buildSupportBundle (async, non-blocking)", () => {
});
describe("support bundle export runner", () => {
it("emits busy, cancel, build, write and success lifecycle phases with deterministic durations", async () => {
let now = 0;
let releaseBuild: (buffer: Buffer) => void = () => undefined;
let signalBuildStarted: () => void = () => undefined;
const buildStarted = new Promise<void>((resolve) => { signalBuildStarted = resolve; });
const buildPending = new Promise<Buffer>((resolve) => { releaseBuild = resolve; });
const lifecycle: SupportBundleExportLifecycleEvent[] = [];
let chooseCount = 0;
const run = createSupportBundleExportRunner({
now: () => now,
chooseFile: async () => {
chooseCount += 1;
now += 5;
return chooseCount === 1 ? "C:\\Private\\support.zip" : null;
},
build: async () => {
signalBuildStarted();
const buffer = await buildPending;
now += 20;
return buffer;
},
write: async () => {
now += 30;
},
onLifecycle: (event) => {
lifecycle.push(event);
}
});
const first = run();
await buildStarted;
await expect(run()).resolves.toMatchObject({ saved: false, busy: true });
releaseBuild(Buffer.from("zip"));
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Private\\support.zip" });
await expect(run()).resolves.toEqual({ saved: false, busy: false });
expect(lifecycle).toEqual([
{ phase: "busy", durationMs: 0, totalDurationMs: 0 },
{ phase: "build", durationMs: 20, totalDurationMs: 25, bytes: 3 },
{ phase: "write", durationMs: 30, totalDurationMs: 55, bytes: 3 },
{ phase: "success", durationMs: 55, totalDurationMs: 55, bytes: 3 },
{ phase: "cancel", durationMs: 5, totalDurationMs: 5 }
]);
});
it("reports a path-free failure phase and duration when writing fails", async () => {
let now = 100;
const lifecycle: SupportBundleExportLifecycleEvent[] = [];
const failures: unknown[] = [];
const target = "C:\\Users\\Alice\\Desktop\\private-support.zip";
const run = createSupportBundleExportRunner({
now: () => now,
chooseFile: async () => {
now += 4;
return target;
},
build: async () => {
now += 6;
return Buffer.from("zip");
},
write: async () => {
now += 9;
throw Object.assign(new Error(`ENOSPC while writing ${target}`), { code: "ENOSPC" });
},
onLifecycle: (event) => {
lifecycle.push(event);
},
onFailure: (error) => {
failures.push(error);
}
});
await expect(run()).rejects.toMatchObject({
name: "SupportBundleExportError",
phase: "write",
durationMs: 19,
code: "ENOSPC"
});
expect(lifecycle).toEqual([
{ phase: "build", durationMs: 6, totalDurationMs: 10, bytes: 3 },
{
phase: "failure",
failedPhase: "write",
durationMs: 9,
totalDurationMs: 19,
code: "ENOSPC"
}
]);
expect(failures).toHaveLength(1);
expect(String((failures[0] as Error).message)).not.toContain(target);
expect(String((failures[0] as Error).message)).not.toContain("Alice");
});
it("returns a visible busy result for reentry without choosing another target", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root);
@@ -430,6 +1102,32 @@ describe("support bundle export runner", () => {
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target });
});
it("records the selected export target before bundle construction starts", async () => {
const phases: string[] = [];
const run = createSupportBundleExportRunner({
chooseFile: async () => {
phases.push("choose");
return "C:\\Temp\\support.zip";
},
onStart: ({ filePath }) => {
phases.push(`start:${path.basename(filePath)}`);
},
build: async () => {
phases.push("build");
return Buffer.from("zip");
},
write: async () => {
phases.push("write");
},
onSuccess: () => {
phases.push("success");
}
});
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Temp\\support.zip" });
expect(phases).toEqual(["choose", "start:support.zip", "build", "write", "success"]);
});
it("reports success only after the target write has completed", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root);
@@ -476,7 +1174,7 @@ describe("support bundle export runner", () => {
}
});
await expect(run()).rejects.toThrow("write failed");
await expect(run()).rejects.toMatchObject({ name: "SupportBundleExportError", phase: "write" });
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
});