fix: harden account rotation and active download recovery
Apply account enablement changes optimistically and persist rapid toggles sequentially without losing clicks. Refresh Mega-Debrid API and Web account pools during active downloads, isolate per-account cancellation budgets, rotate within the same conversion attempt, and keep pause, stop, resume, reset, and no-account gates consistent. Use the native Electron clipboard under Remote Desktop and make support bundle export bounded, redacted, responsive, guarded, visible, and atomic. Add end-to-end regressions for live account switching, persisted partial recovery, support export, native copy, availability preservation, and the 500 ms telemetry cadence.
This commit is contained in:
@@ -61,4 +61,15 @@ describe("account preload contract", () => {
|
||||
IPC_CHANNELS.DELETE_ACCOUNT
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes copied text through the native Electron clipboard channel", async () => {
|
||||
electron.invoke.mockResolvedValueOnce(true);
|
||||
|
||||
await electron.api?.writeClipboardText("https://rapidgator.net/file/example");
|
||||
|
||||
expect(electron.invoke).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.WRITE_CLIPBOARD_TEXT,
|
||||
"https://rapidgator.net/file/example"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SerialTaskQueue,
|
||||
setAccountTargetEnabled,
|
||||
type AccountToggleTarget
|
||||
} from "../src/renderer/account-toggle-queue";
|
||||
|
||||
describe("account toggle queue", () => {
|
||||
it("keeps every rapid mutation and persists them in click order", async () => {
|
||||
const queue = new SerialTaskQueue();
|
||||
const calls: number[] = [];
|
||||
const first = queue.enqueue(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
calls.push(1);
|
||||
});
|
||||
const second = queue.enqueue(async () => {
|
||||
calls.push(2);
|
||||
});
|
||||
const third = queue.enqueue(async () => {
|
||||
calls.push(3);
|
||||
});
|
||||
|
||||
await Promise.all([first, second, third]);
|
||||
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("continues after a failed mutation", async () => {
|
||||
const queue = new SerialTaskQueue();
|
||||
const calls: number[] = [];
|
||||
const failed = queue.enqueue(async () => {
|
||||
calls.push(1);
|
||||
throw new Error("failed");
|
||||
});
|
||||
const recovered = queue.enqueue(async () => {
|
||||
calls.push(2);
|
||||
});
|
||||
|
||||
await expect(failed).rejects.toThrow("failed");
|
||||
await recovered;
|
||||
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("combines rapid Mega-Debrid Web activations without losing earlier clicks", () => {
|
||||
const accountIds = ["web-1", "web-2", "web-3"];
|
||||
let settings = {
|
||||
disabledProviders: [],
|
||||
debridLinkDisabledKeyIds: [],
|
||||
megaDebridApiDisabledAccountIds: [],
|
||||
megaDebridWebDisabledAccountIds: [...accountIds],
|
||||
megaDebridDisabledAccountIds: [...accountIds]
|
||||
};
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const target: AccountToggleTarget = { kind: "mega-web", accountId };
|
||||
settings = setAccountTargetEnabled(settings, target, true);
|
||||
}
|
||||
|
||||
expect(settings.megaDebridWebDisabledAccountIds).toEqual([]);
|
||||
expect(settings.megaDebridDisabledAccountIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
|
||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||
import { getSnapshotRenderDelay } from "../src/renderer/App";
|
||||
import { getSnapshotRenderDelay, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
||||
|
||||
describe("desktop shell", () => {
|
||||
it("uses keyboard-focusable controls for every copy target", () => {
|
||||
@@ -13,8 +13,8 @@ describe("desktop shell", () => {
|
||||
|
||||
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
|
||||
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3);
|
||||
expect(source).not.toContain("navigator.clipboard.writeText(key.token)");
|
||||
expect(source).toContain("navigator.clipboard.writeText(key.masked)");
|
||||
expect(source).not.toContain("navigator.clipboard.writeText");
|
||||
expect(source).toContain("window.rd.writeClipboardText(key.masked)");
|
||||
expect(source).toContain("Maskierte Kennung kopiert");
|
||||
});
|
||||
|
||||
@@ -35,11 +35,57 @@ describe("desktop shell", () => {
|
||||
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||
});
|
||||
|
||||
it("does not stack renderer latency on the manager cadence for large active queues", () => {
|
||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
||||
it("renders active download telemetry at a stable half-second cadence", () => {
|
||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
|
||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
||||
});
|
||||
|
||||
it("keeps support bundle progress tied to the unresolved export", async () => {
|
||||
let resolveExport: (value: { saved: boolean; busy?: boolean }) => void = () => undefined;
|
||||
const pendingExport = new Promise<{ saved: boolean; busy?: boolean }>((resolve) => { resolveExport = resolve; });
|
||||
const busyStates: boolean[] = [];
|
||||
const messages: string[] = [];
|
||||
|
||||
const running = runSupportBundleExportUi({
|
||||
exportBundle: () => pendingExport,
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
clearMessage: () => { messages.length = 0; },
|
||||
showMessage: (message) => { messages.push(message); }
|
||||
});
|
||||
|
||||
expect(busyStates).toEqual([true]);
|
||||
expect(messages).toEqual([]);
|
||||
resolveExport({ saved: true });
|
||||
await running;
|
||||
expect(busyStates).toEqual([true, false]);
|
||||
expect(messages).toEqual(["Support-Bundle exportiert"]);
|
||||
});
|
||||
|
||||
it("ends support bundle progress immediately when the file dialog is canceled", async () => {
|
||||
const busyStates: boolean[] = [];
|
||||
const messages = ["Vorherige Meldung"];
|
||||
|
||||
await runSupportBundleExportUi({
|
||||
exportBundle: async () => ({ saved: false, busy: false }),
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
clearMessage: () => { messages.length = 0; },
|
||||
showMessage: (message) => { messages.push(message); }
|
||||
});
|
||||
|
||||
expect(busyStates).toEqual([true, false]);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders progress from the real busy state instead of a timed status message", () => {
|
||||
const busyHtml = renderToStaticMarkup(<SupportBundleToast busy message="Alte Meldung" />);
|
||||
const idleHtml = renderToStaticMarkup(<SupportBundleToast busy={false} message="Export beendet" />);
|
||||
|
||||
expect(busyHtml).toContain("Support-Bundle wird erstellt …");
|
||||
expect(busyHtml).not.toContain("Alte Meldung");
|
||||
expect(idleHtml).toContain("Export beendet");
|
||||
expect(idleHtml).not.toContain("Support-Bundle wird erstellt …");
|
||||
});
|
||||
|
||||
it("keeps application menus mounted for animated opening and closing", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||
|
||||
+274
-42
@@ -8,13 +8,14 @@ import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearM
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetDebridLinkRuntimeStateForTests();
|
||||
resetMegaDebridRuntimeStateForTests();
|
||||
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetDebridLinkRuntimeStateForTests();
|
||||
resetMegaDebridRuntimeStateForTests();
|
||||
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
||||
delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("leadProviderChainWith", () => {
|
||||
it("leaves the order unchanged when no preferred provider is given", () => {
|
||||
@@ -1398,7 +1399,7 @@ describe("debrid service", () => {
|
||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fallback from Mega API to Mega Web unless Mega Web is a separate provider in the order", async () => {
|
||||
it("does not fallback from Mega API to Mega Web unless Mega Web is a separate provider in the order", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
@@ -1426,8 +1427,183 @@ describe("debrid service", () => {
|
||||
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/mega-api-only.rar.html")).rejects.toThrow(/mega-debrid api/i);
|
||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("isolates Mega-Debrid API single-flight consumers so one abort does not cancel the shared connect", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "single-flight-user:single-flight-pass",
|
||||
megaDebridApiCredentials: "single-flight-user:single-flight-pass",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-api" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
let releaseConnect: () => void = () => {};
|
||||
let markConnectStarted: () => void = () => {};
|
||||
const connectStarted = new Promise<void>((resolve) => {
|
||||
markConnectStarted = resolve;
|
||||
});
|
||||
let connectSignal: AbortSignal | undefined;
|
||||
let connectCalls = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("action=connectUser")) {
|
||||
connectCalls += 1;
|
||||
connectSignal = init?.signal as AbortSignal | undefined;
|
||||
markConnectStarted();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => reject(new Error("shared connect aborted"));
|
||||
releaseConnect = (): void => {
|
||||
connectSignal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
if (connectSignal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
connectSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "shared-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
return new Response(JSON.stringify({
|
||||
response_code: "ok",
|
||||
debridLink: "https://mega-cdn.example/survivor.rar",
|
||||
filename: "survivor.rar"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const firstController = new AbortController();
|
||||
const secondController = new AbortController();
|
||||
const first = service.unrestrictLink("https://rapidgator.net/file/first-consumer", firstController.signal);
|
||||
await connectStarted;
|
||||
const second = service.unrestrictLink("https://rapidgator.net/file/second-consumer", secondController.signal);
|
||||
const firstOutcome = first.then(() => "fulfilled", () => "rejected");
|
||||
const secondOutcome = second.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
|
||||
try {
|
||||
firstController.abort("cancel-first-consumer");
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const firstState = await Promise.race([
|
||||
firstOutcome,
|
||||
new Promise<"pending">((resolve) => {
|
||||
timeout = setTimeout(() => resolve("pending"), 100);
|
||||
})
|
||||
]);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
expect(firstState).toBe("rejected");
|
||||
expect(connectSignal?.aborted).toBe(false);
|
||||
expect(secondController.signal.aborted).toBe(false);
|
||||
|
||||
releaseConnect();
|
||||
const survivor = await secondOutcome;
|
||||
expect(survivor.status).toBe("fulfilled");
|
||||
if (survivor.status === "fulfilled") {
|
||||
expect(survivor.value.directUrl).toBe("https://mega-cdn.example/survivor.rar");
|
||||
}
|
||||
expect(connectCalls).toBe(1);
|
||||
} finally {
|
||||
releaseConnect();
|
||||
await Promise.all([firstOutcome, secondOutcome]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => {
|
||||
const oldSettings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "generation-user:old-pass",
|
||||
megaDebridApiCredentials: "generation-user:old-pass",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-api" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const newSettings = {
|
||||
...oldSettings,
|
||||
megaCredentials: "generation-user:new-pass",
|
||||
megaDebridApiCredentials: "generation-user:new-pass"
|
||||
};
|
||||
let releaseOldConnect: () => void = () => {};
|
||||
let markOldConnectStarted: () => void = () => {};
|
||||
const oldConnectStarted = new Promise<void>((resolve) => {
|
||||
markOldConnectStarted = resolve;
|
||||
});
|
||||
const connectPasswords: string[] = [];
|
||||
const getLinkTokens: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("action=connectUser")) {
|
||||
const parsed = new URL(url);
|
||||
const password = parsed.searchParams.get("password") || "";
|
||||
connectPasswords.push(password);
|
||||
if (password === "old-pass") {
|
||||
markOldConnectStarted();
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseOldConnect = resolve;
|
||||
});
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "old-token" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "new-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
const token = new URL(url).searchParams.get("token") || "";
|
||||
getLinkTokens.push(token);
|
||||
return new Response(JSON.stringify({
|
||||
response_code: "ok",
|
||||
debridLink: `https://mega-cdn.example/${token}.rar`,
|
||||
filename: `${token}.rar`
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(oldSettings);
|
||||
const oldRequest = service.unrestrictLink("https://rapidgator.net/file/old-credentials");
|
||||
const oldOutcome = oldRequest.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await oldConnectStarted;
|
||||
|
||||
try {
|
||||
service.setSettings(newSettings);
|
||||
const current = await service.unrestrictLink("https://rapidgator.net/file/new-credentials");
|
||||
expect(current.directUrl).toBe("https://mega-cdn.example/new-token.rar");
|
||||
|
||||
releaseOldConnect();
|
||||
await oldOutcome;
|
||||
|
||||
const subsequent = await service.unrestrictLink("https://rapidgator.net/file/subsequent");
|
||||
expect(subsequent.directUrl).toBe("https://mega-cdn.example/new-token.rar");
|
||||
expect(connectPasswords).toEqual(["old-pass", "new-pass"]);
|
||||
expect(getLinkTokens.at(-1)).toBe("new-token");
|
||||
} finally {
|
||||
releaseOldConnect();
|
||||
await oldOutcome;
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
|
||||
const settings = {
|
||||
@@ -1691,12 +1867,11 @@ describe("debrid service", () => {
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);
|
||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||
expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal);
|
||||
} finally {
|
||||
clearTimeout(abortTimer);
|
||||
}
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);
|
||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
clearTimeout(abortTimer);
|
||||
}
|
||||
});
|
||||
|
||||
it("bleibt klebrig bei einem funktionierenden Account (kein Account-Wechsel pro Link)", async () => {
|
||||
@@ -2091,8 +2266,9 @@ describe("debrid service", () => {
|
||||
expect(acc!.cooldown!.message).toContain("Abbruch");
|
||||
});
|
||||
|
||||
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
||||
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
||||
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
@@ -2110,15 +2286,20 @@ describe("debrid service", () => {
|
||||
};
|
||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
controller.abort("simulated-60s-timeout");
|
||||
return Promise.reject(new Error("aborted"));
|
||||
}
|
||||
return Promise.resolve({
|
||||
let calls = 0;
|
||||
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const onAbort = (): void => reject(new Error("aborted"));
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
fileName: "healthy.rar",
|
||||
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
|
||||
fileSize: null,
|
||||
@@ -2128,7 +2309,7 @@ describe("debrid service", () => {
|
||||
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
|
||||
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
|
||||
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e);
|
||||
expect(err).toBeTruthy();
|
||||
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
||||
|
||||
@@ -2280,7 +2461,7 @@ describe("debrid service", () => {
|
||||
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
|
||||
it("rotates to the next Mega-Web account in the same unrestrict after an account timeout", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -2310,19 +2491,70 @@ describe("debrid service", () => {
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
||||
|
||||
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
|
||||
expect(loginsSeen).toContain("user1");
|
||||
expect(loginsSeen).not.toContain("user2");
|
||||
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
||||
|
||||
// Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served.
|
||||
loginsSeen.length = 0;
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2");
|
||||
expect(loginsSeen).not.toContain("user1");
|
||||
expect(loginsSeen).toContain("user2");
|
||||
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||
}, 20000);
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-1");
|
||||
expect(loginsSeen).toContain("user1");
|
||||
expect(loginsSeen).toContain("user2");
|
||||
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
||||
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||
}, 20000);
|
||||
|
||||
it("gives every Mega-Web account a fresh timeout budget after a queue timeout", async () => {
|
||||
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaLogin: "user1",
|
||||
megaPassword: "pass1",
|
||||
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const loginsSeen: string[] = [];
|
||||
const accountSignals: AbortSignal[] = [];
|
||||
const outerController = new AbortController();
|
||||
const megaWeb = vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => {
|
||||
loginsSeen.push(account?.login || "");
|
||||
if (signal) {
|
||||
accountSignals.push(signal);
|
||||
}
|
||||
if (account?.login === "user1") {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
const onAbort = (): void => reject(new Error("aborted:debrid"));
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new Error("account 2 received an aborted signal");
|
||||
}
|
||||
return {
|
||||
directUrl: "https://mega-web.example/fresh-account.rar",
|
||||
fileName: "fresh-account.rar",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
};
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/queue-timeout-rotation", outerController.signal);
|
||||
|
||||
expect(loginsSeen).toEqual(["user1", "user2"]);
|
||||
expect(accountSignals).toHaveLength(2);
|
||||
expect(accountSignals[0]).not.toBe(accountSignals[1]);
|
||||
expect(accountSignals[0].aborted).toBe(true);
|
||||
expect(accountSignals[1].aborted).toBe(false);
|
||||
expect(outerController.signal.aborted).toBe(false);
|
||||
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||
}, 20000);
|
||||
|
||||
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
|
||||
|
||||
+388
-10
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, getUnrestrictTimeoutMsForProviderPlan, isResumeHardResetReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
@@ -14,13 +14,13 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
||||
import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage";
|
||||
import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||
import { resetVideoToolingCache } from "../src/main/video-processor";
|
||||
import type { HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||
import type { AppSettings, DebridProvider, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -532,6 +532,43 @@ describe("disk write recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resume recovery", () => {
|
||||
it("preserves a partial file for one renewed link and resets after repeated range rejection", () => {
|
||||
expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 0)).toBe(false);
|
||||
expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 1)).toBe(true);
|
||||
expect(isResumeHardResetReason("range_mismatch_on_resume:100/0", 1)).toBe(true);
|
||||
expect(isResumeHardResetReason("resume_download_underflow:100/200", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("budgets a later Mega fallback across every active account without a fifteen minute cap", () => {
|
||||
process.env.RD_UNRESTRICT_TIMEOUT_MS = "60000";
|
||||
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "120000";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-unrestrict-budget-"));
|
||||
tempDirs.push(root);
|
||||
const settings: AppSettings = {
|
||||
...defaultSettings(),
|
||||
bestToken: "best-token",
|
||||
megaDebridWebCredentials: Array.from({ length: 8 }, (_, index) => `user-${index}:pass-${index}`).join("\n"),
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiEnabled: false,
|
||||
providerOrder: ["megadebrid-web", "bestdebrid"],
|
||||
autoProviderFallback: true,
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract")
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.addPackages([{ name: "fallback-budget", links: ["https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] }]);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const item = snapshot.session.items[snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0]];
|
||||
const providerPlan = (manager as unknown as {
|
||||
getReachableUnrestrictProviderPlan: (candidate: typeof item, preferredLeadProvider: DebridProvider | null) => DebridProvider[];
|
||||
}).getReachableUnrestrictProviderPlan(item, "bestdebrid");
|
||||
|
||||
expect(providerPlan).toEqual(["bestdebrid", "megadebrid-web"]);
|
||||
expect(getUnrestrictTimeoutMsForProviderPlan(settings, providerPlan)).toBe(1_035_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download start account gate", () => {
|
||||
it("disables every start path when no usable account is active", async () => {
|
||||
const createManager = () => {
|
||||
@@ -760,6 +797,8 @@ async function removeDirWithRetries(dir: string): Promise<void> {
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete process.env.RD_UNRESTRICT_TIMEOUT_MS;
|
||||
delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS;
|
||||
delete process.env.RD_FFMPEG_BIN;
|
||||
delete process.env.RD_FFPROBE_BIN;
|
||||
resetVideoToolingCache();
|
||||
@@ -861,6 +900,155 @@ describe("download manager", () => {
|
||||
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes an active Mega-Debrid account pool without restarting the application", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-pool-refresh-"));
|
||||
tempDirs.push(root);
|
||||
const firstId = getMegaDebridAccountId("first-user");
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||
megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||
megaDebridWebEnabled: true
|
||||
};
|
||||
const invalidateMegaSession = vi.fn();
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
||||
manager.addPackages([{ name: "pool-refresh", links: ["https://rapidgator.net/file/pool-refresh"] }]);
|
||||
const session = (manager as any).session;
|
||||
const item = Object.values(session.items)[0] as any;
|
||||
item.provider = "megadebrid-web";
|
||||
item.status = "validating";
|
||||
session.running = true;
|
||||
const active = {
|
||||
itemId: item.id,
|
||||
packageId: item.packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false
|
||||
};
|
||||
(manager as any).activeTasks.set(item.id, active);
|
||||
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
||||
primeMegaDebridRuntimeCooldownForTests(`${firstId}:web`, 120_000);
|
||||
failures.set("megadebrid-web:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
failures.set("megadebrid-api:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
|
||||
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstId] });
|
||||
|
||||
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||
expect(active.abortController.signal.aborted).toBe(true);
|
||||
expect(active.abortReason).toBe("settings_refresh");
|
||||
expect(getMegaDebridAccountCooldownState(`${firstId}:web`)).toBeNull();
|
||||
expect(failures.has("megadebrid-web:rapidgator.net")).toBe(false);
|
||||
expect(failures.has("megadebrid-api:rapidgator.net")).toBe(false);
|
||||
expect(failures.has("realdebrid:rapidgator.net")).toBe(true);
|
||||
});
|
||||
|
||||
it("continues a running Mega-Web download with the next enabled account after a live settings change", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-account-switch-"));
|
||||
tempDirs.push(root);
|
||||
const payload = Buffer.alloc(256 * 1024, 0x5a);
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, {
|
||||
"Content-Length": String(payload.length),
|
||||
"Content-Type": "application/octet-stream"
|
||||
});
|
||||
res.end(payload);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Testserver konnte nicht gestartet werden");
|
||||
}
|
||||
const directUrl = `http://127.0.0.1:${address.port}/account-two.bin`;
|
||||
const firstAccountId = getMegaDebridAccountId("first-user");
|
||||
const accountCalls: string[] = [];
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||
megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [],
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
autoReconnect: false,
|
||||
enableIntegrityCheck: false,
|
||||
maxParallel: 1
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), {
|
||||
invalidateMegaSession: vi.fn(),
|
||||
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => {
|
||||
const login = account?.login || "";
|
||||
accountCalls.push(login);
|
||||
if (login === "first-user") {
|
||||
return await new Promise<UnrestrictedLink | null>((_resolve, reject) => {
|
||||
const onAbort = (): void => reject(new Error("aborted:settings-refresh"));
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
return {
|
||||
fileName: "account-two.bin",
|
||||
directUrl,
|
||||
fileSize: payload.length,
|
||||
retriesUsed: 0
|
||||
};
|
||||
})
|
||||
});
|
||||
manager.addPackages([{ name: "live-account-switch", links: ["https://rapidgator.net/file/live-account-switch"] }]);
|
||||
|
||||
try {
|
||||
await manager.start();
|
||||
await waitFor(() => accountCalls.includes("first-user"), 10_000);
|
||||
|
||||
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstAccountId] });
|
||||
|
||||
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.status === "completed"), 15_000);
|
||||
expect(accountCalls).toEqual(["first-user", "second-user"]);
|
||||
expect(fs.readFileSync(path.join(root, "downloads", "live-account-switch", "account-two.bin"))).toEqual(payload);
|
||||
} finally {
|
||||
manager.stop();
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("aborts active work immediately when downloads are paused", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pause-active-"));
|
||||
tempDirs.push(root);
|
||||
const invalidateMegaSession = vi.fn();
|
||||
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
||||
manager.addPackages([{ name: "pause-active", links: ["https://rapidgator.net/file/pause-active"] }]);
|
||||
const session = (manager as any).session;
|
||||
const item = Object.values(session.items)[0] as any;
|
||||
item.status = "validating";
|
||||
session.running = true;
|
||||
const active = {
|
||||
itemId: item.id,
|
||||
packageId: item.packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false
|
||||
};
|
||||
(manager as any).activeTasks.set(item.id, active);
|
||||
|
||||
expect(manager.togglePause()).toBe(true);
|
||||
|
||||
expect(active.abortController.signal.aborted).toBe(true);
|
||||
expect(active.abortReason).toBe("pause");
|
||||
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases only Mega-Debrid reset parks when a newly usable account appears", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-"));
|
||||
tempDirs.push(root);
|
||||
@@ -2435,7 +2623,7 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => {
|
||||
it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(256 * 1024, 21);
|
||||
@@ -2581,10 +2769,138 @@ describe("download manager", () => {
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("restores a persisted Mega-Web partial and hard-resets after two HTTP 200 range rejections", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-resume-restore-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(192 * 1024, 37);
|
||||
const partialSize = 64 * 1024;
|
||||
const packageId = "mega-resume-restore-package";
|
||||
const itemId = "mega-resume-restore-item";
|
||||
const outputDir = path.join(root, "downloads", "mega-resume-restore");
|
||||
const targetPath = path.join(outputDir, "mega-resume-restore.mkv");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(targetPath, binary.subarray(0, partialSize));
|
||||
const rangeHeaders: string[] = [];
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET") {
|
||||
rangeHeaders.push(String(req.headers.range || ""));
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", String(binary.length));
|
||||
res.end(binary);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("server address unavailable");
|
||||
}
|
||||
const directUrl = `http://127.0.0.1:${address.port}/mega-resume-restore`;
|
||||
|
||||
try {
|
||||
const session = emptySession();
|
||||
const createdAt = Date.now() - 10_000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "mega-resume-restore",
|
||||
outputDir,
|
||||
extractDir: path.join(root, "extract", "mega-resume-restore"),
|
||||
status: "queued",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/mega-resume-restore",
|
||||
provider: "megadebrid-web",
|
||||
status: "queued",
|
||||
retries: 1,
|
||||
speedBps: 0,
|
||||
downloadedBytes: partialSize,
|
||||
totalBytes: binary.length,
|
||||
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
||||
fileName: "mega-resume-restore.mkv",
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: `range_ignored_on_resume:${partialSize}/${binary.length}`,
|
||||
fullStatus: "Resume-Link erneuern, Retry 1/3",
|
||||
onlineStatus: "online",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||
saveSession(storagePaths, session);
|
||||
const megaWebUnrestrict = vi.fn(async (): Promise<UnrestrictedLink> => ({
|
||||
directUrl,
|
||||
fileName: "mega-resume-restore.mkv",
|
||||
fileSize: binary.length,
|
||||
retriesUsed: 0
|
||||
}));
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
megaDebridWebCredentials: "mega-user:mega-pass",
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: ["megadebrid-web"],
|
||||
autoProviderFallback: false,
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
retryLimit: 3,
|
||||
autoExtract: false,
|
||||
autoReconnect: false,
|
||||
enableIntegrityCheck: false,
|
||||
maxParallel: 1
|
||||
},
|
||||
loadSession(storagePaths),
|
||||
storagePaths,
|
||||
{ megaWebUnrestrict }
|
||||
);
|
||||
|
||||
expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({
|
||||
status: "queued",
|
||||
downloadedBytes: partialSize,
|
||||
targetPath,
|
||||
onlineStatus: "online"
|
||||
}));
|
||||
|
||||
await manager.start();
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||
|
||||
const restoredItem = manager.getSnapshot().session.items[itemId];
|
||||
expect(restoredItem).toEqual(expect.objectContaining({
|
||||
status: "completed",
|
||||
downloadedBytes: binary.length,
|
||||
onlineStatus: "online"
|
||||
}));
|
||||
expect(megaWebUnrestrict).toHaveBeenCalledTimes(3);
|
||||
expect(rangeHeaders).toEqual([
|
||||
`bytes=${partialSize}-`,
|
||||
`bytes=${partialSize}-`,
|
||||
""
|
||||
]);
|
||||
expect(fs.readFileSync(targetPath)).toEqual(binary);
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const actual = Buffer.alloc(192 * 1024, 17);
|
||||
@@ -8323,11 +8639,73 @@ describe("download manager", () => {
|
||||
progressPercent: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
onlineStatus: undefined
|
||||
onlineStatus: "online"
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a known online status when resetting a whole package", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reset-package-online-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "reset-package-online";
|
||||
const itemId = "reset-package-online-item";
|
||||
const createdAt = Date.now();
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "reset-package-online",
|
||||
outputDir: path.join(root, "downloads", "reset-package-online"),
|
||||
extractDir: path.join(root, "extract", "reset-package-online"),
|
||||
status: "failed",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/reset-package-online",
|
||||
provider: "megadebrid-web",
|
||||
status: "failed",
|
||||
retries: 3,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 512,
|
||||
totalBytes: 1_024,
|
||||
progressPercent: 50,
|
||||
fileName: "reset-package-online.bin",
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 3,
|
||||
lastError: "range_ignored_on_resume:512/1024",
|
||||
fullStatus: "Fehler",
|
||||
onlineStatus: "online",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
await manager.resetPackage(packageId);
|
||||
|
||||
expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({
|
||||
status: "queued",
|
||||
downloadedBytes: 0,
|
||||
fullStatus: "Wartet",
|
||||
onlineStatus: "online"
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -708,7 +708,7 @@ describe("downloads view", () => {
|
||||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
||||
});
|
||||
|
||||
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
|
||||
it("blocks resume without a usable account and keeps lifecycle controls independent from unrelated action busy state", () => {
|
||||
const pausedToolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
|
||||
@@ -721,6 +721,7 @@ describe("downloads view", () => {
|
||||
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
|
||||
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
|
||||
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
|
||||
expect(findButton(busyToolbar, "Stop").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("enables package movement only for a visible selected package row", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("mega-web-fallback", () => {
|
||||
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>;
|
||||
sessions: Map<string, { cookie: string; setAt: number }>;
|
||||
};
|
||||
vi.spyOn(internals, "login")
|
||||
const login = vi.spyOn(internals, "login")
|
||||
.mockResolvedValueOnce("first-stale-cookie")
|
||||
.mockResolvedValueOnce("second-stale-cookie");
|
||||
vi.spyOn(internals, "generate")
|
||||
@@ -55,12 +55,58 @@ describe("mega-web-fallback", () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ directUrl: "https://mega.direct/retry", fileName: "retry.bin" });
|
||||
|
||||
const result = await fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" });
|
||||
await expect(fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" }))
|
||||
.rejects.toThrow(/aborted/i);
|
||||
|
||||
expect(result?.directUrl).toBe("https://mega.direct/retry");
|
||||
expect(login).toHaveBeenCalledTimes(1);
|
||||
expect(internals.sessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a fresh session when an invalidated older request finishes later", async () => {
|
||||
let releaseOldGenerate: () => void = () => {};
|
||||
let markOldGenerateStarted: () => void = () => {};
|
||||
const oldGenerateGate = new Promise<void>((resolve) => {
|
||||
releaseOldGenerate = resolve;
|
||||
});
|
||||
const oldGenerateStarted = new Promise<void>((resolve) => {
|
||||
markOldGenerateStarted = resolve;
|
||||
});
|
||||
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||
const internals = fallback as unknown as {
|
||||
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string } | null>;
|
||||
sessions: Map<string, { cookie: string; setAt: number }>;
|
||||
};
|
||||
const login = vi.spyOn(internals, "login")
|
||||
.mockResolvedValueOnce("old-cookie")
|
||||
.mockResolvedValueOnce("fresh-cookie");
|
||||
vi.spyOn(internals, "generate").mockImplementation(async (link) => {
|
||||
if (link.includes("old")) {
|
||||
markOldGenerateStarted();
|
||||
await oldGenerateGate;
|
||||
return null;
|
||||
}
|
||||
return { directUrl: "https://mega.direct/fresh", fileName: "fresh.bin" };
|
||||
});
|
||||
|
||||
const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" });
|
||||
const oldSettled = oldRequest.catch((error: unknown) => error);
|
||||
await oldGenerateStarted;
|
||||
fallback.invalidateSession();
|
||||
const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" });
|
||||
|
||||
try {
|
||||
await expect(freshRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/fresh" });
|
||||
expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie");
|
||||
} finally {
|
||||
releaseOldGenerate();
|
||||
}
|
||||
|
||||
await oldSettled;
|
||||
expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie");
|
||||
expect(login).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache old credentials from a queued request after invalidation", async () => {
|
||||
let releaseFirstLogin: () => void = () => {};
|
||||
let markFirstLoginStarted: () => void = () => {};
|
||||
@@ -88,16 +134,162 @@ describe("mega-web-fallback", () => {
|
||||
vi.spyOn(internals, "generate").mockResolvedValue({ directUrl: "https://mega.direct/queued", fileName: "queued.bin" });
|
||||
|
||||
const first = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "old-user", password: "old-pass" });
|
||||
const firstSettled = first.catch((error: unknown) => error);
|
||||
await firstLoginStarted;
|
||||
const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" });
|
||||
const queuedSettled = queued.catch((error: unknown) => error);
|
||||
fallback.invalidateSession();
|
||||
releaseFirstLogin();
|
||||
|
||||
await expect(Promise.all([first, queued])).resolves.toHaveLength(2);
|
||||
expect(loginCount).toBe(2);
|
||||
await expect(firstSettled).resolves.toBeInstanceOf(Error);
|
||||
await expect(queuedSettled).resolves.toBeInstanceOf(Error);
|
||||
expect(loginCount).toBe(1);
|
||||
expect(internals.sessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not let a blocked old account job block a new request after session invalidation", async () => {
|
||||
let releaseOldLogin: () => void = () => {};
|
||||
let markOldLoginStarted: () => void = () => {};
|
||||
const oldLoginGate = new Promise<void>((resolve) => {
|
||||
releaseOldLogin = resolve;
|
||||
});
|
||||
const oldLoginStarted = new Promise<void>((resolve) => {
|
||||
markOldLoginStarted = resolve;
|
||||
});
|
||||
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||
const internals = fallback as unknown as {
|
||||
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
|
||||
};
|
||||
const login = vi.spyOn(internals, "login")
|
||||
.mockImplementationOnce(async () => {
|
||||
markOldLoginStarted();
|
||||
await oldLoginGate;
|
||||
return "old-cookie";
|
||||
})
|
||||
.mockResolvedValueOnce("new-cookie");
|
||||
vi.spyOn(internals, "generate").mockImplementation(async (link) => ({
|
||||
directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old",
|
||||
fileName: link.includes("fresh") ? "fresh.bin" : "old.bin"
|
||||
}));
|
||||
|
||||
const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" });
|
||||
const oldSettled = oldRequest.catch((error: unknown) => error);
|
||||
await oldLoginStarted;
|
||||
fallback.invalidateSession();
|
||||
const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" });
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 });
|
||||
await expect(freshRequest).resolves.toMatchObject({
|
||||
directUrl: "https://mega.direct/fresh",
|
||||
fileName: "fresh.bin"
|
||||
});
|
||||
} finally {
|
||||
releaseOldLogin();
|
||||
await Promise.allSettled([oldSettled, freshRequest]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not execute provider work for a queued request aborted before it starts", async () => {
|
||||
let releaseFirstLogin: () => void = () => {};
|
||||
let markFirstLoginStarted: () => void = () => {};
|
||||
const firstLoginGate = new Promise<void>((resolve) => {
|
||||
releaseFirstLogin = resolve;
|
||||
});
|
||||
const firstLoginStarted = new Promise<void>((resolve) => {
|
||||
markFirstLoginStarted = resolve;
|
||||
});
|
||||
const generatedLinks: string[] = [];
|
||||
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||
const internals = fallback as unknown as {
|
||||
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
|
||||
queues: Map<string, Promise<unknown>>;
|
||||
};
|
||||
const login = vi.spyOn(internals, "login").mockImplementation(async () => {
|
||||
markFirstLoginStarted();
|
||||
await firstLoginGate;
|
||||
return "first-cookie";
|
||||
});
|
||||
vi.spyOn(internals, "generate").mockImplementation(async (link) => {
|
||||
generatedLinks.push(link);
|
||||
return { directUrl: "https://mega.direct/first", fileName: "first.bin" };
|
||||
});
|
||||
|
||||
const firstRequest = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "same-user", password: "same-pass" });
|
||||
await firstLoginStarted;
|
||||
const queuedController = new AbortController();
|
||||
const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" });
|
||||
const queueTail = internals.queues.get("same-user");
|
||||
queuedController.abort("cancel-before-start");
|
||||
|
||||
await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i);
|
||||
releaseFirstLogin();
|
||||
await expect(firstRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/first" });
|
||||
await queueTail;
|
||||
|
||||
expect(login).toHaveBeenCalledTimes(1);
|
||||
expect(generatedLinks).toEqual(["https://mega.debrid/first"]);
|
||||
});
|
||||
|
||||
it("starts a new request normally after invalidating a queue with an aborted waiter", async () => {
|
||||
let releaseOldLogin: () => void = () => {};
|
||||
let markOldLoginStarted: () => void = () => {};
|
||||
const oldLoginGate = new Promise<void>((resolve) => {
|
||||
releaseOldLogin = resolve;
|
||||
});
|
||||
const oldLoginStarted = new Promise<void>((resolve) => {
|
||||
markOldLoginStarted = resolve;
|
||||
});
|
||||
const generatedLinks: string[] = [];
|
||||
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||
const internals = fallback as unknown as {
|
||||
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
|
||||
queues: Map<string, Promise<unknown>>;
|
||||
};
|
||||
const login = vi.spyOn(internals, "login")
|
||||
.mockImplementationOnce(async () => {
|
||||
markOldLoginStarted();
|
||||
await oldLoginGate;
|
||||
return "old-cookie";
|
||||
})
|
||||
.mockResolvedValueOnce("fresh-cookie");
|
||||
vi.spyOn(internals, "generate").mockImplementation(async (link) => {
|
||||
generatedLinks.push(link);
|
||||
return {
|
||||
directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old",
|
||||
fileName: link.includes("fresh") ? "fresh.bin" : "old.bin"
|
||||
};
|
||||
});
|
||||
|
||||
const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" });
|
||||
const oldSettled = oldRequest.catch((error: unknown) => error);
|
||||
await oldLoginStarted;
|
||||
const queuedController = new AbortController();
|
||||
const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" });
|
||||
const staleQueueTail = internals.queues.get("same-user");
|
||||
queuedController.abort("cancel-before-start");
|
||||
await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i);
|
||||
|
||||
fallback.invalidateSession();
|
||||
const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" });
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 });
|
||||
await expect(freshRequest).resolves.toMatchObject({
|
||||
directUrl: "https://mega.direct/fresh",
|
||||
fileName: "fresh.bin"
|
||||
});
|
||||
} finally {
|
||||
releaseOldLogin();
|
||||
await Promise.allSettled([oldSettled, freshRequest, staleQueueTail]);
|
||||
}
|
||||
|
||||
expect(generatedLinks).toEqual(["https://mega.debrid/fresh"]);
|
||||
});
|
||||
|
||||
it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
|
||||
let fetchCallCount = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
|
||||
@@ -482,7 +482,7 @@ describe("bandwidth chart palette", () => {
|
||||
expect(chartBlock).toContain('role="img"');
|
||||
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
|
||||
expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")');
|
||||
expect(chartBlock).toContain("reducedMotion ? 1000 : 250");
|
||||
expect(chartBlock).toContain("reducedMotion ? 1000 : 500");
|
||||
});
|
||||
|
||||
it("asks for confirmation before deleting all saved download statistics", () => {
|
||||
|
||||
+416
-11
@@ -2,20 +2,26 @@ 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 } from "vitest";
|
||||
import { buildSupportBundle } from "../src/main/support-bundle";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSupportBundle,
|
||||
createSupportBundleExportRunner,
|
||||
writeSupportBundleAtomically
|
||||
} from "../src/main/support-bundle";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
afterEach(() => {
|
||||
shutdownSessionLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
function fakeManager(): DownloadManager {
|
||||
function fakeManager(): DownloadManager {
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
session: { packages: {}, items: {}, packageOrder: [] },
|
||||
@@ -30,7 +36,100 @@ function fakeManager(): DownloadManager {
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
} as unknown as DownloadManager;
|
||||
}
|
||||
}
|
||||
|
||||
function populatedFakeManager(): DownloadManager {
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
session: {
|
||||
packages: {
|
||||
"package-1": { id: "package-1", name: "Paket", itemIds: ["item-1"] }
|
||||
},
|
||||
items: {
|
||||
"item-1": { id: "item-1", packageId: "package-1", fileName: "datei.bin" }
|
||||
},
|
||||
packageOrder: ["package-1"]
|
||||
},
|
||||
speedText: "",
|
||||
etaText: "",
|
||||
canStart: false,
|
||||
canStop: false,
|
||||
canPause: false
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: () => { throw new Error("package log getter must not run"); },
|
||||
getItemLogPath: () => { throw new Error("item log getter must not run"); }
|
||||
} as unknown as DownloadManager;
|
||||
}
|
||||
|
||||
function sensitiveActiveManager(itemCount = 1): DownloadManager {
|
||||
const packages = {
|
||||
"package-sensitive": {
|
||||
id: "package-sensitive",
|
||||
name: "Private Collection",
|
||||
outputDir: "C:\\Users\\Alice\\Downloads\\Private Collection",
|
||||
extractDir: "C:\\Users\\Alice\\Extracted\\Private Collection",
|
||||
status: "downloading",
|
||||
itemIds: Array.from({ length: itemCount }, (_, index) => `item-${index}`),
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
cleanedUrls: ["https://files.example.test/archive?api_key=cleaned-secret#private"],
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
};
|
||||
const items = Object.fromEntries(Array.from({ length: itemCount }, (_, index) => [
|
||||
`item-${index}`,
|
||||
{
|
||||
id: `item-${index}`,
|
||||
packageId: "package-sensitive",
|
||||
url: `https://url-user:url-pass@files.example.test/archive-${index}?token=query-secret-${index}#fragment-secret`,
|
||||
provider: "realdebrid",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 1024,
|
||||
downloadedBytes: index,
|
||||
totalBytes: 2048,
|
||||
progressPercent: 50,
|
||||
fileName: `private-${index}.bin`,
|
||||
targetPath: `C:\\Users\\Alice\\Downloads\\Private Collection\\private-${index}.bin`,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "Authorization: Bearer item-bearer-secret",
|
||||
fullStatus: "Cookie: session=item-cookie-secret",
|
||||
createdAt: 1,
|
||||
updatedAt: index + 2
|
||||
}
|
||||
]));
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
session: {
|
||||
version: 1,
|
||||
packageOrder: ["package-sensitive"],
|
||||
packages,
|
||||
items,
|
||||
runStartedAt: 1,
|
||||
totalDownloadedBytes: 10,
|
||||
summaryText: "password=summary-secret",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "api_key=reconnect-secret",
|
||||
paused: false,
|
||||
running: true,
|
||||
updatedAt: 2
|
||||
},
|
||||
speedText: "Geschwindigkeit: 1 KB/s",
|
||||
etaText: "ETA: 1m",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: () => { throw new Error("package log getter must not run"); },
|
||||
getItemLogPath: () => { throw new Error("item log getter must not run"); }
|
||||
} as unknown as DownloadManager;
|
||||
}
|
||||
|
||||
describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
|
||||
@@ -60,7 +159,7 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
|
||||
});
|
||||
|
||||
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
|
||||
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-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -69,6 +168,312 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
|
||||
clearTimeout(timer);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(timerFired).toBe(true);
|
||||
});
|
||||
});
|
||||
expect(timerFired).toBe(true);
|
||||
});
|
||||
|
||||
it("includes only recent directory logs without live duplicates or log getter side effects", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
const recentLog = path.join(itemLogs, "recent.txt");
|
||||
const oldLog = path.join(itemLogs, "old.txt");
|
||||
fs.writeFileSync(recentLog, "recent", "utf8");
|
||||
fs.writeFileSync(oldLog, "old", "utf8");
|
||||
const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000);
|
||||
fs.utimesSync(oldLog, oldTimestamp, oldTimestamp);
|
||||
|
||||
const buffer = await buildSupportBundle(populatedFakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
|
||||
|
||||
expect(entries).toContain("logs/item-logs/recent.txt");
|
||||
expect(entries).not.toContain("logs/item-logs/old.txt");
|
||||
expect(entries.some((entry) => entry.startsWith("logs/live/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("includes each physical session log only once", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
initSessionLog(root);
|
||||
const sessionLogPath = getSessionLogPath();
|
||||
expect(sessionLogPath).not.toBeNull();
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
|
||||
const sessionEntries = entries.filter((entry) => (
|
||||
entry === "logs/session.log" || entry.startsWith("logs/session-logs/")
|
||||
));
|
||||
|
||||
expect(sessionEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bounds recent item logs to the newest diagnostic files", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
const baseTime = Date.now() - 60_000;
|
||||
for (let index = 0; index < 365; index += 1) {
|
||||
const filePath = path.join(itemLogs, `item-${String(index).padStart(3, "0")}.txt`);
|
||||
fs.writeFileSync(filePath, String(index), "utf8");
|
||||
const timestamp = new Date(baseTime + index);
|
||||
fs.utimesSync(filePath, timestamp, timestamp);
|
||||
}
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
|
||||
const itemEntries = entries.filter((entry) => entry.startsWith("logs/item-logs/"));
|
||||
|
||||
expect(itemEntries).toHaveLength(16);
|
||||
expect(itemEntries).not.toContain("logs/item-logs/item-000.txt");
|
||||
expect(itemEntries).toContain("logs/item-logs/item-364.txt");
|
||||
});
|
||||
|
||||
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);
|
||||
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
||||
megaDebridWebCredentials: "primary-user:primary-password-secret\nsecondary-user:secondary-password-secret",
|
||||
megaDebridWebEnabled: true
|
||||
}), "utf8");
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
fs.writeFileSync(path.join(itemLogs, "token=filename-secret.log"), [
|
||||
"Authorization: Bearer log-bearer-secret",
|
||||
"Cookie: session=log-cookie-secret; auth=second-cookie-secret",
|
||||
"username=account-secret",
|
||||
"password=log-password-secret",
|
||||
"api_key=log-api-key-secret",
|
||||
"provider rejected secondary-password-secret during rotation",
|
||||
"https://log-user:log-pass@files.example.test/archive?token=log-query-secret#log-fragment-secret",
|
||||
"C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin"
|
||||
].join("\n"), "utf8");
|
||||
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({
|
||||
authorization: "Bearer manifest-bearer-secret",
|
||||
supportManifestPath: "C:\\Users\\Alice\\AppData\\debug_support_manifest.json",
|
||||
endpoint: "https://support.example.test/check?api_key=manifest-query-secret#manifest-fragment"
|
||||
}), "utf8");
|
||||
|
||||
const buffer = await buildSupportBundle(sensitiveActiveManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const zip = new AdmZip(buffer);
|
||||
const archiveText = zip.getEntries()
|
||||
.map((entry) => `${entry.entryName}\n${entry.getData().toString("utf8")}`)
|
||||
.join("\n");
|
||||
const forbidden = [
|
||||
root,
|
||||
"C:\\Users\\Alice",
|
||||
"url-user",
|
||||
"url-pass",
|
||||
"query-secret-0",
|
||||
"fragment-secret",
|
||||
"item-bearer-secret",
|
||||
"item-cookie-secret",
|
||||
"summary-secret",
|
||||
"reconnect-secret",
|
||||
"cleaned-secret",
|
||||
"filename-secret",
|
||||
"log-bearer-secret",
|
||||
"log-cookie-secret",
|
||||
"second-cookie-secret",
|
||||
"account-secret",
|
||||
"log-password-secret",
|
||||
"log-api-key-secret",
|
||||
"primary-password-secret",
|
||||
"secondary-password-secret",
|
||||
"log-query-secret",
|
||||
"log-fragment-secret",
|
||||
"manifest-bearer-secret",
|
||||
"manifest-query-secret",
|
||||
"manifest-fragment"
|
||||
];
|
||||
|
||||
for (const secret of forbidden) {
|
||||
expect(archiveText).not.toContain(secret);
|
||||
}
|
||||
expect(archiveText).toContain("<redacted>");
|
||||
expect(archiveText).toContain("<local-path>");
|
||||
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
|
||||
items?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(itemOverview.items?.[0]).toMatchObject({ sourceHost: "files.example.test" });
|
||||
expect(itemOverview.items?.[0]).not.toHaveProperty("url");
|
||||
});
|
||||
|
||||
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);
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
const payload = `${"x".repeat(512 * 1024)}\napi_key=tail-secret`;
|
||||
for (let index = 0; index < 48; index += 1) {
|
||||
fs.writeFileSync(path.join(itemLogs, `active-${String(index).padStart(2, "0")}.log`), payload, "utf8");
|
||||
}
|
||||
const oldRotatedLog = path.join(itemLogs, "rotated.log.old");
|
||||
fs.writeFileSync(oldRotatedLog, "api_key=old-secret", "utf8");
|
||||
const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000);
|
||||
fs.utimesSync(oldRotatedLog, oldTimestamp, oldTimestamp);
|
||||
|
||||
const timerGaps: number[] = [];
|
||||
let lastTick = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
timerGaps.push(now - lastTick);
|
||||
lastTick = now;
|
||||
}, 5);
|
||||
const buffer = await buildSupportBundle(sensitiveActiveManager(1_800), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
clearInterval(timer);
|
||||
|
||||
const zip = new AdmZip(buffer);
|
||||
const logEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
|
||||
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
|
||||
count?: number;
|
||||
included?: number;
|
||||
omitted?: number;
|
||||
items?: unknown[];
|
||||
};
|
||||
const totalUncompressedBytes = zip.getEntries().reduce((sum, entry) => sum + entry.getData().length, 0);
|
||||
|
||||
expect(logEntries.length).toBeLessThanOrEqual(16);
|
||||
expect(Math.max(...logEntries.map((entry) => entry.getData().length))).toBeLessThanOrEqual(256 * 1024);
|
||||
expect(logEntries.some((entry) => entry.entryName.includes("rotated"))).toBe(false);
|
||||
expect(itemOverview.count).toBe(1_800);
|
||||
expect(itemOverview.items?.length).toBeLessThanOrEqual(500);
|
||||
expect(itemOverview.omitted).toBeGreaterThan(0);
|
||||
expect(totalUncompressedBytes).toBeLessThan(8 * 1024 * 1024);
|
||||
expect(timerGaps.length).toBeGreaterThan(2);
|
||||
expect(Math.max(...timerGaps)).toBeLessThan(100);
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe("support bundle export runner", () => {
|
||||
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);
|
||||
const target = path.join(root, "support.zip");
|
||||
let chooseCount = 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 run = createSupportBundleExportRunner({
|
||||
chooseFile: async () => {
|
||||
chooseCount += 1;
|
||||
return target;
|
||||
},
|
||||
build: async () => {
|
||||
signalBuildStarted();
|
||||
return buildPending;
|
||||
},
|
||||
write: async () => undefined
|
||||
});
|
||||
|
||||
const first = run();
|
||||
await buildStarted;
|
||||
const second = await run();
|
||||
|
||||
expect(second).toEqual({
|
||||
saved: false,
|
||||
busy: true,
|
||||
message: "Support-Bundle wird bereits erstellt."
|
||||
});
|
||||
expect(chooseCount).toBe(1);
|
||||
|
||||
releaseBuild(Buffer.from("zip"));
|
||||
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||
});
|
||||
|
||||
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);
|
||||
const target = path.join(root, "support.zip");
|
||||
let releaseWrite: () => void = () => undefined;
|
||||
let signalWriteStarted: () => void = () => undefined;
|
||||
let successCount = 0;
|
||||
const writeStarted = new Promise<void>((resolve) => { signalWriteStarted = resolve; });
|
||||
const writePending = new Promise<void>((resolve) => { releaseWrite = resolve; });
|
||||
const run = createSupportBundleExportRunner({
|
||||
chooseFile: async () => target,
|
||||
build: async () => Buffer.from("zip"),
|
||||
write: async () => {
|
||||
signalWriteStarted();
|
||||
await writePending;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
successCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const result = run();
|
||||
await writeStarted;
|
||||
expect(successCount).toBe(0);
|
||||
|
||||
releaseWrite();
|
||||
await expect(result).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||
expect(successCount).toBe(1);
|
||||
});
|
||||
|
||||
it("releases the busy guard after an export failure", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||
tempDirs.push(root);
|
||||
const target = path.join(root, "support.zip");
|
||||
let writeCount = 0;
|
||||
const run = createSupportBundleExportRunner({
|
||||
chooseFile: async () => target,
|
||||
build: async () => Buffer.from("zip"),
|
||||
write: async () => {
|
||||
writeCount += 1;
|
||||
if (writeCount === 1) {
|
||||
throw new Error("write failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await expect(run()).rejects.toThrow("write failed");
|
||||
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||
});
|
||||
|
||||
it("releases the busy guard after the target dialog is canceled", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||
tempDirs.push(root);
|
||||
const target = path.join(root, "support.zip");
|
||||
let chooseCount = 0;
|
||||
const run = createSupportBundleExportRunner({
|
||||
chooseFile: async () => {
|
||||
chooseCount += 1;
|
||||
return chooseCount === 1 ? null : target;
|
||||
},
|
||||
build: async () => Buffer.from("zip"),
|
||||
write: async () => undefined
|
||||
});
|
||||
|
||||
await expect(run()).resolves.toEqual({ saved: false, busy: false });
|
||||
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||
});
|
||||
|
||||
it("replaces the target atomically without leaving temporary files", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||
tempDirs.push(root);
|
||||
const target = path.join(root, "support.zip");
|
||||
fs.writeFileSync(target, "old", "utf8");
|
||||
|
||||
await writeSupportBundleAtomically(target, Buffer.from("new"));
|
||||
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("new");
|
||||
expect(fs.readdirSync(root)).toEqual(["support.zip"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,7 @@ export function createVisualElectronApi(
|
||||
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
|
||||
return fixture.snapshot.clipboardActive;
|
||||
},
|
||||
writeClipboardText: async () => true,
|
||||
pickFolder: async () => "C:\\Visual\\Selected",
|
||||
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
|
||||
getSessionStats: async () => ({
|
||||
|
||||
Reference in New Issue
Block a user