Initial public release v1.7.233

This commit is contained in:
Sucukdeluxe
2026-08-01 22:29:53 +02:00
commit dc367633f5
152 changed files with 85159 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
}
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
}
function mockFetchOnce(status: number, body: unknown): void {
const text = typeof body === "string" ? body : JSON.stringify(body);
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => text
})) as unknown as typeof fetch);
}
const NOW = 1_700_000_000_000;
afterEach(() => {
vi.unstubAllGlobals();
});
describe("checkMegaDebridAccount", () => {
it("reports valid + premium from vip_end (future Unix ts)", async () => {
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(futureSec * 1000);
expect(st.email).toBe("a@b.de");
expect(st.message).toMatch(/Premium noch/);
});
it("reports valid but NOT premium when vip_end is in the past", async () => {
const pastSec = Math.floor(NOW / 1000) - 1000;
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
});
it("reports valid but no premium when vip_end is 0/missing", async () => {
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.premiumUntilMs).toBe(0);
expect(st.message).toMatch(/Kein Premium/);
});
it("reports invalid login when response_code != ok", async () => {
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Ungueltiger Login/);
});
it("reports invalid on HTTP error", async () => {
mockFetchOnce(500, "server error");
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
});
it("never throws on network error — returns a failed status", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
});
});
describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60;
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
});
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Free/);
});
it("reports invalid key on HTTP 401", async () => {
mockFetchOnce(401, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Ungueltiger API-Key/);
});
it("reports invalid key when success=false", async () => {
mockFetchOnce(200, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
});
});
describe("checkAllDebridAccounts", () => {
it("returns empty array when nothing configured", async () => {
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toEqual([]);
});
it("checks every configured mega account + debrid-link key", async () => {
const futureSec = Math.floor(Date.now() / 1000) + 1000;
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
if (String(url).includes("mega-debrid")) {
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
}
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const settings = {
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
megaPassword: "",
debridLinkApiKeys: "key1\nkey2\nkey3"
} as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(5);
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
expect(result.every((r) => r.valid)).toBe(true);
});
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
let inFlight = 0;
let maxInFlight = 0;
vi.stubGlobal("fetch", vi.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(9);
expect(maxInFlight).toBeLessThanOrEqual(4);
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
import { defaultSettings } from "../src/main/constants";
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
return {
mode: "edit",
kind,
token: "",
login: "",
password: "",
dailyLimitGb: "",
keyDailyLimitGbById: {},
megaAccounts: [{ login: "user@x", password: "pw" }],
megaNewLogin: "",
megaNewPassword: "",
megaDisabledIds: []
};
}
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
expect(next.megaDebridApiEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(false);
});
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(true);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
import type { RotationEvent } from "../src/shared/types";
describe("rotation item-sink (AsyncLocalStorage)", () => {
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
const captured: RotationEvent[] = [];
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
await Promise.resolve();
});
const events = captured.map((e) => e.event);
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
const failed = captured.find((e) => e.event === "FAILED");
expect(failed?.reason).toBe("Timeout");
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
});
it("does not leak events to the sink outside the run() scope", () => {
const captured: RotationEvent[] = [];
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
expect(captured).toHaveLength(0);
});
it("isolates two parallel item sinks (no cross-attribution)", async () => {
const a: RotationEvent[] = [];
const b: RotationEvent[] = [];
await Promise.all([
runWithRotationItemSink((ev) => a.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
await new Promise((r) => setTimeout(r, 10));
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
}),
runWithRotationItemSink((ev) => b.push(ev), async () => {
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
await new Promise((r) => setTimeout(r, 5));
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
})
]);
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
});
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
const ring = getRecentRotationEvents(10);
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/package-order";
describe("reorderPackageOrderByDrop", () => {
it("moves adjacent package down by one on drop", () => {
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
expect(next).toEqual(["a", "c", "b"]);
});
it("moves package after lower drop target", () => {
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
expect(next).toEqual(["b", "c", "a", "d"]);
});
it("returns original order when ids are invalid", () => {
const order = ["a", "b", "c"];
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
});
});
describe("sortPackageOrderByName", () => {
it("sorts package IDs alphabetically ascending", () => {
const sorted = sortPackageOrderByName(
["pkg3", "pkg1", "pkg2"],
{
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
},
false
);
expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]);
});
it("sorts package IDs alphabetically descending", () => {
const sorted = sortPackageOrderByName(
["pkg1", "pkg2", "pkg3"],
{
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
},
true
);
expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownAuditLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("audit-log", () => {
it("writes audit events to the audit log", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-"));
tempDirs.push(baseDir);
initAuditLog(baseDir);
logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] });
const logPath = getAuditLogPath();
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Audit-Log Start");
expect(content).toContain("Settings changed");
expect(content).toContain("changedKeys");
});
it("rotates oversized audit logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
tempDirs.push(baseDir);
const oversizedPath = path.join(baseDir, "audit.log");
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
initAuditLog(baseDir);
expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const content = fs.readFileSync(oversizedPath, "utf8");
expect(content).toContain("Audit-Log Start");
});
});
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
describe("backup-crypto", () => {
it("encrypts and decrypts a round-trip correctly", () => {
const original = JSON.stringify({
version: 2,
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
session: { packages: {}, items: {} },
history: [{ id: "h1", name: "Test" }]
});
const encrypted = encryptBackup(original);
const decrypted = decryptBackup(encrypted);
expect(decrypted).toBe(original);
});
it("produces binary output that is not plaintext readable", () => {
const secret = "super-secret-token-12345";
const plaintext = JSON.stringify({ settings: { token: secret } });
const encrypted = encryptBackup(plaintext);
expect(encrypted.toString("utf8")).not.toContain(secret);
expect(encrypted.toString("latin1")).not.toContain(secret);
});
it("starts with the MDD1 magic bytes", () => {
const encrypted = encryptBackup("test");
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
});
it("produces different ciphertext for the same input (random IV)", () => {
const plaintext = "same input data";
const a = encryptBackup(plaintext);
const b = encryptBackup(plaintext);
expect(a.equals(b)).toBe(false);
expect(decryptBackup(a)).toBe(plaintext);
expect(decryptBackup(b)).toBe(plaintext);
});
it("throws on truncated data", () => {
const encrypted = encryptBackup("test data");
const truncated = encrypted.subarray(0, 10);
expect(() => decryptBackup(truncated)).toThrow();
});
it("throws on corrupted ciphertext", () => {
const encrypted = encryptBackup("test data");
const corrupted = Buffer.from(encrypted);
corrupted[corrupted.length - 1] ^= 0xff;
expect(() => decryptBackup(corrupted)).toThrow();
});
it("throws on wrong magic bytes", () => {
const encrypted = encryptBackup("test data");
const wrongMagic = Buffer.from(encrypted);
wrongMagic[0] = 0x00;
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
});
it("throws on empty buffer", () => {
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
});
it("handles large payloads", () => {
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
const encrypted = encryptBackup(large);
const decrypted = decryptBackup(encrypted);
expect(decrypted).toBe(large);
});
it("handles unicode content", () => {
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
const encrypted = encryptBackup(unicode);
expect(decryptBackup(encrypted)).toBe(unicode);
});
it("handles empty string round-trip", () => {
const encrypted = encryptBackup("");
expect(decryptBackup(encrypted)).toBe("");
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
}
const session: SessionState = {
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
reconnectReason: "", paused: false, running: true, updatedAt: 0
};
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
describe("buildBackupPayload — default is settings-only", () => {
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
expect(p.history).toBeUndefined();
expect(p.settings).toBeDefined();
});
it("includes session + history when backupIncludeDownloads is true", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
expect(p.kind).toBe("full");
expect(p.session).toBe(session);
expect(p.history).toBe(history);
});
it("treats a missing flag as settings-only (safe default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
});
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
// "Haken aus bleibt aus": the exported settings object preserves the flag,
// so importing it keeps the toggle off.
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
});
});
describe("planBackupImport — decision follows the file, not the local toggle", () => {
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(false);
expect(plan.message).toMatch(/Einstellungen/);
});
it("full backup (with session) -> restore downloads + relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(true);
});
it("rejects payloads without settings", () => {
expect(planBackupImport({ session }).valid).toBe(false);
expect(planBackupImport(null).valid).toBe(false);
expect(planBackupImport("nope").valid).toBe(false);
expect(planBackupImport({}).valid).toBe(false);
});
it("a settings-only export then import does NOT pull in the download list", () => {
// Build with toggle off, then plan the import of exactly that payload.
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
});
it("a full export then import DOES restore the download list", () => {
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(true);
});
});
+251
View File
@@ -0,0 +1,251 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { buildBackupPayload, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "../src/main/backup-payload";
import { defaultSettings } from "../src/main/constants";
import { normalizeSettings } from "../src/main/storage";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugAllowlist,
getDebugServerRuntimeStatus
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
import type { AppSettings, SessionState } from "../src/shared/types";
const tempDirs: string[] = [];
function input(settingsOverride: Partial<AppSettings>, remoteDiagnostics?: BackupRemoteDiagnostics) {
return {
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
appVersion: "1.7.233",
exportedAt: "2026-08-01T00:00:00.000Z",
session: {} as unknown as SessionState,
history: [],
remoteDiagnostics
};
}
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("backup remoteDiagnostics export gating", () => {
it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
const settings = {
...defaultSettings(),
backupIncludeRemoteDiagnostics: true
};
const payload = buildBackupPayload({
settings,
appVersion: "1.7.233",
exportedAt: "2026-08-01T00:00:00.000Z",
session: {} as unknown as SessionState,
history: [],
remoteDiagnostics: {
allowlist: ["192.0.2.0/24"],
port: 8976,
hostMode: "network"
}
});
expect(payload.remoteDiagnostics).toEqual({
allowlist: ["192.0.2.0/24"],
port: 8976,
hostMode: "network"
});
});
it("omits remoteDiagnostics when the toggle is off even if a section is provided", () => {
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.remoteDiagnostics).toBeUndefined();
});
it("omits remoteDiagnostics when toggle on but no section gathered", () => {
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, undefined));
expect(payload.remoteDiagnostics).toBeUndefined();
});
it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]);
const sectionJson = JSON.stringify(payload.remoteDiagnostics);
expect(sectionJson.toLowerCase()).not.toContain("token");
expect(sectionJson).not.toContain("publicHost");
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
});
});
describe("backupIncludeRemoteDiagnostics settings persistence", () => {
it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => {
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: true } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(true);
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: false } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
});
});
describe("resolveRemoteDiagnosticsRestore", () => {
it("maps network + non-empty allowlist to 0.0.0.0", () => {
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
});
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
});
it("maps local to 127.0.0.1", () => {
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
});
it("rejects an out-of-range or non-integer port", () => {
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
});
it("filters non-string and blank allowlist entries and trims", () => {
const r = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
});
it("returns null for missing or empty/invalid sections", () => {
expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
expect(resolveRemoteDiagnosticsRestore({})).toBeNull();
});
});
describe("backup remoteDiagnostics live restore round-trip", () => {
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
const restorePort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
expect(getDebugAllowlist()).toEqual([]);
const payload = buildBackupPayload(input(
{ backupIncludeRemoteDiagnostics: true },
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
));
const restore = resolveRemoteDiagnosticsRestore(payload.remoteDiagnostics);
expect(restore).not.toBeNull();
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
const status = await restartDebugServer();
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
expect(status.port).toBe(restorePort);
expect(status.host).toBe("0.0.0.0");
expect(status.allowlistCount).toBe(2);
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
});
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
});
});
describe("debug-server live diagnostics endpoints", () => {
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
expect(provRes.status).toBe(200);
const prov = await provRes.json();
expect(typeof prov.capturedAtMs).toBe("number");
expect(prov.megaDebrid).toBeTruthy();
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
expect(prov.debridLink).toBeTruthy();
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
expect(unauth.status).toBe(401);
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
expect(convRes.status).toBe(200);
const conv = await convRes.json();
expect(Array.isArray(conv.lines)).toBe(true);
expect(conv).toHaveProperty("available");
});
});
+167
View File
@@ -0,0 +1,167 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockCookiesSet,
mockFetch,
mockClearStorageData,
mockClearCache,
mockFromPartition,
mockSession
} = vi.hoisted(() => {
const cookiesSet = vi.fn();
const fetch = vi.fn();
const clearStorageData = vi.fn();
const clearCache = vi.fn();
const fromPartition = vi.fn();
return {
mockCookiesSet: cookiesSet,
mockFetch: fetch,
mockClearStorageData: clearStorageData,
mockClearCache: clearCache,
mockFromPartition: fromPartition,
mockSession: {
cookies: {
set: cookiesSet
},
fetch,
clearStorageData,
clearCache
}
};
});
vi.mock("electron", () => ({
session: {
fromPartition: mockFromPartition
}
}));
vi.mock("../src/main/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}));
import { BestDebridWebFallback } from "../src/main/bestdebrid-web";
function createCookieFile(contents: string): string {
const filePath = path.join(os.tmpdir(), `bestdebrid-cookies-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
fs.writeFileSync(filePath, contents, "utf8");
return filePath;
}
describe("bestdebrid-web", () => {
const tempFiles: string[] = [];
beforeEach(() => {
mockFromPartition.mockReturnValue(mockSession);
});
afterEach(() => {
vi.clearAllMocks();
mockFromPartition.mockReturnValue(mockSession);
while (tempFiles.length > 0) {
const filePath = tempFiles.pop();
if (!filePath) {
continue;
}
try {
fs.rmSync(filePath, { force: true });
} catch {
}
}
});
it("imports HttpOnly Netscape cookies instead of skipping them as comments", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session",
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
const count = await fallback.importCookiesFromFile(filePath);
expect(count).toBe(2);
expect(mockClearStorageData).toHaveBeenCalledTimes(1);
expect(mockClearStorageData).toHaveBeenCalledWith({
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
expect(mockCookiesSet).toHaveBeenCalledTimes(2);
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
name: "PHPSESSID",
domain: ".bestdebrid.com",
httpOnly: true,
secure: true
}));
});
it("deduplicates conflicting session cookies and prefers the HttpOnly variant", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585384\tPHPSESSID\tnon-http-only",
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\thttp-only"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
const count = await fallback.importCookiesFromFile(filePath);
expect(count).toBe(1);
expect(mockCookiesSet).toHaveBeenCalledTimes(1);
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
name: "PHPSESSID",
value: "http-only",
httpOnly: true,
domain: ".bestdebrid.com"
}));
});
it("rejects cookie files that only contain tracking cookies", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
".bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\t__stripe_mid\tstripe",
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
await expect(fallback.importCookiesFromFile(filePath))
.rejects.toThrow("Login-Cookie");
expect(mockCookiesSet).not.toHaveBeenCalled();
});
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
].join("\n"));
tempFiles.push(filePath);
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({
error: 1,
message: "Free users are not allowed to download using a VPN or proxy. Please purchase a premium plan."
}), { status: 200 }))
.mockResolvedValueOnce(new Response("<div class=\"font-medium\">Guest</div>", { status: 200 }));
const fallback = new BestDebridWebFallback(() => true);
await fallback.importCookiesFromFile(filePath);
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
.rejects.toThrow("Nicht eingeloggt");
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
.rejects.toThrow("Keine Cookies importiert");
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
});
});
+100
View File
@@ -0,0 +1,100 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("cleanup", () => {
it("removes archive artifacts but keeps media", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir);
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBeGreaterThan(0);
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
});
it("removes sample artifacts and link files", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir);
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
const links = await removeDownloadLinkArtifacts(dir);
const samples = await removeSampleArtifacts(dir);
expect(links).toBeGreaterThan(0);
expect(samples.files + samples.dirs).toBeGreaterThan(0);
});
it("cleans up archive files in nested directories", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir);
const sub1 = path.join(dir, "season1");
const sub2 = path.join(dir, "season1", "extras");
fs.mkdirSync(sub2, { recursive: true });
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBe(4);
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
});
it("detects link artifacts by URL content in text files", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir);
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
const removed = await removeDownloadLinkArtifacts(dir);
expect(removed).toBeGreaterThanOrEqual(3);
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
});
it("does not recurse into sample symlink or junction targets", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
tempDirs.push(dir, external);
const outsideFile = path.join(external, "outside-sample.mkv");
fs.writeFileSync(outsideFile, "keep", "utf8");
const linkedSampleDir = path.join(dir, "sample");
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(external, linkedSampleDir, linkType);
const result = await removeSampleArtifacts(dir);
expect(result.files).toBe(0);
expect(fs.existsSync(outsideFile)).toBe(true);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { encodeConnectionCode } from "../src/main/connection-code";
function decodeConnectionCode(code: string) {
const encoded = code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/");
const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Record<string, unknown>;
return {
host: payload.h,
port: payload.p,
token: payload.t,
name: payload.n,
scheme: payload.s ?? "http",
fingerprint: payload.fp
};
}
describe("connection-code", () => {
it("encodes a directly decodable payload", () => {
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
expect(code.startsWith("rddiag:v1:")).toBe(true);
const decoded = decodeConnectionCode(code);
expect(decoded.host).toBe("203.0.113.5");
expect(decoded.port).toBe(9868);
expect(decoded.token).toBe("deadbeef");
expect(decoded.name).toBe("server-1");
expect(decoded.scheme).toBe("http");
});
it("carries https scheme and fingerprint when set", () => {
const code = encodeConnectionCode({
host: "diag.example.com",
port: 8443,
token: "abc",
scheme: "https",
fingerprint: "AA:BB:CC"
});
const decoded = decodeConnectionCode(code);
expect(decoded.scheme).toBe("https");
expect(decoded.fingerprint).toBe("AA:BB:CC");
});
it("omits scheme key for plain http (default)", () => {
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
expect(json.s).toBeUndefined();
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
});
it("rejects invalid input", () => {
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
});
});
+202
View File
@@ -0,0 +1,202 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { importDlcContainers } from "../src/main/container";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("container", () => {
it("skips oversized DLC files without throwing and blocking other files", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const oversizedFilePath = path.join(dir, "oversized.dlc");
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
const validFilePath = path.join(dir, "valid.dlc");
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 });
}
return new Response("", { status: 404 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("valid");
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("skips non-dlc files completely", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-non-"));
tempDirs.push(dir);
const txtPath = path.join(dir, "links.txt");
fs.writeFileSync(txtPath, "http://link.com/1");
const result = await importDlcContainers([txtPath]);
expect(result).toEqual([]);
});
it("falls back to dcrypt if local decryption returns empty", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "fallback.dlc");
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("http://fallback.com/1", { status: 200 });
}
return new Response("", { status: 404 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([filePath]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("fallback");
expect(result[0].links).toEqual(["http://fallback.com/1"]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("falls back to dcrypt when local decryption throws invalid padding", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "invalid-local.dlc");
fs.writeFileSync(filePath, "X".repeat(120));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response(`<rc>${Buffer.alloc(16).toString("base64")}</rc>`, { status: 200 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("http://example.com/fallback1", { status: 200 });
}
return new Response("", { status: 404 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([filePath]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("invalid-local");
expect(result[0].links).toEqual(["http://example.com/fallback1"]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("falls back to paste endpoint when upload returns 413", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "big-dlc.dlc");
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("Request Entity Too Large", { status: 413 });
}
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
return new Response("http://paste-fallback.com/file1.rar\nhttp://paste-fallback.com/file2.rar", { status: 200 });
}
return new Response("", { status: 404 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([filePath]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("big-dlc");
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it("throws when both dcrypt endpoints return 413", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "huge.dlc");
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("Request Entity Too Large", { status: 413 });
}
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
return new Response("Request Entity Too Large", { status: 413 });
}
return new Response("", { status: 500 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
await expect(importDlcContainers([filePath])).rejects.toThrow(/zu groß für dcrypt/i);
});
it("throws when upload returns 413 and paste returns 500", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "doomed.dlc");
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("Request Entity Too Large", { status: 413 });
}
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
return new Response("paste failure", { status: 500 });
}
return new Response("", { status: 500 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
});
it("throws clear error when all dlc imports fail", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "broken.dlc");
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("upstream failure", { status: 500 });
}
return new Response("", { status: 500 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
formatConversionBlock,
hasActiveConversionTrace,
runWithConversionTrace,
traceConversionPhase,
type ConversionTrace
} from "../src/main/conversion-trace";
describe("formatConversionBlock", () => {
it("renders a header with verdict + total and one indented line per phase", () => {
const trace: ConversionTrace = {
startedAt: 1000,
itemId: "id1",
itemName: "tvs-foo.part5.rar",
link: "https://rapidgator.net/file/abc/tvs-foo.part5.rar.html",
providerOrder: "megadebrid-api,megadebrid-web",
notes: { slots: "conv2/dl6/max8" },
phases: [
{ atMs: 0, phase: "chain-try", provider: "megadebrid-api" },
{ atMs: 5, phase: "token", provider: "megadebrid-api", account: "2/2(e3)", tokenState: "fresh", workMs: 812, outcome: "ok" },
{ atMs: 820, phase: "api-getlink", provider: "megadebrid-api", account: "2/2(e3)", workMs: 634, outcome: "ok" }
]
};
const block = formatConversionBlock(trace, "OK", "", 1450);
const lines = block.split("\n");
expect(lines[0]).toContain("[CONV]");
expect(lines[0]).toContain("item=tvs-foo.part5.rar");
expect(lines[0]).toContain("result=OK");
expect(lines[0]).toContain("total=1450ms");
expect(lines[0]).toContain("slots=conv2/dl6/max8");
expect(lines).toHaveLength(4);
expect(lines[2]).toContain("+5ms token");
expect(lines[2]).toContain("token=fresh");
expect(lines[2]).toContain("workMs=812");
});
it("includes the failure detail in the header verdict", () => {
const trace: ConversionTrace = {
startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {},
phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }]
};
const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003);
expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
expect(block).toContain("caller-timeout");
});
});
describe("conversion trace context", () => {
it("traceConversionPhase is a no-op outside an active trace and does not throw", () => {
expect(hasActiveConversionTrace()).toBe(false);
expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow();
});
it("activates an ambient trace across awaits inside runWithConversionTrace", async () => {
expect(hasActiveConversionTrace()).toBe(false);
const seen = await runWithConversionTrace(
{ itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" },
async () => {
const before = hasActiveConversionTrace();
traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" });
await Promise.resolve();
const afterAwait = hasActiveConversionTrace();
return before && afterAwait;
}
);
expect(seen).toBe(true);
expect(hasActiveConversionTrace()).toBe(false);
});
});
+2813
View File
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugServerRuntimeStatus,
evaluateClientAllowed,
getPeerIp
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = [];
const TOKEN = "allowlist-secret";
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
const manager = {} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
return { baseUrl };
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("debug-server allowlist matcher (pure)", () => {
it("always allows loopback regardless of rules", () => {
expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true);
expect(evaluateClientAllowed("::1", [])).toBe(true);
expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true);
});
it("matches an exact allowlisted IP and rejects others", () => {
expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true);
expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false);
});
it("matches inside a CIDR and rejects outside it", () => {
expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true);
expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false);
});
it("fail-closed: empty rules reject every non-loopback client", () => {
expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false);
expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false);
});
it("derives the client IP from the socket peer, never from X-Forwarded-For", () => {
const forgedLoopback = {
socket: { remoteAddress: "8.8.8.8" },
headers: { "x-forwarded-for": "127.0.0.1" }
} as unknown as http.IncomingMessage;
expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8");
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true);
const ipv6Mapped = {
socket: { remoteAddress: "::ffff:10.0.0.5" },
headers: {}
} as unknown as http.IncomingMessage;
expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5");
});
});
describe("debug-server allowlist enforcement (wired)", () => {
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
expect(plain.status).toBe(200);
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
headers: { "X-Forwarded-For": "203.0.113.9" }
});
expect(spoofed.status).toBe(200);
});
it("still enforces the token for loopback clients", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await fetch(`${baseUrl}/health`);
expect(res.status).toBe(401);
});
it("reloads the allowlist live via restartDebugServer", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
const status = await restartDebugServer();
expect(status.running).toBe(true);
expect(status.allowlistCount).toBe(2);
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
});
});
+563
View File
@@ -0,0 +1,563 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/main/windows-host-diagnostics", () => ({
getWindowsHostDiagnostics: () => ({
collectedAt: "2026-03-09T00:00:03.000Z",
supported: true,
platform: "win32",
crashControl: {
crashDumpEnabled: 3,
minidumpDir: "C:\\Windows\\Minidumps",
dumpFile: "C:\\Windows\\MEMORY.DMP",
overwrite: 1,
logEvent: 1,
autoReboot: 1
},
recentKernelPower: [
{
timeCreated: "2026-03-09T00:00:04.000Z",
id: 41,
providerName: "Microsoft-Windows-Kernel-Power",
levelDisplayName: "Critical",
message: "unexpected restart",
bugcheckCode: "0",
bugcheckCodeHex: "",
reportId: ""
}
],
recentWerKernel: [],
recentKernelDump: [],
recentAppCrashes: [],
recentMinidumps: [],
assessmentHints: ["watchdog hint"],
errors: []
})
}));
import { defaultSettings } from "../src/main/constants";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { configureLogger, getLogFilePath, logger } from "../src/main/logger";
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
import type { DownloadManager } from "../src/main/download-manager";
import type { UiSnapshot } from "../src/shared/types";
const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
const legacyManifestField = ["a", "i", "Manifest"].join("");
const forbiddenSupportMarkers = [
["A", "I"].join(""),
["assist", "ant"].join(""),
["assist", "ants"].join(""),
["ag", "ent"].join(""),
["ag", "ents"].join(""),
["Clau", "de"].join(""),
["Anthro", "pic"].join(""),
["Co", "dex"].join(""),
["Open", "AI"].join(""),
["M", "C", "P"].join(""),
["K", "I"].join("")
];
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`debug server not ready: ${url}`);
}
function buildSnapshot(baseDir: string): UiSnapshot {
const settings = {
...defaultSettings(),
outputDir: path.join(baseDir, "downloads"),
extractDir: path.join(baseDir, "extract")
};
return {
settings,
session: {
version: 1,
packageOrder: ["pkg-1"],
packages: {
"pkg-1": {
id: "pkg-1",
name: "server-package",
outputDir: path.join(baseDir, "downloads", "server-package"),
extractDir: path.join(baseDir, "extract", "server-package"),
status: "downloading",
itemIds: ["item-1", "item-2"],
cancelled: false,
enabled: true,
priority: "normal",
postProcessLabel: "",
createdAt: Date.now() - 30_000,
updatedAt: Date.now()
}
},
items: {
"item-1": {
id: "item-1",
packageId: "pkg-1",
url: "https://hoster.example/file-1",
provider: "realdebrid",
providerLabel: "Real-Debrid",
status: "downloading",
retries: 1,
speedBps: 8 * 1024 * 1024,
downloadedBytes: 64 * 1024 * 1024,
totalBytes: 256 * 1024 * 1024,
progressPercent: 25,
fileName: "episode.part1.rar",
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part1.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft (Real-Debrid)",
createdAt: Date.now() - 30_000,
updatedAt: Date.now()
},
"item-2": {
id: "item-2",
packageId: "pkg-1",
url: "https://hoster.example/file-2",
provider: "realdebrid",
providerLabel: "Real-Debrid",
status: "failed",
retries: 3,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "episode.part2.rar",
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part2.rar"),
resumable: false,
attempts: 3,
lastError: "hoster unavailable",
fullStatus: "Fehler: hoster unavailable",
createdAt: Date.now() - 30_000,
updatedAt: Date.now()
}
},
runStartedAt: Date.now() - 30_000,
totalDownloadedBytes: 64 * 1024 * 1024,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: Date.now()
},
summary: null,
stats: {
totalDownloaded: 64 * 1024 * 1024,
totalDownloadedAllTime: 128 * 1024 * 1024,
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 1,
sessionStartedAt: Date.now() - 30_000,
appSessionStartedAt: Date.now() - 60_000,
sessionRuntimeMs: 60_000,
totalRuntimeMs: 3 * 60_000,
runtimeMeasuredAt: Date.now()
},
speedText: "8.0 MB/s",
etaText: "ETA: 00:25",
canStart: false,
canStop: true,
canPause: true,
clipboardActive: false,
reconnectSeconds: 0,
packageSpeedBps: {
"pkg-1": 8 * 1024 * 1024
}
};
}
async function createFixture() {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-"));
tempDirs.push(baseDir);
const token = "debug-secret";
const port = await getFreePort();
const snapshot = buildSnapshot(baseDir);
const storagePaths = createStoragePaths(baseDir);
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8");
const debridLinkApiKeys = "key-a\nkey-b";
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
saveSettings(storagePaths, {
...snapshot.settings,
token: "rd-secret-token",
realDebridUseWebLogin: true,
debridLinkApiKeys,
debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [],
totalDownloadedAllTime: 128 * 1024 * 1024,
totalCompletedFilesAllTime: 12,
totalRuntimeAllTimeMs: 5 * 60_000
});
saveHistory(storagePaths, [
{
id: "hist-1",
name: "server-package",
totalBytes: 123,
downloadedBytes: 123,
fileCount: 2,
provider: "realdebrid",
completedAt: Date.now() - 5_000,
durationSeconds: 42,
status: "completed",
outputDir: path.join(baseDir, "downloads", "server-package"),
urls: ["https://hoster.example/file-1"]
}
]);
configureLogger(baseDir);
fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8");
initAuditLog(baseDir);
const auditLogPath = getAuditLogPath();
if (!auditLogPath) {
throw new Error("audit log path missing");
}
logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" });
initRenameLog(baseDir);
logRenameEvent("INFO", "RENAME-LINE", { stage: "auto-rename", sourcePath: "C:\\extract\\old.mkv" });
initTraceLog(baseDir);
setTraceEnabled(true, "test-fixture");
logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" });
initSessionLog(baseDir);
const sessionLogPath = getSessionLogPath();
if (!sessionLogPath) {
throw new Error("session log path missing");
}
fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8");
logger.info("TRACE-MAIN-LINE");
initPackageLogs(baseDir);
initItemLogs(baseDir);
const packageLogPath = ensurePackageLog({
packageId: "pkg-1",
name: "server-package",
outputDir: snapshot.session.packages["pkg-1"]!.outputDir,
extractDir: snapshot.session.packages["pkg-1"]!.extractDir
});
if (!packageLogPath) {
throw new Error("package log path missing");
}
fs.appendFileSync(packageLogPath, "2026-03-09T00:00:02.000Z [INFO] PACKAGE-LINE\n", "utf8");
const itemLogPath = ensureItemLog({
itemId: "item-2",
packageId: "pkg-1",
packageName: "server-package",
fileName: "episode.part2.rar",
targetPath: snapshot.session.items["item-2"]!.targetPath
});
if (!itemLogPath) {
throw new Error("item log path missing");
}
fs.appendFileSync(itemLogPath, "2026-03-09T00:00:03.000Z [ERROR] ITEM-LINE\n", "utf8");
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: (packageId: string) => packageId === "pkg-1" ? packageLogPath : null,
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${token}`);
await new Promise((resolve) => setTimeout(resolve, 300));
return {
baseUrl,
token,
baseDir
};
}
afterEach(() => {
stopDebugServer();
shutdownSessionLog();
shutdownPackageLogs();
shutdownItemLogs();
shutdownRenameLog();
shutdownTraceLog();
shutdownAuditLog();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("debug-server", () => {
it("serves diagnostics with main, session, and package log tails", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.meta?.appVersion).toBeTruthy();
expect(payload.meta?.debugServer?.host).toBe("0.0.0.0");
expect(payload.status?.running).toBe(true);
expect(payload.host?.platform).toBe("win32");
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
expect(payload.selectedPackage?.name).toBe("server-package");
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE");
expect((payload.logs?.rename?.lines || []).join("\n")).toContain("RENAME-LINE");
expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT");
expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
expect(payload.accounts?.realDebrid?.configured).toBe(true);
expect(payload.history?.total).toBe(1);
});
it("writes a machine-readable support manifest into the runtime folder", async () => {
const fixture = await createFixture();
const manifestPath = path.join(fixture.baseDir, "debug_support_manifest.json");
expect(fs.existsSync(manifestPath)).toBe(true);
expect(fs.existsSync(path.join(fixture.baseDir, legacyManifestFile))).toBe(false);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
expect(manifest.purpose).toBe("Machine-readable manifest for support tooling and remote troubleshooting.");
expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
expect(manifest.debugServer?.port).toBeGreaterThan(0);
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name.");
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
expect(manifest.selfCheckEndpoint).toBe("/self-check");
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
expect(metaResponse.ok).toBe(true);
const metaPayload = await metaResponse.json() as Record<string, any>;
expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
expect(metaPayload.supportFiles?.[legacyManifestField]).toBeUndefined();
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath());
expect(metaPayload.supportChecks?.setup).toBe("/debug/setup");
expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check");
});
it("serves a debug setup check with trace expiry details", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.enabled).toBe(true);
expect(payload.status).toBe("ok");
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
expect(payload.host).toBe("0.0.0.0");
expect(payload.localOnly).toBe(false);
expect(payload.tokenConfigured).toBe(true);
expect(payload.supportManifestPresent).toBe(true);
expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json"));
expect(payload.traceEnabled).toBe(true);
expect(payload.traceAutoDisableAt).toBeTruthy();
expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0);
expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0);
expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0);
expect(payload.logSummary?.totalBytes).toBeGreaterThan(0);
expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0);
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>");
expect(Array.isArray(payload.notes)).toBe(true);
});
it("serves the self-check alias", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.status).toBe("ok");
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
});
it("writes the client IP into the debug trace log", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
headers: {
"X-Forwarded-For": "159.195.63.46"
}
});
expect(response.ok).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 200));
const traceLogPath = getTraceLogPath();
expect(traceLogPath).toBeTruthy();
const traceText = fs.readFileSync(traceLogPath!, "utf8");
expect(traceText).toContain("clientIp=159.195.63.46");
});
it("serves package details and package log by package query", async () => {
const fixture = await createFixture();
const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`);
expect(packagesResponse.ok).toBe(true);
const packagesPayload = await packagesResponse.json() as Record<string, any>;
expect(packagesPayload.count).toBe(1);
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`);
expect(logResponse.ok).toBe(true);
const logPayload = await logResponse.json() as Record<string, any>;
expect(logPayload.package?.name).toBe("server-package");
expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE");
});
it("serves item log by item query", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.item?.id).toBe("item-2");
expect(payload.item?.fileName).toBe("episode.part2.rar");
expect((payload.lines || []).join("\n")).toContain("ITEM-LINE");
});
it("serves host diagnostics separately", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.platform).toBe("win32");
expect(payload.crashControl?.crashDumpEnabled).toBe(3);
expect(payload.assessmentHints?.[0]).toContain("watchdog");
});
it("serves audit log, settings, accounts, stats, and history", async () => {
const fixture = await createFixture();
const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`);
expect(auditResponse.ok).toBe(true);
const auditPayload = await auditResponse.json() as Record<string, any>;
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`);
expect(renameResponse.ok).toBe(true);
const renamePayload = await renameResponse.json() as Record<string, any>;
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE");
const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`);
expect(traceResponse.ok).toBe(true);
const tracePayload = await traceResponse.json() as Record<string, any>;
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0&note=test`);
expect(traceConfigResponse.ok).toBe(true);
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
expect(traceConfigPayload.config?.enabled).toBe(false);
const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`);
expect(settingsResponse.ok).toBe(true);
const settingsPayload = await settingsResponse.json() as Record<string, any>;
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
expect(settingsPayload.extraction?.archivePasswordCount).toBe(0);
expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token");
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`);
expect(accountsResponse.ok).toBe(true);
const accountsPayload = await accountsResponse.json() as Record<string, any>;
expect(accountsPayload.debridLink?.keyCount).toBe(2);
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`);
expect(statsResponse.ok).toBe(true);
const statsPayload = await statsResponse.json() as Record<string, any>;
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`);
expect(historyResponse.ok).toBe(true);
const historyPayload = await historyResponse.json() as Record<string, any>;
expect(historyPayload.total).toBe(1);
expect(historyPayload.entries?.[0]?.name).toBe("server-package");
expect(historyPayload.entries?.[0]?.urlCount).toBe(1);
});
it("downloads a support bundle zip", async () => {
const fixture = await createFixture();
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`);
expect(response.ok).toBe(true);
expect(response.headers.get("content-type")).toContain("application/zip");
const buffer = Buffer.from(await response.arrayBuffer());
const zip = new AdmZip(buffer);
const entries = zip.getEntries().map((entry) => entry.entryName);
expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/accounts.json");
expect(entries).toContain("overview/debug-setup.json");
expect(entries).toContain("overview/self-check.json");
expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log");
expect(entries).toContain("logs/trace.log");
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(""));
expect(entries).not.toContain("runtime/debug_token.txt");
});
it("rejects unauthenticated requests", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/status`);
expect(response.status).toBe(401);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
getDesktopRenameLogPath,
initDesktopRenameLog,
logDesktopRename,
shutdownDesktopRenameLog,
verifyRename
} from "../src/main/desktop-rename-log";
const createdTmpDirs: string[] = [];
function tmpDesktop(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-"));
createdTmpDirs.push(dir);
return dir;
}
afterEach(() => {
shutdownDesktopRenameLog();
for (const dir of createdTmpDirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
createdTmpDirs.length = 0;
});
describe("desktop-rename-log", () => {
it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => {
const desktop = tmpDesktop();
initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath();
expect(logPath).toBeTruthy();
expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true);
expect(fs.existsSync(logPath as string)).toBe(true);
logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" });
const content = fs.readFileSync(logPath as string, "utf8");
expect(content).toContain("Rename-Session gestartet");
expect(content).toContain("Test-Rename");
expect(content).toContain("source=a.mkv");
expect(content).toContain("requested=b.mkv");
expect(content).toMatch(/\[INFO\]/);
});
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
const desktop = tmpDesktop();
initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath() as string;
logDesktopRename("INFO", "ZeileA");
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
expect(fs.existsSync(logPath)).toBe(false);
logDesktopRename("INFO", "ZeileB");
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
expect(fs.existsSync(logPath)).toBe(true);
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("Rename-Session gestartet");
expect(content).toContain("ZeileB");
});
it("is a silent no-op when initialized without a desktop path (never throws)", () => {
initDesktopRenameLog("");
expect(getDesktopRenameLogPath()).toBeNull();
expect(() => logDesktopRename("INFO", "egal")).not.toThrow();
});
it("verifyRename: ok when the target exists under the exact name and the source is gone", () => {
const dir = tmpDesktop();
const source = path.join(dir, "scn-xyz.part1.rar");
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
fs.writeFileSync(target, "data");
const v = verifyRename(source, target);
expect(v.ok).toBe(true);
expect(v.level).toBe("INFO");
expect(v.targetExists).toBe(true);
expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar");
expect(v.nameMatches).toBe(true);
expect(v.sourceGone).toBe(true);
expect(v.targetSize).toBe(4);
});
it("verifyRename: FAILS when the target is missing although rename reported success", () => {
const dir = tmpDesktop();
const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar"));
expect(v.ok).toBe(false);
expect(v.level).toBe("ERROR");
expect(v.targetExists).toBe(false);
expect(v.reason).toMatch(/nicht gefunden/i);
});
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
const dir = tmpDesktop();
const source = path.join(dir, "src.rar");
const target = path.join(dir, "dst.rar");
fs.writeFileSync(source, "x");
fs.writeFileSync(target, "x");
const v = verifyRename(source, target);
expect(v.ok).toBe(false);
expect(v.level).toBe("ERROR");
expect(v.sourceGone).toBe(false);
expect(v.reason).toMatch(/Quelldatei existiert noch/i);
});
it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => {
const dir = tmpDesktop();
const p = path.join(dir, "file.mkv");
fs.writeFileSync(p, "x");
const v = verifyRename(p, p);
expect(v.ok).toBe(true);
expect(v.targetExists).toBe(true);
expect(v.nameMatches).toBe(true);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "../src/main/download-completion";
describe("download-completion", () => {
describe("planDownloadCompletion", () => {
it("uses content-length when present", () => {
const plan = planDownloadCompletion({
existingBytes: 0, responseStatus: 200, contentLength: 1000,
totalFromRange: null, knownTotal: null, correctedTotal: null
});
expect(plan.source).toBe("content-length");
expect(plan.expectedTotal).toBe(1000);
});
it("falls back to stream-end when no size info is available", () => {
const plan = planDownloadCompletion({
existingBytes: 0, responseStatus: 200, contentLength: 0,
totalFromRange: null, knownTotal: null, correctedTotal: null
});
expect(plan.source).toBe("stream-end");
expect(plan.expectedTotal).toBeNull();
});
});
describe("validateDownloadedFileCompletion", () => {
const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false };
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
it("rejects a 0-byte stream-end download (H3)", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
expect(result.ok).toBe(false);
expect(result.error).toContain("download_underflow");
});
it("accepts a non-empty stream-end download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd });
expect(result.ok).toBe(true);
expect(result.totalBytes).toBe(5_000_000);
});
it("rejects an underflowing content-length download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 });
expect(result.ok).toBe(false);
});
it("accepts a complete content-length download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) });
expect(result.ok).toBe(true);
});
it("rejects a 0-byte download even with known provider size", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) });
expect(result.ok).toBe(false);
});
it("accepts provider-metadata download and flags size mismatch", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
expect(result.ok).toBe(false);
});
});
describe("reconcileFinalizedSize", () => {
it("keeps the streamed count for a pre-allocated file whose on-disk size is the zero-padding (corruption guard)", () => {
expect(reconcileFinalizedSize(300_000_000, 1_000_000_000, true)).toBe(300_000_000);
});
it("shrinks to the on-disk size when a pre-allocated file is genuinely short (real partial write)", () => {
expect(reconcileFinalizedSize(500, 300, true)).toBe(300);
});
it("reconciles in both directions for a non-pre-allocated file (stat is authoritative)", () => {
expect(reconcileFinalizedSize(300, 1000, false)).toBe(1000);
expect(reconcileFinalizedSize(1000, 300, false)).toBe(300);
});
it("returns the streamed count unchanged when the stat is invalid", () => {
expect(reconcileFinalizedSize(1234, Number.NaN, true)).toBe(1234);
expect(reconcileFinalizedSize(1234, -1, false)).toBe(1234);
});
it("is a no-op when on-disk size already equals the streamed count", () => {
expect(reconcileFinalizedSize(777, 777, true)).toBe(777);
expect(reconcileFinalizedSize(777, 777, false)).toBe(777);
});
it("does not block legitimate overshoot on a pre-allocated file (server sent more than pre-alloc)", () => {
expect(reconcileFinalizedSize(900, 900, true)).toBe(900);
});
});
});
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { createErrorRing } from "../src/main/error-ring";
describe("createErrorRing", () => {
it("keeps entries in insertion order", () => {
const ring = createErrorRing(10);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "WARN", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]);
expect(ring.size()).toBe(2);
});
it("caps at capacity by dropping the oldest", () => {
const ring = createErrorRing(3);
for (const m of ["a", "b", "c", "d", "e"]) {
ring.push({ ts: m, level: "ERROR", message: m });
}
expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]);
expect(ring.size()).toBe(3);
});
it("snapshot returns a copy, not the live buffer", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "WARN", message: "x" });
const snap = ring.snapshot();
snap.push({ ts: "t2", level: "ERROR", message: "injected" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]);
});
it("clear empties the ring", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "ERROR", message: "x" });
ring.clear();
expect(ring.snapshot()).toEqual([]);
expect(ring.size()).toBe(0);
});
it("coerces a non-positive capacity to at least 1", () => {
const ring = createErrorRing(0);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "ERROR", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { extractPackageArchives } from "../src/main/extractor";
const tempDirs: string[] = [];
const originalBackend = process.env.RD_EXTRACT_BACKEND;
function hasJavaRuntime(): boolean {
const result = spawnSync("java", ["-version"], { stdio: "ignore" });
return result.status === 0;
}
function hasJvmExtractorRuntime(): boolean {
const root = path.join(process.cwd(), "resources", "extractor-jvm");
const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
const requiredLibs = [
path.join(root, "lib", "sevenzipjbinding.jar"),
path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(root, "lib", "zip4j.jar")
];
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
if (originalBackend === undefined) {
delete process.env.RD_EXTRACT_BACKEND;
} else {
process.env.RD_EXTRACT_BACKEND = originalBackend;
}
});
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
it("extracts zip archives through SevenZipJBinding backend", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zipPath = path.join(packageDir, "release.zip");
const zip = new AdmZip();
zip.addFile("episode.txt", Buffer.from("ok"));
zip.writeZip(zipPath);
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
});
it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zipPath = path.join(packageDir, "progress-test.zip");
const zip = new AdmZip();
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
zip.addFile("file2.txt", Buffer.from("Another file ".repeat(100)));
zip.writeZip(zipPath);
const progressUpdates: Array<{
archiveName: string;
percent: number;
phase: string;
archivePercent?: number;
}> = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onProgress: (update) => {
progressUpdates.push({
archiveName: update.archiveName,
percent: update.percent,
phase: update.phase,
archivePercent: update.archivePercent,
});
},
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
const phases = new Set(progressUpdates.map((u) => u.phase));
expect(phases.has("preparing")).toBe(true);
expect(phases.has("extracting")).toBe(true);
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
expect(extracting.length).toBeGreaterThan(0);
const lastExtracting = extracting[extracting.length - 1];
expect(lastExtracting.archivePercent).toBe(100);
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
});
it("extracts multiple archives sequentially with progress for each", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-multi-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zip1 = new AdmZip();
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
zip1.writeZip(path.join(packageDir, "archive1.zip"));
const zip2 = new AdmZip();
zip2.addFile("episode02.txt", Buffer.from("ep2 content"));
zip2.writeZip(path.join(packageDir, "archive2.zip"));
const archiveNames = new Set<string>();
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onProgress: (update) => {
if (update.phase === "extracting" && update.archiveName) {
archiveNames.add(update.archiveName);
}
},
});
expect(result.extracted).toBe(2);
expect(result.failed).toBe(0);
expect(archiveNames.has("archive1.zip")).toBe(true);
expect(archiveNames.has("archive2.zip")).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
});
it("respects ask/skip conflict mode in jvm backend", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const zipPath = path.join(packageDir, "conflict.zip");
const zip = new AdmZip();
zip.addFile("same.txt", Buffer.from("new"));
zip.writeZip(zipPath);
const existingPath = path.join(targetDir, "same.txt");
fs.writeFileSync(existingPath, "old", "utf8");
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "ask",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
expect(fs.readFileSync(existingPath, "utf8")).toBe("old");
});
});
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { classifyDiskError } from "../src/main/fs-error";
import { isDebugFlagEnabled } from "../src/main/logger";
describe("classifyDiskError", () => {
it("maps ENOSPC from an error code to a disk-full reason", () => {
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
expect(classifyDiskError(err)).toMatch(/Festplatte voll/);
});
it("maps EACCES from a code to a permission reason", () => {
const err = Object.assign(new Error("nope"), { code: "EACCES" });
expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/);
});
it("lower-case codes are normalized", () => {
const err = Object.assign(new Error("x"), { code: "enospc" });
expect(classifyDiskError(err)).toMatch(/ENOSPC/);
});
it("falls back to scanning the message text when no code is present", () => {
expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/);
});
it("handles a plain string error", () => {
expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/);
});
it("returns null for an unrelated error", () => {
expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull();
expect(classifyDiskError(new Error("premature close"))).toBeNull();
expect(classifyDiskError(null)).toBeNull();
expect(classifyDiskError(undefined)).toBeNull();
});
});
describe("isDebugFlagEnabled", () => {
it("is true for affirmative values", () => {
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
expect(isDebugFlagEnabled(v)).toBe(true);
}
});
it("is false for empty/negative/garbage values", () => {
for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) {
expect(isDebugFlagEnabled(v)).toBe(false);
}
});
});
+165
View File
@@ -0,0 +1,165 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
// download-manager's selection + .DL.-rename wiring is exercised for real.
vi.mock("../src/main/video-processor", async (importActual) => {
const actual = await importActual<typeof import("../src/main/video-processor")>();
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedProcess.mockReset();
mockedTooling.mockReset();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const stateDir = path.join(root, "state");
fs.mkdirSync(extractDir, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
keepGermanAudioOnly,
germanAudioMode: "tag",
autoRename4sf4sj: false,
outputDir: path.join(root, "out"),
extractDir,
mkvLibraryDir: path.join(stateDir, "_mkv")
},
emptySession(),
createStoragePaths(stateDir)
);
const pkg: any = {
id: "ga-pkg-1",
name: "Test.Show.S01.GERMAN.DL.720p",
outputDir: path.join(root, "out", "Test.Show"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0
};
// Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked)
// processVideoFile. Tests that need the no-tool path override this.
mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
return { extractDir, manager, pkg };
}
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
const SAMPLE_DL = "Show.sample.DL.mkv";
const DL_AVI = "Show.S01E03.German.DL.avi";
function stage(extractDir: string): void {
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
fs.writeFileSync(path.join(extractDir, f), "x");
}
}
describe("keepGermanAudioOnly integration", () => {
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
expect(n).toBe(1);
const files = fs.readdirSync(extractDir);
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
expect(files).not.toContain(DL_MKV);
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
expect(files).toContain(SAMPLE_DL); // sample skipped
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
});
it("does nothing when the setting is off", async () => {
const { extractDir, manager, pkg } = setup(false);
stage(extractDir);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled();
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
it("leaves the file fully untouched (name included) when no German track is found", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
});
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0); // not counted as a remux
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
});
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoTool: 1, remuxed: 0 });
expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-tool" });
});
it("stores a per-package summary with counts and file details", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2, audioLanguages: ["eng", "fre"] } as VideoProcessResult);
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoGerman: 1, remuxed: 0, failed: 0 });
expect(pkg.audioStripSummary.files).toHaveLength(1);
expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-german", reason: "no-german-track", languages: "eng,fre" });
expect(pkg.updatedAt).toBeGreaterThan(0); // bumped so the snapshot delta picks it up
});
});
+107
View File
@@ -0,0 +1,107 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
const PLAIN_MKV = "Show.S01E02.German.720p.x264.mkv";
const DL_AVI = "Show.S01E03.German.DL.avi";
function setup(keepGermanAudioOnly: boolean): { extractDir: string; libraryDir: string; manager: DownloadManager; pkg: any } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-race-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const stateDir = path.join(root, "state");
const libraryDir = path.join(root, "library");
fs.mkdirSync(extractDir, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
autoExtract: true,
collectMkvToLibrary: true,
keepGermanAudioOnly,
germanAudioMode: "tag",
autoRename4sf4sj: false,
outputDir: path.join(root, "out"),
extractDir,
mkvLibraryDir: libraryDir
},
emptySession(),
createStoragePaths(stateDir)
);
const pkg: any = {
id: "race-pkg-1",
name: "Show.S01.GERMAN.DL.720p",
outputDir: path.join(root, "out", "Show.S01"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0
};
for (const f of [DL_MKV, PLAIN_MKV, DL_AVI]) {
fs.writeFileSync(path.join(extractDir, f), "x");
}
return { extractDir, libraryDir, manager, pkg };
}
function libraryNames(libraryDir: string): string[] {
try { return fs.readdirSync(libraryDir); } catch { return []; }
}
describe("Hybrid-Sammel Race-Schutz (.DL. noch nicht tonspur-bereinigt)", () => {
it("haelt eine remuxbare .DL.-Datei im Hybrid-Lauf zurueck (keepGermanAudioOnly an)", async () => {
const { extractDir, libraryDir, manager, pkg } = setup(true);
await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, true);
// Race-Opfer bleibt in extractDir, damit eine spaetere Runde / der Deferred-Pass es bereinigt
expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(true);
expect(libraryNames(libraryDir)).not.toContain(DL_MKV);
// Praezision: bereits bereinigte mkv (kein .DL.) wird gesammelt
expect(fs.existsSync(path.join(extractDir, PLAIN_MKV))).toBe(false);
// Praezision: .DL.avi ist nicht remuxbar -> wird NICHT zurueckgehalten, sondern gesammelt
expect(fs.existsSync(path.join(extractDir, DL_AVI))).toBe(false);
});
it("sammelt die remuxbare .DL.-Datei im Deferred-Lauf (deferFreshFiles=false)", async () => {
const { extractDir, libraryDir, manager, pkg } = setup(true);
await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, false);
expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(false);
expect(libraryNames(libraryDir)).toContain(DL_MKV);
});
it("haelt nichts zurueck wenn keepGermanAudioOnly aus ist (.DL. ist dann normaler Output)", async () => {
const { extractDir, manager, pkg } = setup(false);
await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, true);
expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(false);
});
});
+94
View File
@@ -0,0 +1,94 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseHashLine, readHashManifest, validateFileAgainstManifest } from "../src/main/integrity";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("integrity", () => {
it("parses md5 and sfv lines", () => {
const md = parseHashLine("d41d8cd98f00b204e9800998ecf8427e sample.bin");
expect(md?.algorithm).toBe("md5");
const sfv = parseHashLine("sample.bin 1A2B3C4D");
expect(sfv?.algorithm).toBe("crc32");
});
it("validates file against md5 manifest", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir);
const filePath = path.join(dir, "movie.bin");
fs.writeFileSync(filePath, Buffer.from("hello"));
fs.writeFileSync(path.join(dir, "hash.md5"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n");
const result = await validateFileAgainstManifest(filePath, dir);
expect(result.ok).toBe(true);
});
it("skips manifest files larger than 5MB", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir);
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
const manifestPath = path.join(dir, "hashes.md5");
fs.writeFileSync(manifestPath, largeContent, "utf8");
const stat = fs.statSync(manifestPath);
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
const manifest = readHashManifest(dir);
expect(manifest.size).toBe(0);
});
it("does not parse SHA256 (64-char hex) as valid hash", () => {
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
const result = parseHashLine(sha256Line);
expect(result).toBeNull();
});
it("parses SHA1 hash lines correctly", () => {
const sha1Line = "da39a3ee5e6b4b0d3255bfef95601890afd80709 emptyfile.bin";
const result = parseHashLine(sha1Line);
expect(result).not.toBeNull();
expect(result?.algorithm).toBe("sha1");
expect(result?.digest).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709");
expect(result?.fileName).toBe("emptyfile.bin");
});
it("ignores comment lines in hash manifests", () => {
expect(parseHashLine("; This is a comment")).toBeNull();
expect(parseHashLine("")).toBeNull();
expect(parseHashLine(" ")).toBeNull();
});
it("trusts the per-line algorithm over the file extension for a mislabeled manifest (.sfv holding md5 lines)", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir);
const filePath = path.join(dir, "movie.bin");
fs.writeFileSync(filePath, Buffer.from("hello"));
fs.writeFileSync(path.join(dir, "checksums.sfv"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n", "utf8");
const manifest = readHashManifest(dir);
expect(manifest.get("movie.bin")?.algorithm).toBe("md5");
const result = await validateFileAgainstManifest(filePath, dir);
expect(result.ok).toBe(true);
expect(result.message).toContain("MD5");
});
it("keeps first hash entry when duplicate filename appears across manifests", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir);
fs.writeFileSync(path.join(dir, "disc1.md5"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa movie.mkv\n", "utf8");
fs.writeFileSync(path.join(dir, "disc2.md5"), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb movie.mkv\n", "utf8");
const manifest = readHashManifest(dir);
expect(manifest.get("movie.mkv")?.digest).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
});
});
+85
View File
@@ -0,0 +1,85 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensureItemLog, getItemLogPath, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownItemLogs();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("item-log", () => {
it("creates a persistent item log file", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
const logPath = ensureItemLog({
itemId: "item-1",
packageId: "pkg-1",
packageName: "Test Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Test Paket\\episode.part2.rar"
});
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Item-Log Start");
expect(content).toContain("episode.part2.rar");
});
it("writes detail events into the item log", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
ensureItemLog({
itemId: "item-2",
packageId: "pkg-2",
packageName: "Detail Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Detail Paket\\episode.part2.rar"
});
logItemEvent("item-2", "ERROR", "Entpack-Fehler", {
archive: "episode.part2.rar",
code: "missing_parts",
detail: "Unexpected end of archive"
});
await new Promise((resolve) => setTimeout(resolve, 350));
const logPath = getItemLogPath("item-2");
expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Entpack-Fehler");
expect(content).toContain("archive=episode.part2.rar");
expect(content).toContain("code=missing_parts");
});
it("keeps traversal-like item ids inside the item log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
const logPath = ensureItemLog({
itemId: "..\\..\\outside",
packageId: "pkg-traversal",
packageName: "Traversal Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "item-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { buildLinkExportSelection, serializeLinkExportText } from "../src/main/link-export";
import { parseCollectorInput } from "../src/main/link-parser";
import type { UiSnapshot } from "../src/shared/types";
function buildSnapshot(): UiSnapshot {
return {
settings: {} as UiSnapshot["settings"],
session: {
version: 1,
packageOrder: ["pkg-1", "pkg-2"],
packages: {
"pkg-1": {
id: "pkg-1",
name: "Dave Staffel 1",
outputDir: "C:\\Downloads\\Dave Staffel 1",
extractDir: "C:\\Extract\\Dave Staffel 1",
status: "queued",
itemIds: ["item-1", "item-2"],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
},
"pkg-2": {
id: "pkg-2",
name: "Andere Staffel",
outputDir: "C:\\Downloads\\Andere Staffel",
extractDir: "C:\\Extract\\Andere Staffel",
status: "queued",
itemIds: ["item-3"],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
}
},
items: {
"item-1": {
id: "item-1",
packageId: "pkg-1",
url: "https://example.com/e01",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Dave.S01E01.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
},
"item-2": {
id: "item-2",
packageId: "pkg-1",
url: "https://example.com/e02",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Dave.S01E02.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
},
"item-3": {
id: "item-3",
packageId: "pkg-2",
url: "https://example.com/other",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Andere.S01E01.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
}
},
runStartedAt: 0,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 1
},
summary: null,
stats: {
totalDownloaded: 0,
totalDownloadedAllTime: 0,
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 2,
sessionStartedAt: 0,
appSessionStartedAt: 0,
sessionRuntimeMs: 0,
totalRuntimeMs: 0,
runtimeMeasuredAt: 0
},
speedText: "",
etaText: "",
canStart: true,
canStop: false,
canPause: false,
clipboardActive: false,
reconnectSeconds: 0,
packageSpeedBps: {}
};
}
describe("link-export", () => {
it("keeps original package names when exporting selected items", () => {
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-3"]);
expect(selection.packageCount).toBe(2);
expect(selection.linkCount).toBe(2);
expect(selection.packages.map((pkg) => pkg.name)).toEqual(["Dave Staffel 1", "Andere Staffel"]);
});
it("roundtrips exported text back into parsed package inputs", () => {
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-2"]);
const text = serializeLinkExportText(selection.packages);
const reparsed = parseCollectorInput(text, "");
expect(reparsed).toHaveLength(1);
expect(reparsed[0]?.name).toBe("Dave Staffel 1");
expect(reparsed[0]?.links).toEqual(["https://example.com/e01", "https://example.com/e02"]);
expect(reparsed[0]?.fileNames).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { mergePackageInputs, parseCollectorInput } from "../src/main/link-parser";
describe("link-parser", () => {
describe("mergePackageInputs", () => {
it("merges packages with the same name and preserves order", () => {
const input = [
{ name: "Package A", links: ["http://link1", "http://link2"] },
{ name: "Package B", links: ["http://link3"] },
{ name: "Package A", links: ["http://link4", "http://link1"] },
{ name: "", links: ["http://link5"] }
];
const result = mergePackageInputs(input);
expect(result).toHaveLength(3);
const pkgA = result.find(p => p.name === "Package A");
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]);
const pkgB = result.find(p => p.name === "Package B");
expect(pkgB?.links).toEqual(["http://link3"]);
});
it("sanitizes names during merge", () => {
const input = [
{ name: "Valid_Name", links: ["http://link1"] },
{ name: "Valid?Name*", links: ["http://link2"] }
];
const result = mergePackageInputs(input);
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
});
it("preserves file name hints when merging packages", () => {
const input = [
{ name: "Package A", links: ["http://link1", "http://link2"], fileNames: ["one.rar", "two.rar"] },
{ name: "Package A", links: ["http://link3", "http://link1"], fileNames: ["three.rar", "ignored.rar"] }
];
const result = mergePackageInputs(input);
expect(result).toHaveLength(1);
expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]);
expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]);
});
});
describe("parseCollectorInput", () => {
it("returns empty array for empty or invalid input", () => {
expect(parseCollectorInput("")).toEqual([]);
expect(parseCollectorInput("just some text without links")).toEqual([]);
expect(parseCollectorInput("ftp://notsupported")).toEqual([]);
});
it("parses and merges links from raw text", () => {
const rawText = `
Here are some links:
http://example.com/part1.rar
http://example.com/part2.rar
# package: Custom_Name
http://other.com/file1
http://other.com/file2
`;
const result = parseCollectorInput(rawText, "DefaultFallback");
expect(result).toHaveLength(2);
const defaultPkg = result.find(p => p.name === "DefaultFallback");
expect(defaultPkg?.links).toEqual([
"http://example.com/part1.rar",
"http://example.com/part2.rar"
]);
const customPkg = result.find(p => p.name === "Custom_Name");
expect(customPkg?.links).toEqual([
"http://other.com/file1",
"http://other.com/file2"
]);
});
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { logTimestamp } from "../src/main/log-timestamp";
describe("logTimestamp", () => {
it("formats local time with an explicit UTC offset (ISO 8601), not a UTC 'Z' string", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant);
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
expect(formatted.endsWith("Z")).toBe(false);
});
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
});
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant);
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason, isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
describe("isMegaDebridResolveFailure", () => {
it("detects the real Mega-Debrid French resolve-failure phrase", () => {
expect(isMegaDebridResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
});
it("matches inside the aggregated provider-chain error (api fail | web timeout)", () => {
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: Mega-Debrid (API): Fichier supprimé chez l'hébergeur | Mega-Debrid Web: Mega-Debrid (Web): Abbruch/Timeout nach 60s";
expect(isMegaDebridResolveFailure(aggregated)).toBe(true);
});
it("matches the de-accented variant", () => {
expect(isMegaDebridResolveFailure("Fichier supprime chez l'hebergeur")).toBe(true);
});
it("matches other Mega-Debrid resolve phrases", () => {
expect(isMegaDebridResolveFailure("Fichier introuvable")).toBe(true);
expect(isMegaDebridResolveFailure("Le fichier n'existe plus")).toBe(true);
});
it("does NOT match unrelated/transient text", () => {
expect(isMegaDebridResolveFailure("Abbruch/Timeout nach 60s")).toBe(false);
expect(isMegaDebridResolveFailure("Quota/Limit erreicht")).toBe(false);
});
});
describe("germanMegaDebridResolveReason (transient wording, NOT 'tot')", () => {
it("renders 'supprimé' as a transient, retryable German reason", () => {
const reason = germanMegaDebridResolveReason("Mega-Debrid API: Fichier supprimé chez l'hébergeur");
expect(reason).toBe("Datei beim Hoster gerade nicht abrufbar");
expect(reason.toLowerCase()).not.toContain("tot");
expect(reason.toLowerCase()).not.toContain("gelöscht");
});
it("renders not-found phrases in German", () => {
expect(germanMegaDebridResolveReason("Fichier introuvable")).toBe("Datei beim Hoster nicht gefunden");
});
});
describe("isMegaDebridTransientResolveFailure (matches raw French AND rendered German)", () => {
it("matches the raw French phrase that may reach the download-manager", () => {
expect(isMegaDebridTransientResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
});
it("matches the German rendered reason that classifyAccountFailure produces", () => {
const aggregated = "Mega-Debrid (Account 1/4, ab***@x): Datei beim Hoster gerade nicht abrufbar | Mega-Debrid (Account 2/4, cd***@y): Datei beim Hoster gerade nicht abrufbar";
expect(isMegaDebridTransientResolveFailure(aggregated)).toBe(true);
});
it("matches the German not-found rendered reason", () => {
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Datei beim Hoster nicht gefunden")).toBe(true);
});
it("does NOT match a Mega-Debrid timeout/abort (that has its own account cooldown path)", () => {
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Abbruch/Timeout nach 60s")).toBe(false);
});
it("does NOT match unrelated provider errors", () => {
expect(isMegaDebridTransientResolveFailure("AllDebrid: zu viele aktive Downloads")).toBe(false);
expect(isMegaDebridTransientResolveFailure("Debrid-Link: badToken")).toBe(false);
});
});
+178
View File
@@ -0,0 +1,178 @@
import crypto from "node:crypto";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
decryptMegaAttributes,
isMegaFileUrl,
parseMegaUrl,
resolveMegaFilename
} from "../src/main/mega-public-api";
function base64Url(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function makeRandomFileKey(): Buffer {
return crypto.randomBytes(32);
}
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
const plain = "MEGA" + JSON.stringify(jsonAttrs);
const padded = Buffer.from(plain, "utf8");
const padLen = (16 - (padded.length % 16)) % 16;
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
cipher.setAutoPadding(false);
const enc = Buffer.concat([cipher.update(buf), cipher.final()]);
return base64Url(enc);
}
describe("mega-public-api", () => {
describe("isMegaFileUrl", () => {
it("recognizes new format", () => {
expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true);
});
it("recognizes legacy format", () => {
expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true);
});
it("recognizes mega.co.nz", () => {
expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true);
});
it("rejects folder URLs", () => {
expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false);
});
it("rejects non-mega URLs", () => {
expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false);
});
it("rejects garbage", () => {
expect(isMegaFileUrl("")).toBe(false);
expect(isMegaFileUrl("foo")).toBe(false);
});
});
describe("parseMegaUrl", () => {
it("parses new-format URL into id + 32-byte key", () => {
const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo";
const parsed = parseMegaUrl(url);
expect(parsed).not.toBeNull();
expect(parsed?.id).toBe("pZl1wBRQ");
expect(parsed?.rawKey.length).toBe(32);
});
it("parses legacy-format URL", () => {
const id = "abcDEF12";
const key = makeRandomFileKey();
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
const parsed = parseMegaUrl(url);
expect(parsed?.id).toBe(id);
expect(parsed?.rawKey.equals(key)).toBe(true);
});
it("rejects URL with folder key (16 bytes)", () => {
const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`;
expect(parseMegaUrl(url)).toBeNull();
});
it("rejects malformed URLs", () => {
expect(parseMegaUrl("not-a-url")).toBeNull();
expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull();
});
});
describe("decryptMegaAttributes", () => {
it("round-trips encrypted Mega attributes", () => {
const aesKey = crypto.randomBytes(16);
const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" };
const enc = encryptAttributes(original, aesKey);
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
const decrypted = decryptMegaAttributes(decoded, aesKey);
expect(decrypted).not.toBeNull();
expect(decrypted?.n).toBe(original.n);
});
it("returns null for wrong key", () => {
const aesKey = crypto.randomBytes(16);
const wrongKey = crypto.randomBytes(16);
const enc = encryptAttributes({ n: "x" }, aesKey);
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull();
});
it("returns null for non-multiple-of-16 input", () => {
const aesKey = crypto.randomBytes(16);
expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull();
});
it("returns null for wrong key length", () => {
expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull();
});
});
describe("resolveMegaFilename (mocked fetch)", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("returns filename + size for a valid Mega response", async () => {
const fileKey = makeRandomFileKey();
const aesKey = fileKey.subarray(0, 16);
const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`;
const encrypted = encryptAttributes(
{ n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" },
aesKey
);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return [{ s: 1234567890, at: encrypted, msd: 1 }];
}
} as unknown as Response);
const result = await resolveMegaFilename(url);
expect(result).not.toBeNull();
expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv");
expect(result?.size).toBe(1234567890);
});
it("returns null when Mega returns numeric error", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return -9;
}
} as unknown as Response);
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null when response is array with error code", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return [-16];
}
} as unknown as Response);
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null when fetch throws", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null for non-mega URL without making any fetch call", async () => {
const fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
});
+354
View File
@@ -0,0 +1,354 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { MegaWebFallback } from "../src/main/mega-web-fallback";
const originalFetch = globalThis.fetch;
describe("mega-web-fallback", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("MegaWebFallback class", () => {
it("returns null when credentials are empty", async () => {
const fallback = new MegaWebFallback(() => ({ login: "", password: "" }));
const result = await fallback.unrestrict("https://mega.debrid/test");
expect(result).toBeNull();
});
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) => {
const urlStr = String(url);
fetchCallCount += 1;
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`
<div class="acp-box">
<h3>Link: https://mega.debrid/link1</h3>
<a href="javascript:processDebrid(1,'secretcode123',0)">Download</a>
</div>
`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
const result = await fallback.unrestrict("https://mega.debrid/link1");
expect(result).not.toBeNull();
expect(result?.directUrl).toBe("https://mega.direct/123");
expect(result?.fileName).toBe("link1");
expect(fetchCallCount).toBe(4);
});
it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => {
let ajaxCalls = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
ajaxCalls += 1;
return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
expect(ajaxCalls).toBe(1);
});
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
let ajaxCalls = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
ajaxCalls += 1;
return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
expect(ajaxCalls).toBe(0);
});
it("logs in with the per-account credentials passed to unrestrict, not the default", async () => {
const loginsUsed: string[] = [];
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const params = new URLSearchParams(String(opts?.body ?? ""));
loginsUsed.push(params.get("login") || "");
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
expect(result?.directUrl).toBe("https://mega.direct/ok");
expect(loginsUsed).toContain("account2");
expect(loginsUsed).not.toContain("defaultacc");
});
it("throws if login fails to set cookie", async () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
return new Response("", { headers, status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
});
it("throws if login verify check fails (no form found)", async () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
});
it("returns null if generation fails to find a code", async () => {
let callCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
callCount++;
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`<div>No links here</div>`, { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
const result = await fallback.unrestrict("http://mega.debrid/file");
expect(result).toBeNull();
});
it("serialisiert gleichzeitige Umwandlungen auf DEMSELBEN Account (kein Doppel-Login)", async () => {
let loginCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const u = String(url);
if (u.includes("form=login")) {
loginCount += 1;
await new Promise((r) => setTimeout(r, 15));
const headers = new Headers();
headers.append("set-cookie", "session=c; path=/");
return new Response("", { headers, status: 200 });
}
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
const [r1, r2] = await Promise.all([
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "same", password: "pw" }),
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "same", password: "pw" })
]);
expect(r1?.directUrl).toBe("https://mega.direct/ok");
expect(r2?.directUrl).toBe("https://mega.direct/ok");
// Serialisiert auf demselben Account → der zweite nutzt die gecachte Session, kein zweiter Login.
expect(loginCount).toBe(1);
});
it("wandelt auf VERSCHIEDENEN Accounts parallel um (Logins laufen gleichzeitig)", async () => {
let activeLogins = 0;
let maxActiveLogins = 0;
const bothInFlight = new Promise<void>((resolve) => {
const check = setInterval(() => { if (activeLogins >= 2) { clearInterval(check); resolve(); } }, 5);
});
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const u = String(url);
if (u.includes("form=login")) {
activeLogins += 1;
maxActiveLogins = Math.max(maxActiveLogins, activeLogins);
await bothInFlight;
activeLogins -= 1;
const headers = new Headers();
headers.append("set-cookie", "session=c; path=/");
return new Response("", { headers, status: 200 });
}
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "d", password: "p" }));
const [r1, r2] = await Promise.all([
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "acc1", password: "p" }),
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "acc2", password: "p" })
]);
expect(r1?.directUrl).toBe("https://mega.direct/ok");
expect(r2?.directUrl).toBe("https://mega.direct/ok");
// Verschiedene Accounts → beide Logins gleichzeitig in-flight (sonst haengt es am bothInFlight-Barrier).
expect(maxActiveLogins).toBe(2);
}, 10000);
it("aborts pending Mega-Web polling when signal is cancelled", async () => {
globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return Promise.resolve(new Response("", { headers, status: 200 }));
}
if (urlStr.includes("page=debrideur")) {
return Promise.resolve(new Response('<form id="debridForm"></form>', { status: 200 }));
}
if (urlStr.includes("form=debrid")) {
return Promise.resolve(new Response(`
<div class="acp-box">
<h3>Link: https://mega.debrid/link2</h3>
<a href="javascript:processDebrid(1,'secretcode456',0)">Download</a>
</div>
`, { status: 200 }));
}
if (urlStr.includes("ajax=debrid")) {
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
const onAbort = (): void => reject(new Error("aborted:ajax"));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
return Promise.resolve(new Response("Not found", { status: 404 }));
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort("test");
}, 200);
try {
await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i);
} finally {
clearTimeout(timer);
}
});
it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => {
let releaseLogin: () => void = () => {};
const loginGate = new Promise<void>((resolve) => { releaseLogin = resolve; });
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const u = String(url);
if (u.includes("form=login")) {
await loginGate;
const headers = new Headers();
headers.append("set-cookie", "session=c; path=/");
return new Response("", { headers, status: 200 });
}
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
const firstCtrl = new AbortController();
const first = fallback.unrestrict("https://mega.debrid/a", firstCtrl.signal, { login: "same", password: "pw" });
await new Promise((r) => setTimeout(r, 25));
const secondCtrl = new AbortController();
const second = fallback.unrestrict("https://mega.debrid/b", secondCtrl.signal, { login: "same", password: "pw" });
await new Promise((r) => setTimeout(r, 25));
secondCtrl.abort("caller-timeout");
await expect(second).rejects.toThrow(/queue.?timeout/i);
releaseLogin();
await first.catch(() => null);
await new Promise((r) => setTimeout(r, 20));
}, 10000);
});
});
+153
View File
@@ -0,0 +1,153 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/main/notify", async (importActual) => {
const actual = await importActual<typeof import("../src/main/notify")>();
return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { sendNotification } from "../src/main/notify";
const mockedSend = sendNotification as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedSend.mockClear();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
tempDirs.push(root);
const session = emptySession();
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "out"),
extractDir: path.join(root, "extract"),
notifyUrl: "https://discord.com/api/webhooks/123/abc",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true
},
session,
createStoragePaths(path.join(root, "state"))
);
return { manager, session };
}
function addPackage(session: ReturnType<typeof emptySession>, itemStatuses: string[]): any {
const pkgId = "pkg-1";
const pkg: any = {
id: pkgId,
name: "Test.Show.S01",
outputDir: "C:/out",
extractDir: "C:/extract",
status: "queued",
itemIds: itemStatuses.map((_s, i) => `it-${i}`),
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
};
session.packages[pkgId] = pkg;
session.packageOrder.push(pkgId);
itemStatuses.forEach((status, i) => {
session.items[`it-${i}`] = {
id: `it-${i}`,
packageId: pkgId,
url: `https://dummy/${i}`,
provider: null,
status,
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: `f${i}.rar`,
targetPath: "",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "",
createdAt: 1,
updatedAt: 1
} as any;
});
return pkg;
}
describe("refreshPackageStatus failed-transition notify", () => {
it("notifies a MIXED package (some success, last finisher failed) — the lost-webhook case", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["completed", "failed"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).toHaveBeenCalledTimes(1);
expect(mockedSend.mock.calls[0][1].title).toBe("❌ Paket fehlgeschlagen");
expect(mockedSend.mock.calls[0][1].message).toContain("1 von 2");
});
it("notifies an all-failed package and dedups repeat refreshes", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).toHaveBeenCalledTimes(1);
});
it("stays silent outside a run (startup recovery must not spam)", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed"]);
session.running = false;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).not.toHaveBeenCalled();
});
it("does not notify while items are still pending", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "queued"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("queued");
expect(mockedSend).not.toHaveBeenCalled();
});
it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
session.running = true;
mockedSend.mockResolvedValueOnce(false);
(manager as any).refreshPackageStatus(pkg);
await new Promise((r) => setTimeout(r, 0));
expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification, truncateContent } from "../src/main/notify";
const noSleep = async (): Promise<void> => {};
const WEBHOOK = "https://discord.com/api/webhooks/123/abc";
describe("normalizeDiscordMention", () => {
it("wraps a bare user ID as a pinging mention", () => {
expect(normalizeDiscordMention("123456789012345678")).toBe("<@123456789012345678>");
expect(normalizeDiscordMention(" 987654321 ")).toBe("<@987654321>");
});
it("passes @everyone/@here and formed mentions through", () => {
expect(normalizeDiscordMention("@everyone")).toBe("@everyone");
expect(normalizeDiscordMention("@here")).toBe("@here");
expect(normalizeDiscordMention("<@123456789>")).toBe("<@123456789>");
expect(normalizeDiscordMention("<@&111222333>")).toBe("<@&111222333>");
});
it("returns empty for empty input", () => {
expect(normalizeDiscordMention("")).toBe("");
expect(normalizeDiscordMention(" ")).toBe("");
});
});
describe("isNotifyUrlValid", () => {
it("accepts http/https URLs", () => {
expect(isNotifyUrlValid(WEBHOOK)).toBe(true);
expect(isNotifyUrlValid("http://192.168.1.10:8080/hook")).toBe(true);
expect(isNotifyUrlValid(` ${WEBHOOK} `)).toBe(true);
});
it("rejects empty and non-http values", () => {
expect(isNotifyUrlValid("")).toBe(false);
expect(isNotifyUrlValid("discord.com/api/webhooks/123/abc")).toBe(false);
expect(isNotifyUrlValid("ftp://x")).toBe(false);
expect(isNotifyUrlValid("https:// mit leerzeichen")).toBe(false);
expect(isNotifyUrlValid("***")).toBe(false);
});
});
describe("truncateContent", () => {
it("leaves short content untouched", () => {
expect(truncateContent("hallo")).toBe("hallo");
});
it("caps at the limit", () => {
expect(truncateContent("x".repeat(3000)).length).toBe(2000);
});
it("never splits a surrogate pair at the boundary", () => {
const emoji = "🏁";
const content = "x".repeat(1999) + emoji;
const cut = truncateContent(content);
expect(cut.length).toBe(1999);
expect(/[\uD800-\uDBFF]$/.test(cut)).toBe(false);
});
});
describe("buildNotifyRequest", () => {
it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => {
const req = buildNotifyRequest(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
expect(req.url).toBe(WEBHOOK);
expect(req.init.method).toBe("POST");
expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" });
const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)");
expect(body.username).toBe("Real-Debrid Downloader");
});
it("prepends the mention so Discord pings (bare ID gets wrapped)", () => {
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" });
const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("<@123456789012345678> **T**\nM");
});
it("sends no mention prefix when the field is empty", () => {
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" });
const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("**T**\nM");
});
});
describe("sendNotification", () => {
it("returns true on HTTP ok (Discord answers 204 No Content)", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(true);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("retries a 429 using Discord's retry_after and then succeeds", async () => {
const waits: number[] = [];
const sleepSpy = async (ms: number): Promise<void> => { waits.push(ms); };
const fetchFn = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 1.2 }), { status: 429 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, sleepSpy)).resolves.toBe(true);
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(waits).toContain(1200); // seconds -> ms
});
it("retries transient 5xx and network errors, then gives up", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 502 }));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
expect(fetchFn).toHaveBeenCalledTimes(3); // initial + 2 retries
const fetchErr = vi.fn().mockRejectedValue(new Error("offline"));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchErr, noSleep)).resolves.toBe(false);
expect(fetchErr).toHaveBeenCalledTimes(3);
});
it("does not retry a permanent 4xx", async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 404 }));
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("serializes concurrent sends in order (burst protection)", async () => {
const order: string[] = [];
const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
order.push(JSON.parse(String(init.body)).content);
return new Response(null, { status: 204 });
});
const sends = [
sendNotification(WEBHOOK, { title: "1", message: "" }, fetchFn, noSleep),
sendNotification(WEBHOOK, { title: "2", message: "" }, fetchFn, noSleep),
sendNotification(WEBHOOK, { title: "3", message: "" }, fetchFn, noSleep)
];
await expect(Promise.all(sends)).resolves.toEqual([true, true, true]);
expect(order).toEqual(["**1**\n", "**2**\n", "**3**\n"]);
});
it("does not call fetch for an invalid URL", async () => {
const fetchFn = vi.fn();
await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
expect(fetchFn).not.toHaveBeenCalled();
});
});
+82
View File
@@ -0,0 +1,82 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensurePackageLog, getPackageLogPath, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownPackageLogs();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("package-log", () => {
it("creates a persistent package log file", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
const logPath = ensurePackageLog({
packageId: "pkg-1",
name: "Test Paket",
outputDir: "C:\\downloads\\Test Paket",
extractDir: "C:\\extract\\Test Paket"
});
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Paket-Log Start");
expect(content).toContain("Test Paket");
});
it("writes detail events into the package log", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
ensurePackageLog({
packageId: "pkg-2",
name: "Detail Paket",
outputDir: "C:\\downloads\\Detail Paket",
extractDir: "C:\\extract\\Detail Paket"
});
logPackageEvent("pkg-2", "INFO", "Passwort-Versuch", {
archive: "episode.part1.rar",
attempt: "1/3",
password: "\"secret\""
});
await new Promise((resolve) => setTimeout(resolve, 350));
const logPath = getPackageLogPath("pkg-2");
expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Passwort-Versuch");
expect(content).toContain("archive=episode.part1.rar");
expect(content).toContain("password=\"secret\"");
});
it("keeps traversal-like package ids inside the package log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
const logPath = ensurePackageLog({
packageId: "..\\..\\outside",
name: "Traversal Paket",
outputDir: "C:\\downloads\\Traversal Paket",
extractDir: "C:\\extract\\Traversal Paket"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "package-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import { sortPackagesForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[]): PackageEntry {
const now = Date.now();
return {
id,
name: id,
outputDir: "",
extractDir: "",
status: "queued",
itemIds,
cancelled: false,
enabled: true,
priority: "normal",
createdAt: now,
updatedAt: now
};
}
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem {
const now = Date.now();
return {
id,
packageId,
url: `https://hoster.example/${id}`,
provider: null,
status,
retries: 0,
speedBps: 0,
downloadedBytes,
totalBytes: downloadedBytes,
progressPercent: downloadedBytes > 0 ? 50 : 0,
fileName: `${id}.bin`,
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "",
createdAt: now,
updatedAt: now
};
}
describe("sortPackagesForDisplay", () => {
it("floats active packages to the top, keeping queue order within each group", () => {
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
// their original queue order; pkg-c (queued only) sinks below.
const packages = [
createPackage("pkg-a", ["a1", "a2"]),
createPackage("pkg-c", ["c1"]),
createPackage("pkg-b", ["b1", "b2"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 250),
a2: createItem("a2", "pkg-a", "completed", 500),
c1: createItem("c1", "pkg-c", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 800),
b2: createItem("b2", "pkg-b", "completed", 900)
};
const sorted = sortPackagesForDisplay(packages, items, true, true);
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"])
];
// Both active. pkg-b initially has more bytes than pkg-a.
const before: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 100),
b1: createItem("b1", "pkg-b", "downloading", 900)
};
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
// both are still active, so they keep queue order. (Old code swapped them.)
const after: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 5000),
b1: createItem("b1", "pkg-b", "downloading", 950)
};
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
expect(orderAfter).toEqual(orderBefore);
});
it("keeps package order untouched when auto sort is disabled", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
createPackage("pkg-c", ["c1"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 500),
c1: createItem("c1", "pkg-c", "queued", 0)
};
const sorted = sortPackagesForDisplay(packages, items, true, false);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
});
+391
View File
@@ -0,0 +1,391 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
type ReleaseVerification = {
publish: {
provider: string;
owner: string;
repo: string;
};
latestArtifact: string;
missingArtifacts: string[];
};
type CommandResult = {
status: number | null;
stdout?: string;
stderr?: string;
error?: Error;
};
type ArchiveVerification = {
verifiedArchives: string[];
};
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
const verifierUrl = "../scripts/verify_public_release.mjs";
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
verifyReleaseArchives: (
rootDir: string,
options: {
sevenZipPath: string;
runCommand: (command: string, args: string[]) => CommandResult;
}
) => ArchiveVerification;
};
const fixtureRoots: string[] = [];
const redistributionFiles = [
"LICENSE",
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
"resources/extractor-jvm/licenses/7-Zip-license.txt",
"resources/extractor-jvm/licenses/Apache-2.0.txt",
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
] as const;
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
const filePath = path.join(rootDir, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function writeRedistributionFiles(rootDir: string, packaged = false): void {
for (const relativePath of redistributionFiles) {
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
let targetPath: string = relativePath;
if (packaged && relativePath === "LICENSE") {
targetPath = "win-unpacked/resources/LICENSE";
} else if (packaged) {
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
}
writeFile(rootDir, targetPath, content);
}
}
function writeArchivePayload(outputDir: string, omittedName = ""): void {
for (const relativePath of redistributionFiles) {
if (path.basename(relativePath) === omittedName) {
continue;
}
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
const targetPath = relativePath === "LICENSE"
? "resources/LICENSE"
: `resources/app.asar.unpacked/${relativePath}`;
writeFile(outputDir, targetPath, content);
}
}
function createArchiveCommandRunner(omittedName = "") {
return (command: string, args: string[]): CommandResult => {
const archivePath = args[1] || "";
const outputArg = args.find((arg) => arg.startsWith("-o"));
if (!outputArg) {
return { status: 2, stderr: "missing output directory" };
}
const outputDir = outputArg.slice(2);
if (archivePath.toLowerCase().endsWith(".exe")) {
writeFile(outputDir, "payload/app-64.7z", "nested archive");
} else if (archivePath.toLowerCase().endsWith(".7z")) {
writeArchivePayload(outputDir, omittedName);
}
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
};
}
function createReleaseFixture(): string {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
fixtureRoots.push(rootDir);
const setupPayload = Buffer.from("setup");
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
writeFile(rootDir, "package.json", `${JSON.stringify({
name: "real-debrid-downloader",
version: "1.7.233",
build: {
productName: "Real-Debrid-Downloader",
publish: {
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
},
files: [
"build/main/**/*",
"build/renderer/**/*",
"resources/extractor-jvm/**/*",
"LICENSE",
"package.json"
],
extraResources: [
{
from: "LICENSE",
to: "LICENSE"
}
],
nsis: {
artifactName: "${productName}-Setup-${version}.${ext}",
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true
},
portable: {
artifactName: "${productName}-${version}-portable.${ext}"
}
}
}, null, 2)}\n`);
writeFile(
rootDir,
"latest.yml",
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
);
writeFile(
rootDir,
"win-unpacked/resources/app-update.yml",
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
writeRedistributionFiles(rootDir);
writeRedistributionFiles(rootDir, true);
return rootDir;
}
afterEach(() => {
for (const rootDir of fixtureRoots.splice(0)) {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
describe("public release metadata", () => {
it("accepts the canonical GitHub release metadata and artifacts", () => {
const rootDir = createReleaseFixture();
const result = verifyPublicRelease(rootDir);
expect(result.publish).toEqual({
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
});
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
expect(result.missingArtifacts).toEqual([]);
});
it("rejects a package configured for a different GitHub owner", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
});
it("rejects a latest.yml path whose artifact does not exist", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
});
it("rejects syntactically invalid latest.yml", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "latest.yml"),
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
});
it("rejects a noncanonical files entry in latest.yml", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8").replace(
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
"url: Different-Setup-1.7.233.exe"
);
fs.writeFileSync(latestPath, latest);
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
});
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8");
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
});
it("rejects a directory in place of an artifact file", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
fs.rmSync(setupPath);
fs.mkdirSync(setupPath);
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
});
it("rejects an empty artifact file", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
});
it("rejects a release missing a declared redistribution license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
});
it("rejects a modified official license text", () => {
const rootDir = createReleaseFixture();
fs.appendFileSync(
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
"modified"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
});
it("rejects swapped third-party license assignments", () => {
const rootDir = createReleaseFixture();
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
const notice = fs.readFileSync(noticePath, "utf8")
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
fs.writeFileSync(noticePath, notice);
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
});
it("rejects a release whose unpacked application omits a license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(
rootDir,
"win-unpacked",
"resources",
"app.asar.unpacked",
"resources",
"extractor-jvm",
"licenses",
"Apache-2.0.txt"
));
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
});
it("rejects a symlink in place of a release artifact", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
const targetPath = path.join(rootDir, "setup-target.exe");
fs.renameSync(setupPath, targetPath);
fs.symlinkSync(targetPath, setupPath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects a symlink in place of an official license", () => {
const rootDir = createReleaseFixture();
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
const targetPath = path.join(rootDir, "Apache-target.txt");
fs.renameSync(licensePath, targetPath);
fs.symlinkSync(targetPath, licensePath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects build metadata that omits the project license", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
});
it("rejects build metadata that does not copy the project license into resources", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
delete packageJson.build.extraResources;
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
});
it("rejects incomplete third-party redistribution notices", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
});
it("returns a nonzero CLI status for invalid release metadata", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/owner/i);
});
it("recursively verifies redistribution files inside setup and portable archives", () => {
const rootDir = createReleaseFixture();
const result = verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner()
});
expect(result.verifiedArchives).toEqual([
"Real-Debrid-Downloader-Setup-1.7.233.exe",
"Real-Debrid-Downloader-1.7.233-portable.exe"
]);
});
it("rejects an archive whose nested application payload omits a license", () => {
const rootDir = createReleaseFixture();
expect(() => verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
});
it("exposes archive verification as a nonzero CLI gate", () => {
const rootDir = createReleaseFixture();
const result = spawnSync(process.execPath, [
verifierPath,
rootDir,
"--verify-archives",
"--seven-zip",
path.join(rootDir, "missing-7z.exe")
], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockSessionFetch,
mockClearStorageData,
mockClearCache,
mockFromPartition,
mockBrowserWindow,
mockBrowserWindowCtor,
mockExecuteJavaScript,
mockLoadURL,
mockShow,
mockFocus
} = vi.hoisted(() => {
const sessionFetch = vi.fn();
const clearStorageData = vi.fn();
const clearCache = vi.fn();
const fromPartition = vi.fn();
const executeJavaScript = vi.fn();
const loadURL = vi.fn(async () => {});
const show = vi.fn();
const focus = vi.fn();
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
let destroyed = false;
const browserWindow = {
isDestroyed: vi.fn(() => destroyed),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show,
focus,
close: vi.fn(() => {
destroyed = true;
windowEvents.closed?.();
}),
setMenuBarVisibility: vi.fn(),
loadURL,
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
windowEvents[event] = handler;
return browserWindow;
}),
webContents: {
setUserAgent: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
webContentsEvents[event] = handler;
}),
executeJavaScript
}
};
const BrowserWindowCtor = vi.fn(() => {
destroyed = false;
return browserWindow;
});
return {
mockSessionFetch: sessionFetch,
mockClearStorageData: clearStorageData,
mockClearCache: clearCache,
mockFromPartition: fromPartition,
mockBrowserWindow: browserWindow,
mockBrowserWindowCtor: BrowserWindowCtor,
mockExecuteJavaScript: executeJavaScript,
mockLoadURL: loadURL,
mockShow: show,
mockFocus: focus
};
});
vi.mock("electron", () => ({
session: {
fromPartition: mockFromPartition
},
BrowserWindow: mockBrowserWindowCtor
}));
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
describe("realdebrid-web", () => {
const mockSession = {
fetch: mockSessionFetch,
clearStorageData: mockClearStorageData,
clearCache: mockClearCache
};
beforeEach(() => {
mockFromPartition.mockReturnValue(mockSession);
mockExecuteJavaScript.mockReset();
mockLoadURL.mockClear();
mockShow.mockClear();
mockFocus.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
mockFromPartition.mockReturnValue(mockSession);
});
it("extracts private tokens from current Real-Debrid HTML patterns", () => {
expect(extractPrivateTokenFromHtml("document.querySelectorAll('input[name=private_token]')[0].value = 'abc123';"))
.toBe("abc123");
expect(extractPrivateTokenFromHtml("<input type=\"text\" name=\"private_token\" value=\"def456\">"))
.toBe("def456");
expect(extractPrivateTokenFromHtml("<input value=\"ghi789\" name=\"private_token\">"))
.toBe("ghi789");
});
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
download: "https://cdn.real-debrid.example/file.bin",
filename: "file.bin",
filesize: 12345
}), { status: 200 }));
vi.stubGlobal("fetch", apiFetch);
mockExecuteJavaScript.mockResolvedValue("token-from-window");
const fallback = new RealDebridWebFallback(() => true);
await fallback.openLoginWindow();
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
expect(result).toEqual({
directUrl: "https://cdn.real-debrid.example/file.bin",
fileName: "file.bin",
fileSize: 12345,
retriesUsed: 0
});
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
expect(mockShow).toHaveBeenCalled();
expect(mockFocus).toHaveBeenCalled();
expect(mockSessionFetch).not.toHaveBeenCalled();
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
});
});
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from "vitest";
import { RealDebridClient } from "../src/main/realdebrid";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("realdebrid client", () => {
it("returns a clear error when HTML is returned instead of JSON", async () => {
globalThis.fetch = (async (): Promise<Response> => {
return new Response("<html><title>Cloudflare</title></html>", {
status: 200,
headers: { "Content-Type": "text/html" }
});
}) as typeof fetch;
const client = new RealDebridClient("rd-token");
await expect(client.unrestrictLink("https://hoster.example/file/html")).rejects.toThrow(/html/i);
});
it("does not leak raw response body on JSON parse errors", async () => {
globalThis.fetch = (async (): Promise<Response> => {
return new Response("<html>token=secret-should-not-leak</html>", {
status: 200,
headers: { "Content-Type": "application/json" }
});
}) as typeof fetch;
const client = new RealDebridClient("rd-token");
try {
await client.unrestrictLink("https://hoster.example/file/invalid-json");
throw new Error("expected unrestrict to fail");
} catch (error) {
const text = String(error || "");
expect(text.toLowerCase()).toContain("json");
expect(text.toLowerCase()).not.toContain("secret-should-not-leak");
expect(text.toLowerCase()).not.toContain("<html>");
}
});
});
+52
View File
@@ -0,0 +1,52 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("rename-log", () => {
it("writes rename events to the rename log", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-"));
tempDirs.push(baseDir);
initRenameLog(baseDir);
logRenameEvent("INFO", "Auto-Rename durchgeführt", {
packageName: "Test Paket",
sourcePath: "C:\\extract\\old.mkv",
targetPath: "C:\\extract\\new.mkv"
});
const logPath = getRenameLogPath();
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Rename-Log Start");
expect(content).toContain("Auto-Rename durchgeführt");
expect(content).toContain("sourcePath=C:\\extract\\old.mkv");
});
it("rotates oversized rename logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-rotate-"));
tempDirs.push(baseDir);
const oversizedPath = path.join(baseDir, "rename.log");
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
initRenameLog(baseDir);
expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const content = fs.readFileSync(oversizedPath, "utf8");
expect(content).toContain("Rename-Log Start");
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { resolveArchiveItemsFromList } from "../src/main/download-manager";
type MinimalItem = {
targetPath?: string;
fileName?: string;
[key: string]: unknown;
};
function makeItems(names: string[]): MinimalItem[] {
return names.map((name) => ({
targetPath: `C:\\Downloads\\Package\\${name}`,
fileName: name,
id: name,
status: "completed",
}));
}
describe("resolveArchiveItemsFromList", () => {
it("matches multipart .part1.rar archives", () => {
const items = makeItems([
"Movie.part1.rar",
"Movie.part2.rar",
"Movie.part3.rar",
"Other.rar",
]);
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
expect(result).toHaveLength(3);
expect(result.map((i: any) => i.fileName)).toEqual([
"Movie.part1.rar",
"Movie.part2.rar",
"Movie.part3.rar",
]);
});
it("matches multipart .part01.rar archives (zero-padded)", () => {
const items = makeItems([
"Film.part01.rar",
"Film.part02.rar",
"Film.part10.rar",
"Unrelated.zip",
]);
const result = resolveArchiveItemsFromList("Film.part01.rar", items as any);
expect(result).toHaveLength(3);
});
it("matches old-style .rar + .rNN volumes", () => {
const items = makeItems([
"Archive.rar",
"Archive.r00",
"Archive.r01",
"Archive.r02",
"Other.zip",
]);
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
expect(result).toHaveLength(4);
});
it("matches a single .rar file", () => {
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
expect(result).toHaveLength(1);
expect((result[0] as any).fileName).toBe("SingleFile.rar");
});
it("matches split .zip.NNN files", () => {
const items = makeItems([
"Data.zip",
"Data.zip.001",
"Data.zip.002",
"Data.zip.003",
]);
const result = resolveArchiveItemsFromList("Data.zip.001", items as any);
expect(result).toHaveLength(4);
});
it("matches split .7z.NNN files", () => {
const items = makeItems([
"Backup.7z.001",
"Backup.7z.002",
]);
const result = resolveArchiveItemsFromList("Backup.7z.001", items as any);
expect(result).toHaveLength(2);
});
it("matches generic .NNN split files", () => {
const items = makeItems([
"video.001",
"video.002",
"video.003",
]);
const result = resolveArchiveItemsFromList("video.001", items as any);
expect(result).toHaveLength(3);
});
it("matches a single .zip by exact name", () => {
const items = makeItems(["myarchive.zip", "other.rar"]);
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
expect(result).toHaveLength(1);
expect((result[0] as any).fileName).toBe("myarchive.zip");
});
it("matches case-insensitively", () => {
const items = makeItems([
"MOVIE.PART1.RAR",
"MOVIE.PART2.RAR",
]);
const result = resolveArchiveItemsFromList("movie.part1.rar", items as any);
expect(result).toHaveLength(2);
});
it("uses stem-based fallback when exact patterns fail", () => {
const items = makeItems([
"Movie.rar",
]);
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
expect(result).toHaveLength(1);
});
it("returns single archive item when no pattern matches", () => {
const items = makeItems(["totally-different-name.rar"]);
const result = resolveArchiveItemsFromList("Original.rar", items as any);
expect(result).toHaveLength(1);
});
it("returns empty when items have no archive extensions", () => {
const items = makeItems(["video.mkv", "subtitle.srt"]);
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
expect(result).toHaveLength(0);
});
it("falls back to fileName when targetPath is missing", () => {
const items = [
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
{ fileName: "Movie.part2.rar", id: "2", status: "completed" },
];
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
expect(result).toHaveLength(2);
});
it("does not cross-match different archive groups", () => {
const items = makeItems([
"Episode.S01E01.part1.rar",
"Episode.S01E01.part2.rar",
"Episode.S01E02.part1.rar",
"Episode.S01E02.part2.rar",
]);
const result1 = resolveArchiveItemsFromList("Episode.S01E01.part1.rar", items as any);
expect(result1).toHaveLength(2);
expect(result1.every((i: any) => i.fileName.includes("S01E01"))).toBe(true);
const result2 = resolveArchiveItemsFromList("Episode.S01E02.part1.rar", items as any);
expect(result2).toHaveLength(2);
expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { pruneSelection } from "../src/renderer/selection";
import type { SessionState } from "../src/shared/types";
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
const packages: Record<string, never> = {};
const items: Record<string, never> = {};
for (const id of packageIds) packages[id] = {} as never;
for (const id of itemIds) items[id] = {} as never;
return { packages, items };
}
describe("pruneSelection", () => {
it("drops ids whose package/item no longer exists", () => {
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect([...next].sort()).toEqual(["i1", "p1"]);
});
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect(next).toBe(sel);
});
it("returns the same instance for an empty selection", () => {
const sel = new Set<string>();
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
});
it("prunes everything when the whole session was swapped out", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session([], []));
expect(next.size).toBe(0);
expect(next).not.toBe(sel);
});
it("keeps a mixed package+item selection when both survive", () => {
const sel = new Set(["p1", "p2", "i1"]);
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
expect(next).toBe(sel); // unchanged → same instance
});
});
+208
View File
@@ -0,0 +1,208 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { once } from "node:events";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(`Self-check fehlgeschlagen: ${message}`);
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("Timeout während Self-check");
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
async function runDownloadCase(baseDir: string, baseUrl: string, url: string, options?: Partial<ReturnType<typeof defaultSettings>>): Promise<DownloadManager> {
const settings = {
...defaultSettings(),
token: "demo-token",
outputDir: path.join(baseDir, "downloads"),
extractDir: path.join(baseDir, "extract"),
autoExtract: false,
autoReconnect: true,
reconnectWaitSeconds: 1,
...options
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(baseDir, "state")));
manager.addPackages([
{
name: "test-package",
links: [url]
}
]);
manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 30000);
return manager;
}
async function main(): Promise<void> {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-node-self-"));
const binary = Buffer.alloc(512 * 1024, 7);
let flakyFailures = 1;
const server = http.createServer((req, res) => {
const url = req.url || "/";
if (url.startsWith("/file.bin") || url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin") || url.startsWith("/flaky.bin")) {
if (url.startsWith("/flaky.bin") && flakyFailures > 0) {
flakyFailures -= 1;
res.statusCode = 503;
res.end("retry");
return;
}
const range = req.headers.range;
let start = 0;
if (range) {
const match = String(range).match(/bytes=(\d+)-/i);
if (match) {
start = Number(match[1]);
}
}
const chunk = binary.subarray(start);
if (start > 0) {
res.statusCode = 206;
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
}
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", chunk.length);
res.statusCode = res.statusCode || 200;
if (url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin")) {
const mid = Math.floor(chunk.length / 2);
res.write(chunk.subarray(0, mid));
setTimeout(() => {
res.end(chunk.subarray(mid));
}, 400);
return;
}
res.end(chunk);
return;
}
res.statusCode = 404;
res.end("not-found");
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Server konnte nicht gestartet werden");
}
const baseUrl = `http://127.0.0.1:${address.port}`;
const originalFetch = globalThis.fetch;
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("/unrestrict/link")) {
const body = init?.body;
const params = body instanceof URLSearchParams ? body : new URLSearchParams(String(body || ""));
const link = params.get("link") || "";
const filename = link.includes("rarcancel") ? "release.part1.rar" : "file.bin";
const direct = link.includes("slow")
? `${baseUrl}/slow.bin`
: link.includes("rarcancel")
? `${baseUrl}/rarcancel.bin`
: link.includes("flaky")
? `${baseUrl}/flaky.bin`
: `${baseUrl}/file.bin`;
return new Response(
JSON.stringify({
download: direct,
filename,
filesize: binary.length
}),
{
status: 200,
headers: { "Content-Type": "application/json" }
}
);
}
return originalFetch(input, init);
};
try {
const manager1 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/file");
const snapshot1 = manager1.getSnapshot();
const item1 = Object.values(snapshot1.session.items)[0];
assert(item1?.status === "completed", "normaler Download wurde nicht abgeschlossen");
assert(fs.existsSync(item1.targetPath), "Datei fehlt nach Download");
const manager2 = new DownloadManager(
{
...defaultSettings(),
token: "demo-token",
outputDir: path.join(tempRoot, "downloads-pause"),
extractDir: path.join(tempRoot, "extract-pause"),
autoExtract: false,
autoReconnect: false
},
emptySession(),
createStoragePaths(path.join(tempRoot, "state-pause"))
);
manager2.addPackages([{ name: "pause", links: ["https://dummy/slow"] }]);
await manager2.start();
await new Promise((resolve) => setTimeout(resolve, 120));
const paused = manager2.togglePause();
assert(paused, "Pause konnte nicht aktiviert werden");
await new Promise((resolve) => setTimeout(resolve, 150));
manager2.togglePause();
await waitFor(() => !manager2.getSnapshot().session.running, 30000);
const item2 = Object.values(manager2.getSnapshot().session.items)[0];
assert(item2?.status === "completed", "Pause/Resume Download nicht abgeschlossen");
const manager3 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/flaky", { autoReconnect: true, reconnectWaitSeconds: 1 });
const item3 = Object.values(manager3.getSnapshot().session.items)[0];
assert(item3?.status === "completed", "Reconnect-Fall nicht abgeschlossen");
const manager4 = new DownloadManager(
{
...defaultSettings(),
token: "demo-token",
outputDir: path.join(tempRoot, "downloads-cancel"),
extractDir: path.join(tempRoot, "extract-cancel"),
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(tempRoot, "state-cancel"))
);
manager4.addPackages([{ name: "cancel", links: ["https://dummy/rarcancel"] }]);
manager4.start();
await new Promise((resolve) => setTimeout(resolve, 150));
const pkgId = manager4.getSnapshot().session.packageOrder[0];
manager4.cancelPackage(pkgId);
await waitFor(() => !manager4.getSnapshot().session.running || Object.values(manager4.getSnapshot().session.items).every((item) => item.status !== "downloading"), 15000);
const cancelSnapshot = manager4.getSnapshot();
const remainingItems = Object.values(cancelSnapshot.session.items);
if (remainingItems.length === 0) {
assert(cancelSnapshot.session.packageOrder.length === 0, "Abgebrochenes Paket wurde nicht entfernt");
} else {
const cancelItem = remainingItems[0];
assert(cancelItem?.status === "cancelled" || cancelItem?.status === "queued", "Paketabbruch nicht wirksam");
}
const packageDir = path.join(path.join(tempRoot, "downloads-cancel"), "cancel");
const cancelArtifact = path.join(packageDir, "release.part1.rar");
await waitFor(() => !fs.existsSync(cancelArtifact), 10000);
assert(!fs.existsSync(cancelArtifact), "RAR-Artefakt wurde nicht gelöscht");
console.log("Node self-check erfolgreich");
} finally {
globalThis.fetch = originalFetch;
server.close();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
void main();
+151
View File
@@ -0,0 +1,151 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "../src/main/session-log";
import { setLogListener } from "../src/main/logger";
const tempDirs: string[] = [];
afterEach(() => {
shutdownSessionLog();
setLogListener(null);
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("session-log", () => {
it("initSessionLog creates directory and file", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
initSessionLog(baseDir);
const logPath = getSessionLogPath();
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
expect(fs.existsSync(path.join(baseDir, "session-logs"))).toBe(true);
expect(path.basename(logPath!)).toMatch(/^session_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.txt$/);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("=== Session gestartet:");
shutdownSessionLog();
});
it("logger listener writes to session log", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
initSessionLog(baseDir);
const logPath = getSessionLogPath()!;
const { logger } = await import("../src/main/logger");
logger.info("Test-Nachricht für Session-Log");
await new Promise((resolve) => setTimeout(resolve, 500));
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("Test-Nachricht für Session-Log");
shutdownSessionLog();
});
it("shutdownSessionLog writes closing line", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
initSessionLog(baseDir);
const logPath = getSessionLogPath()!;
shutdownSessionLog();
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("=== Session beendet:");
});
it("shutdownSessionLog removes listener", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
initSessionLog(baseDir);
const logPath = getSessionLogPath()!;
shutdownSessionLog();
const { logger } = await import("../src/main/logger");
logger.info("Nach-Shutdown-Nachricht");
await new Promise((resolve) => setTimeout(resolve, 500));
const content = fs.readFileSync(logPath, "utf8");
expect(content).not.toContain("Nach-Shutdown-Nachricht");
});
it("cleanupOldSessionLogs deletes old files", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true });
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
fs.writeFileSync(oldFile, "old session");
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
fs.utimesSync(oldFile, oldTime, oldTime);
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
fs.writeFileSync(newFile, "new session");
initSessionLog(baseDir);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(fs.existsSync(oldFile)).toBe(false);
expect(fs.existsSync(newFile)).toBe(true);
shutdownSessionLog();
});
it("cleanupOldSessionLogs keeps recent files", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true });
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
fs.writeFileSync(recentFile, "recent session");
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
fs.utimesSync(recentFile, recentTime, recentTime);
initSessionLog(baseDir);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(fs.existsSync(recentFile)).toBe(true);
shutdownSessionLog();
});
it("multiple sessions create different files", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
initSessionLog(baseDir);
const path1 = getSessionLogPath();
shutdownSessionLog();
await new Promise((resolve) => setTimeout(resolve, 1100));
initSessionLog(baseDir);
const path2 = getSessionLogPath();
shutdownSessionLog();
expect(path1).not.toBeNull();
expect(path2).not.toBeNull();
expect(path1).not.toBe(path2);
expect(fs.existsSync(path1!)).toBe(true);
expect(fs.existsSync(path2!)).toBe(true);
});
});
Binary file not shown.
+134
View File
@@ -0,0 +1,134 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths } from "../src/main/storage";
import { runStartupHealthCheck } from "../src/main/startup-health-check";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
function makeTempBase(): { baseDir: string; outputDir: string; paths: ReturnType<typeof createStoragePaths> } {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-"));
tempDirs.push(baseDir);
const outputDir = path.join(baseDir, "downloads");
fs.mkdirSync(outputDir, { recursive: true });
return {
baseDir: path.join(baseDir, "runtime"),
outputDir,
paths: createStoragePaths(path.join(baseDir, "runtime"))
};
}
describe("runStartupHealthCheck", () => {
it("flags missing download directory", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "rd-token",
outputDir: path.join(outputDir, "does-not-exist-subdir")
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("outputDir_not_found");
});
it("flags no-provider-configured when all credentials are empty", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "",
megaLogin: "",
megaPassword: "",
megaCredentials: "",
allDebridToken: "",
bestToken: "",
oneFichierApiKey: "",
debridLinkApiKeys: "",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("no_provider_configured");
expect(report.warnCount).toBeGreaterThanOrEqual(1);
});
it("reports configured providers when at least one credential is set", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "rd-token-here",
debridLinkApiKeys: "dl-key-a\ndl-key-b",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const providersFinding = report.findings.find((f) => f.code === "providers_configured");
expect(providersFinding).toBeDefined();
expect(providersFinding?.message).toContain("Real-Debrid");
expect(providersFinding?.message).toContain("Debrid-Link");
expect(providersFinding?.message).toContain("2 Keys");
});
it("flags large state files", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
const settings = {
...defaultSettings(),
token: "rd-token",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("large_state_file");
});
it("flags missing base dir as ERROR", () => {
const { outputDir, paths } = makeTempBase();
const settings = {
...defaultSettings(),
token: "rd-token",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("baseDir_missing");
expect(report.errorCount).toBeGreaterThanOrEqual(1);
});
it("passes cleanly when everything is healthy", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "rd-token-here",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
expect(report.errorCount).toBe(0);
const codes = report.findings.map((f) => f.code);
expect(codes).not.toContain("outputDir_not_found");
expect(codes).not.toContain("outputDir_not_writable");
expect(codes).not.toContain("no_provider_configured");
expect(codes).not.toContain("baseDir_missing");
expect(codes).not.toContain("baseDir_not_writable");
});
});
+867
View File
@@ -0,0 +1,867 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("settings storage", () => {
it("does not persist provider credentials when rememberToken is disabled", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSettings(paths, {
...defaultSettings(),
rememberToken: false,
token: "rd-token",
megaLogin: "mega-user",
megaPassword: "mega-pass",
bestToken: "best-token",
allDebridToken: "all-token"
});
const raw = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record<string, unknown>;
expect(raw.token).toBe("");
expect(raw.megaLogin).toBe("");
expect(raw.megaPassword).toBe("");
expect(raw.bestToken).toBe("");
expect(raw.allDebridToken).toBe("");
const loaded = loadSettings(paths);
expect(loaded.rememberToken).toBe(false);
expect(loaded.token).toBe("");
expect(loaded.megaLogin).toBe("");
expect(loaded.megaPassword).toBe("");
expect(loaded.bestToken).toBe("");
expect(loaded.allDebridToken).toBe("");
});
it("persists provider credentials when rememberToken is enabled", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSettings(paths, {
...defaultSettings(),
rememberToken: true,
token: "rd-token",
megaLogin: "mega-user",
megaPassword: "mega-pass",
bestToken: "best-token",
allDebridToken: "all-token"
});
const loaded = loadSettings(paths);
expect(loaded.token).toBe("rd-token");
expect(loaded.megaLogin).toBe("mega-user");
expect(loaded.megaPassword).toBe("mega-pass");
expect(loaded.bestToken).toBe("best-token");
expect(loaded.allDebridToken).toBe("all-token");
});
it("normalizes invalid enum and numeric values", () => {
const normalized = normalizeSettings({
...defaultSettings(),
providerPrimary: "invalid-provider" as unknown as AppSettings["providerPrimary"],
providerSecondary: "invalid-provider" as unknown as AppSettings["providerSecondary"],
providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"],
cleanupMode: "broken" as unknown as AppSettings["cleanupMode"],
extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"],
completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"],
speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"],
maxParallel: 0,
retryLimit: 999,
reconnectWaitSeconds: 9999,
speedLimitKbps: -1,
outputDir: " ",
extractDir: " ",
mkvLibraryDir: " ",
updateRepo: " "
});
expect(normalized.providerPrimary).toBe("realdebrid");
expect(normalized.providerSecondary).toBe("none");
expect(normalized.providerTertiary).toBe("none");
expect(normalized.cleanupMode).toBe("none");
expect(normalized.extractConflictMode).toBe("overwrite");
expect(normalized.completedCleanupPolicy).toBe("never");
expect(normalized.speedLimitMode).toBe("global");
expect(normalized.maxParallel).toBe(1);
expect(normalized.retryLimit).toBe(99);
expect(normalized.reconnectWaitSeconds).toBe(600);
expect(normalized.speedLimitKbps).toBe(0);
expect(normalized.outputDir).toBe(defaultSettings().outputDir);
expect(normalized.extractDir).toBe(defaultSettings().extractDir);
expect(normalized.mkvLibraryDir).toBe(defaultSettings().mkvLibraryDir);
expect(normalized.updateRepo).toBe(defaultSettings().updateRepo);
});
it("normalizes malformed persisted config on load", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(
paths.configFile,
JSON.stringify({
providerPrimary: "not-valid",
completedCleanupPolicy: "not-valid",
maxParallel: "999",
retryLimit: "-3",
reconnectWaitSeconds: "1",
speedLimitMode: "not-valid",
updateRepo: "",
autoSortPackagesByProgress: false
}),
"utf8"
);
const loaded = loadSettings(paths);
expect(loaded.providerPrimary).toBe("realdebrid");
expect(loaded.completedCleanupPolicy).toBe("never");
expect(loaded.maxParallel).toBe(50);
expect(loaded.retryLimit).toBe(0);
expect(loaded.reconnectWaitSeconds).toBe(10);
expect(loaded.speedLimitMode).toBe("global");
expect(loaded.updateRepo).toBe(defaultSettings().updateRepo);
expect(loaded.autoSortPackagesByProgress).toBe(false);
});
it("keeps explicit none as fallback provider choice", () => {
const normalized = normalizeSettings({
...defaultSettings(),
providerSecondary: "none",
providerTertiary: "none"
});
expect(normalized.providerSecondary).toBe("none");
expect(normalized.providerTertiary).toBe("none");
});
it("migrates legacy MegaDebrid provider selections to explicit API/Web providers", () => {
const apiNormalized = normalizeSettings({
...defaultSettings(),
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: true,
providerPrimary: "megadebrid" as unknown as AppSettings["providerPrimary"],
providerSecondary: "megadebrid" as unknown as AppSettings["providerSecondary"],
disabledProviders: ["megadebrid" as unknown as AppSettings["providerPrimary"]]
});
expect(apiNormalized.providerPrimary).toBe("megadebrid-api");
expect(apiNormalized.providerSecondary).toBe("none");
expect(apiNormalized.disabledProviders).toEqual(["megadebrid-api", "megadebrid-web"]);
const webNormalized = normalizeSettings({
...defaultSettings(),
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: false,
megaDebridApiEnabled: false,
megaDebridWebEnabled: true,
providerPrimary: "megadebrid" as unknown as AppSettings["providerPrimary"],
hosterRouting: { rapidgator: "megadebrid" as unknown as AppSettings["providerPrimary"] }
});
expect(webNormalized.providerPrimary).toBe("megadebrid-web");
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
});
it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const legacyApi = {
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: true,
providerPrimary: "realdebrid",
providerSecondary: "megadebrid"
};
fs.writeFileSync(paths.configFile, JSON.stringify(legacyApi), "utf8");
const loadedApi = loadSettings(paths);
expect(loadedApi.megaDebridApiEnabled).toBe(true);
expect(loadedApi.megaDebridWebEnabled).toBe(false);
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir2);
const paths2 = createStoragePaths(dir2);
const legacyWeb = {
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: false,
providerPrimary: "realdebrid",
providerSecondary: "megadebrid"
};
fs.writeFileSync(paths2.configFile, JSON.stringify(legacyWeb), "utf8");
const loadedWeb = loadSettings(paths2);
expect(loadedWeb.megaDebridApiEnabled).toBe(false);
expect(loadedWeb.megaDebridWebEnabled).toBe(true);
});
it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const deliberatelyDisabled = {
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: true,
megaDebridApiEnabled: false,
megaDebridWebEnabled: false
};
fs.writeFileSync(paths.configFile, JSON.stringify(deliberatelyDisabled), "utf8");
const loaded = loadSettings(paths);
expect(loaded.megaDebridApiEnabled).toBe(false);
expect(loaded.megaDebridWebEnabled).toBe(false);
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir2);
const paths2 = createStoragePaths(dir2);
const noCreds = {
megaDebridPreferApi: true,
providerPrimary: "realdebrid"
};
fs.writeFileSync(paths2.configFile, JSON.stringify(noCreds), "utf8");
const loadedNoCreds = loadSettings(paths2);
expect(loadedNoCreds.megaDebridApiEnabled).toBe(false);
expect(loadedNoCreds.megaDebridWebEnabled).toBe(false);
});
it("normalizes provider daily limits and resets stale daily usage", () => {
const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one");
const normalized = normalizeSettings({
...defaultSettings(),
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridApiEnabled: true,
debridLinkApiKeys: "dl-key-one",
providerDailyLimitBytes: {
realdebrid: 1024,
megadebrid: 2048
} as AppSettings["providerDailyLimitBytes"],
providerTotalUsageBytes: {
realdebrid: 16384,
megadebrid: 32768
} as AppSettings["providerTotalUsageBytes"],
debridLinkApiKeyDailyLimitBytes: {
[debridLinkKey.id]: 3072,
stale: 1234
},
providerDailyUsageDay: "2001-01-01",
providerDailyUsageBytes: {
realdebrid: 4096,
megadebrid: 8192
} as AppSettings["providerDailyUsageBytes"],
debridLinkApiKeyDailyUsageBytes: {
[debridLinkKey.id]: 8192,
stale: 9999
},
debridLinkApiKeyTotalUsageBytes: {
[debridLinkKey.id]: 12288,
stale: 9999
}
});
expect(normalized.providerDailyLimitBytes.realdebrid).toBe(1024);
expect(normalized.providerDailyLimitBytes["megadebrid-api"]).toBe(2048);
expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({
[debridLinkKey.id]: 3072
});
expect(normalized.providerTotalUsageBytes).toEqual({
realdebrid: 16384,
"megadebrid-api": 32768
});
expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey());
expect(normalized.providerDailyUsageBytes).toEqual({});
expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({});
expect(normalized.debridLinkApiKeyTotalUsageBytes).toEqual({
[debridLinkKey.id]: 12288
});
});
it("normalizes archive password list line endings", () => {
const normalized = normalizeSettings({
...defaultSettings(),
archivePasswordList: "one\r\ntwo\r\nthree"
});
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
});
it("defaults Real-Debrid web login to disabled and normalizes the flag", () => {
expect(defaultSettings().realDebridUseWebLogin).toBe(false);
const normalizedEnabled = normalizeSettings({
...defaultSettings(),
realDebridUseWebLogin: 1 as unknown as boolean
});
expect(normalizedEnabled.realDebridUseWebLogin).toBe(true);
const normalizedDisabled = normalizeSettings({
...defaultSettings(),
realDebridUseWebLogin: 0 as unknown as boolean
});
expect(normalizedDisabled.realDebridUseWebLogin).toBe(false);
});
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
const normalizedEnabled = normalizeSettings({
...defaultSettings(),
allDebridUseWebLogin: 1 as unknown as boolean
});
expect(normalizedEnabled.allDebridUseWebLogin).toBe(true);
const normalizedDisabled = normalizeSettings({
...defaultSettings(),
allDebridUseWebLogin: 0 as unknown as boolean
});
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
});
it("defaults history retention to permanent and normalizes invalid values", () => {
expect(defaultSettings().historyRetentionMode).toBe("permanent");
const normalized = normalizeSettings({
...defaultSettings(),
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
});
expect(normalized.historyRetentionMode).toBe("permanent");
});
it("skips adding persisted history entries when history retention is never", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const result = addHistoryEntryForRetention(paths, "never", {
id: "hist-1",
name: "ignored",
totalBytes: 1024,
downloadedBytes: 1024,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 12,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file.rar"]
});
expect(result).toEqual([]);
expect(loadHistory(paths)).toEqual([]);
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
});
it("clears persisted history for session retention mode", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{
id: "hist-2",
name: "kept",
totalBytes: 2048,
downloadedBytes: 2048,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 20,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file2.rar"]
}]);
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
});
it("caps persisted history to the configured maxEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const now = Date.now();
const entries = Array.from({ length: 10 }, (_unused, i) => ({
id: `h-${i}`,
name: `e${i}`,
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: now - i * 1000,
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
}));
saveHistory(paths, entries, { maxEntries: 3, maxAgeDays: 0 });
const loaded = loadHistory(paths, { maxEntries: 3, maxAgeDays: 0 });
expect(loaded).toHaveLength(3);
expect(loaded.map((e) => e.id)).toEqual(["h-0", "h-1", "h-2"]);
});
it("drops history entries older than maxAgeDays", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
const fresh = {
id: "fresh",
name: "fresh",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: now - 2 * day,
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
const old = { ...fresh, id: "old", name: "old", completedAt: now - 40 * day };
saveHistory(paths, [fresh, old], { maxEntries: 500, maxAgeDays: 30 });
const loaded = loadHistory(paths, { maxEntries: 500, maxAgeDays: 30 });
expect(loaded.map((e) => e.id)).toEqual(["fresh"]);
});
it("assigns and preserves bandwidth schedule ids", () => {
const normalized = normalizeSettings({
...defaultSettings(),
bandwidthSchedules: [{ id: "", startHour: 1, endHour: 6, speedLimitKbps: 1024, enabled: true }]
});
const generatedId = normalized.bandwidthSchedules[0]?.id;
expect(typeof generatedId).toBe("string");
expect(generatedId?.length).toBeGreaterThan(0);
const normalizedAgain = normalizeSettings({
...defaultSettings(),
bandwidthSchedules: normalized.bandwidthSchedules
});
expect(normalizedAgain.bandwidthSchedules[0]?.id).toBe(generatedId);
});
it("resets stale active statuses to queued on session load", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const session = emptySession();
session.packages["pkg1"] = {
id: "pkg1",
name: "Test Package",
outputDir: "/tmp/out",
extractDir: "/tmp/extract",
status: "downloading",
itemIds: ["item1", "item2", "item3", "item4"],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: Date.now(),
updatedAt: Date.now()
};
session.items["item1"] = {
id: "item1",
packageId: "pkg1",
url: "https://example.com/file1.rar",
provider: null,
status: "downloading",
retries: 0,
speedBps: 1024,
downloadedBytes: 5000,
totalBytes: 10000,
progressPercent: 50,
fileName: "file1.rar",
targetPath: "/tmp/out/file1.rar",
resumable: true,
attempts: 1,
lastError: "some error",
fullStatus: "",
createdAt: Date.now(),
updatedAt: Date.now()
};
session.items["item2"] = {
id: "item2",
packageId: "pkg1",
url: "https://example.com/file2.rar",
provider: null,
status: "paused",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "file2.rar",
targetPath: "/tmp/out/file2.rar",
resumable: false,
attempts: 0,
lastError: "",
fullStatus: "",
createdAt: Date.now(),
updatedAt: Date.now()
};
session.items["item3"] = {
id: "item3",
packageId: "pkg1",
url: "https://example.com/file3.rar",
provider: null,
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 10000,
totalBytes: 10000,
progressPercent: 100,
fileName: "file3.rar",
targetPath: "/tmp/out/file3.rar",
resumable: false,
attempts: 1,
lastError: "",
fullStatus: "",
createdAt: Date.now(),
updatedAt: Date.now()
};
session.items["item4"] = {
id: "item4",
packageId: "pkg1",
url: "https://example.com/file4.rar",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "file4.rar",
targetPath: "/tmp/out/file4.rar",
resumable: false,
attempts: 0,
lastError: "",
fullStatus: "",
createdAt: Date.now(),
updatedAt: Date.now()
};
saveSession(paths, session);
const loaded = loadSession(paths);
expect(loaded.items["item1"].status).toBe("queued");
expect(loaded.items["item2"].status).toBe("queued");
expect(loaded.items["item1"].speedBps).toBe(0);
expect(loaded.items["item1"].lastError).toBe("");
expect(loaded.items["item3"].status).toBe("completed");
expect(loaded.items["item4"].status).toBe("queued");
expect(loaded.items["item1"].downloadedBytes).toBe(5000);
expect(loaded.packages["pkg1"].name).toBe("Test Package");
});
it("returns empty session when session file contains invalid JSON", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, "{{{corrupted json!!!", "utf8");
const loaded = loadSession(paths);
const empty = emptySession();
expect(loaded.packages).toEqual(empty.packages);
expect(loaded.items).toEqual(empty.items);
expect(loaded.packageOrder).toEqual(empty.packageOrder);
});
it("loads backup session when primary session is corrupted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const backupSession = emptySession();
backupSession.packageOrder = ["pkg-backup"];
backupSession.packages["pkg-backup"] = {
id: "pkg-backup",
name: "Backup Package",
outputDir: path.join(dir, "out"),
extractDir: path.join(dir, "extract"),
status: "queued",
itemIds: ["item-backup"],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: Date.now(),
updatedAt: Date.now()
};
backupSession.items["item-backup"] = {
id: "item-backup",
packageId: "pkg-backup",
url: "https://example.com/backup-file",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "backup-file.rar",
targetPath: path.join(dir, "out", "backup-file.rar"),
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: Date.now(),
updatedAt: Date.now()
};
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(backupSession), "utf8");
fs.writeFileSync(paths.sessionFile, "{broken-session-json", "utf8");
const loaded = loadSession(paths);
expect(loaded.packageOrder).toEqual(["pkg-backup"]);
expect(loaded.packages["pkg-backup"]?.name).toBe("Backup Package");
expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar");
const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record<string, unknown> };
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
});
it("returns defaults when config file contains invalid JSON", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8");
const loaded = loadSettings(paths);
const defaults = defaultSettings();
expect(loaded.providerPrimary).toBe(defaults.providerPrimary);
expect(loaded.maxParallel).toBe(defaults.maxParallel);
expect(loaded.retryLimit).toBe(defaults.retryLimit);
expect(loaded.outputDir).toBe(defaults.outputDir);
expect(loaded.cleanupMode).toBe(defaults.cleanupMode);
});
it("loads backup config when primary config is corrupted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const backupSettings = {
...defaultSettings(),
outputDir: path.join(dir, "backup-output"),
packageName: "from-backup"
};
fs.writeFileSync(`${paths.configFile}.bak`, JSON.stringify(backupSettings, null, 2), "utf8");
fs.writeFileSync(paths.configFile, "{broken-json", "utf8");
const loaded = loadSettings(paths);
expect(loaded.outputDir).toBe(backupSettings.outputDir);
expect(loaded.packageName).toBe("from-backup");
});
it("sanitizes malformed persisted session structures", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, JSON.stringify({
version: "invalid",
packageOrder: [123, "pkg-valid"],
packages: {
"1": "bad-entry",
"pkg-valid": {
id: "pkg-valid",
name: "Valid Package",
outputDir: "C:/tmp/out",
extractDir: "C:/tmp/extract",
status: "downloading",
itemIds: ["item-valid", 123],
cancelled: false,
enabled: true
}
},
items: {
"item-valid": {
id: "item-valid",
packageId: "pkg-valid",
url: "https://example.com/file",
status: "queued",
fileName: "file.bin",
targetPath: "C:/tmp/out/file.bin"
},
"item-bad": "broken"
}
}), "utf8");
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual(["pkg-valid"]);
expect(Object.keys(loaded.items)).toEqual(["item-valid"]);
expect(loaded.packageOrder).toEqual(["pkg-valid"]);
});
it("drops unsafe session ids and target paths outside the package output directory", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const outputDir = path.join(dir, "downloads", "safe");
const safeTargetPath = path.join(outputDir, "safe.bin");
const outsideTargetPath = path.join(dir, "outside.bin");
fs.writeFileSync(paths.sessionFile, JSON.stringify({
version: 2,
packageOrder: ["pkg-safe", "../pkg-evil"],
packages: {
"pkg-safe": {
id: "pkg-safe",
name: "Safe Package",
outputDir,
extractDir: path.join(dir, "extract", "safe"),
status: "queued",
itemIds: ["item-safe", "item-outside", "../item-evil"],
cancelled: false,
enabled: true
},
"../pkg-evil": {
id: "../pkg-evil",
name: "Unsafe Package",
outputDir,
extractDir: path.join(dir, "extract", "unsafe"),
status: "queued",
itemIds: ["item-evil"],
cancelled: false,
enabled: true
}
},
items: {
"item-safe": {
id: "item-safe",
packageId: "pkg-safe",
url: "https://example.com/safe",
status: "queued",
fileName: "safe.bin",
targetPath: safeTargetPath
},
"item-outside": {
id: "item-outside",
packageId: "pkg-safe",
url: "https://example.com/outside",
status: "queued",
fileName: "outside.bin",
targetPath: outsideTargetPath
},
"../item-evil": {
id: "../item-evil",
packageId: "pkg-safe",
url: "https://example.com/evil",
status: "queued",
fileName: "evil.bin",
targetPath: safeTargetPath
}
}
}), "utf8");
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual(["pkg-safe"]);
expect(Object.keys(loaded.items).sort()).toEqual(["item-outside", "item-safe"]);
expect(loaded.packageOrder).toEqual(["pkg-safe"]);
expect(path.resolve(loaded.items["item-safe"]?.targetPath || "")).toBe(path.resolve(safeTargetPath));
expect(loaded.items["item-outside"]?.targetPath).toBe("");
});
it("captures async session save payload before later mutations", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const session = emptySession();
session.summaryText = "before-mutation";
const pending = saveSessionAsync(paths, session);
session.summaryText = "after-mutation";
await pending;
const persisted = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText: string };
expect(persisted.summaryText).toBe("before-mutation");
});
it("creates session backup before sync and async session overwrites", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const first = emptySession();
first.summaryText = "first";
saveSession(paths, first);
const second = emptySession();
second.summaryText = "second";
saveSession(paths, second);
const backupAfterSync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string };
expect(backupAfterSync.summaryText).toBe("first");
const third = emptySession();
third.summaryText = "third";
await saveSessionAsync(paths, third);
const backupAfterAsync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string };
const primaryAfterAsync = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText?: string };
expect(backupAfterAsync.summaryText).toBe("second");
expect(primaryAfterAsync.summaryText).toBe("third");
});
it("applies defaults for missing fields when loading old config", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(
paths.configFile,
JSON.stringify({
token: "my-token",
rememberToken: true,
outputDir: "/custom/output"
}),
"utf8"
);
const loaded = loadSettings(paths);
const defaults = defaultSettings();
expect(loaded.token).toBe("my-token");
expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
expect(loaded.hybridExtract).toBe(defaults.hybridExtract);
expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy);
expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode);
expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch);
expect(loaded.minimizeToTray).toBe(defaults.minimizeToTray);
expect(loaded.retryLimit).toBe(defaults.retryLimit);
expect(loaded.collectMkvToLibrary).toBe(defaults.collectMkvToLibrary);
expect(loaded.mkvLibraryDir).toBe(defaults.mkvLibraryDir);
expect(loaded.theme).toBe(defaults.theme);
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
expect(loaded.updateRepo).toBe(defaults.updateRepo);
});
});
+74
View File
@@ -0,0 +1,74 @@
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";
const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
}
});
function fakeManager(): DownloadManager {
const snapshot = {
stats: {},
session: { packages: {}, items: {}, packageOrder: [] },
speedText: "",
etaText: "",
canStart: false,
canStop: false,
canPause: false
};
return {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} 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 () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ purpose: "support" }), "utf8");
fs.writeFileSync(path.join(root, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
expect(promise).toBeInstanceOf(Promise);
const buffer = await promise;
expect(Buffer.isBuffer(buffer)).toBe(true);
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/settings.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");
});
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);
let timerFired = false;
const timer = setTimeout(() => { timerFired = true; }, 0);
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true);
});
});
+90
View File
@@ -0,0 +1,90 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { configureLogger, logger } from "../src/main/logger";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownSessionLog();
shutdownTraceLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("trace-log", () => {
it("captures main log lines and explicit trace events when enabled", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-"));
tempDirs.push(baseDir);
configureLogger(baseDir);
initTraceLog(baseDir);
initSessionLog(baseDir);
setTraceEnabled(true, "test");
logger.info("TRACE-MAIN-CAPTURE");
logTraceEvent("INFO", "audit", "TRACE-AUDIT-CAPTURE", { source: "test" });
await new Promise((resolve) => setTimeout(resolve, 350));
const traceLogPath = getTraceLogPath();
const sessionLogPath = getSessionLogPath();
const traceConfigPath = getTraceConfigPath();
expect(traceLogPath).not.toBeNull();
expect(sessionLogPath).not.toBeNull();
expect(traceConfigPath).not.toBeNull();
const traceContent = fs.readFileSync(traceLogPath!, "utf8");
expect(traceContent).toContain("Trace-Log Start");
expect(traceContent).toContain("TRACE-MAIN-CAPTURE");
expect(traceContent).toContain("TRACE-AUDIT-CAPTURE");
const sessionContent = fs.readFileSync(sessionLogPath!, "utf8");
expect(sessionContent).toContain("TRACE-MAIN-CAPTURE");
const traceConfig = getTraceConfig();
expect(traceConfig.enabled).toBe(true);
expect(traceConfig.autoDisableAt).toBeTruthy();
expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true);
});
it("auto-disables support trace after the requested duration", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));
tempDirs.push(baseDir);
configureLogger(baseDir);
initTraceLog(baseDir);
setTraceEnabled(true, "expire-test", 50);
await new Promise((resolve) => setTimeout(resolve, 350));
const traceConfig = getTraceConfig();
expect(traceConfig.enabled).toBe(false);
expect(traceConfig.autoDisableAt).toBeNull();
const traceLogPath = getTraceLogPath();
expect(traceLogPath).not.toBeNull();
const traceContent = fs.readFileSync(traceLogPath!, "utf8");
expect(traceContent).toContain("Support-Trace automatisch deaktiviert");
});
it("rotates oversized trace logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-rotate-"));
tempDirs.push(baseDir);
const oversizedPath = path.join(baseDir, "trace.log");
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
initTraceLog(baseDir);
expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const currentContent = fs.readFileSync(oversizedPath, "utf8");
expect(currentContent).toContain("Trace-Log Start");
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
expect(transientResolveRetryDelayMs(1)).toBeLessThanOrEqual(3000);
});
it("ramps gently and caps at 10s", () => {
expect(transientResolveRetryDelayMs(2)).toBeLessThanOrEqual(7000);
expect(transientResolveRetryDelayMs(3)).toBeLessThanOrEqual(10000);
expect(transientResolveRetryDelayMs(10)).toBe(10000);
expect(transientResolveRetryDelayMs(100)).toBe(10000);
});
it("never schedules anywhere near the 5s..120s exponential cap", () => {
for (let n = 1; n <= 50; n += 1) {
expect(transientResolveRetryDelayMs(n)).toBeLessThanOrEqual(10000);
expect(transientResolveRetryDelayMs(n)).toBeGreaterThanOrEqual(1000);
}
});
it("is monotonic non-decreasing", () => {
let prev = 0;
for (let n = 1; n <= 12; n += 1) {
const d = transientResolveRetryDelayMs(n);
expect(d).toBeGreaterThanOrEqual(prev);
prev = d;
}
});
});
describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay)", () => {
it("parses the encoded delay from a bare mega_debrid_cooldown error", () => {
const r = parseMegaDebridCooldownRetry("mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2, Da******el): Token error");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(20330);
expect(r!.detail).toContain("Mega-Debrid");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2): Token error";
expect(parseMegaDebridCooldownRetry(aggregated)!.delayMs).toBe(20330);
});
it("takes the SOONEST (min) cooldown when several accounts are cooled", () => {
const both = "Mega-Debrid API: mega_debrid_cooldown:116285:web | Mega-Debrid API: mega_debrid_cooldown:20330:api";
expect(parseMegaDebridCooldownRetry(both)!.delayMs).toBe(20330);
});
it("clamps to [1s, 15min]", () => {
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
});
it("returns null when there is no mega cooldown marker", () => {
expect(parseMegaDebridCooldownRetry("Datei beim Hoster gerade nicht abrufbar")).toBeNull();
expect(parseMegaDebridCooldownRetry("debrid_link_cooldown:5000:x")).toBeNull();
});
it("does NOT swallow the until-Tagesreset park token", () => {
expect(parseMegaDebridCooldownRetry("mega_debrid_reset_park:43200000:Alle Accounts bis zum Tagesreset gesperrt")).toBeNull();
});
});
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
it("parses the encoded delay from a slow-link error", () => {
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(120000);
expect(r!.detail).toContain("Mega-Debrid");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
});
it("clamps to [1s, 15min]", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
});
it("does not collide with the account-cooldown or reset-park tokens", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
});
});
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
it("parses the encoded until-reset delay from the park token", () => {
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(43200000);
expect(r!.detail).toContain("bis zum Tagesreset gesperrt");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:7200000:Alle Accounts bis zum Tagesreset gesperrt";
expect(parseMegaDebridResetPark(aggregated)!.delayMs).toBe(7200000);
});
it("is NOT clamped to the 15min cooldown ceiling (can park multiple hours)", () => {
expect(parseMegaDebridResetPark("mega_debrid_reset_park:21600000:x")!.delayMs).toBe(21600000);
});
it("clamps to a sane [1s, 26h] window and rejects junk", () => {
expect(parseMegaDebridResetPark("mega_debrid_reset_park:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridResetPark("mega_debrid_reset_park:999999999999:x")!.delayMs).toBe(26 * 60 * 60 * 1000);
expect(parseMegaDebridResetPark("mega_debrid_cooldown:20330:x")).toBeNull();
expect(parseMegaDebridResetPark("kein Token hier")).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { runInstallWithResume, InstallResumeManager } from "../src/main/update-install-flow";
function makeManager(running: boolean): InstallResumeManager & { startCalls: number; stopCalls: number; persistCalls: number; sessionRunning: boolean } {
return {
sessionRunning: running,
startCalls: 0,
stopCalls: 0,
persistCalls: 0,
isSessionRunning() {
return this.sessionRunning;
},
stop() {
this.stopCalls += 1;
this.sessionRunning = false;
},
persistNowSync() {
this.persistCalls += 1;
},
async start() {
this.startCalls += 1;
this.sessionRunning = true;
}
};
}
describe("runInstallWithResume", () => {
it("resumes a running session when the install returns started:false", async () => {
const m = makeManager(true);
const result = await runInstallWithResume(m, async () => ({ started: false }));
expect(result.started).toBe(false);
expect(m.stopCalls).toBe(1);
expect(m.startCalls).toBe(1);
expect(m.isSessionRunning()).toBe(true);
});
it("resumes a running session when the install THROWS, then rethrows", async () => {
const m = makeManager(true);
await expect(
runInstallWithResume(m, async () => {
throw new Error("network down");
})
).rejects.toThrow("network down");
expect(m.stopCalls).toBe(1);
expect(m.startCalls).toBe(1);
expect(m.isSessionRunning()).toBe(true);
});
it("does NOT resume when the install succeeds (started:true) — the app is about to quit", async () => {
const m = makeManager(true);
const result = await runInstallWithResume(m, async () => ({ started: true }));
expect(result.started).toBe(true);
expect(m.startCalls).toBe(0);
expect(m.isSessionRunning()).toBe(false);
});
it("does NOT resume when no session was running before the install", async () => {
const m = makeManager(false);
await runInstallWithResume(m, async () => ({ started: false }));
expect(m.stopCalls).toBe(0);
expect(m.startCalls).toBe(0);
});
});
+172
View File
@@ -0,0 +1,172 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession, loadSession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
afterEach(async () => {
globalThis.fetch = originalFetch;
shutdownItemLogs();
shutdownPackageLogs();
for (const dir of tempDirs.splice(0)) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 80));
}
}
}
});
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
const started = Date.now();
while (!predicate()) {
if (Date.now() - started > timeoutMs) {
throw new Error("waitFor timeout");
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
const openTimers = new Set<NodeJS.Timeout>();
const openResponses = new Set<http.ServerResponse>();
const server = http.createServer((req, res) => {
if ((req.url || "") !== "/direct") {
res.statusCode = 404;
res.end("not-found");
return;
}
res.statusCode = 200;
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", String(64 * 1024 * 1024));
openResponses.add(res);
res.write(Buffer.alloc(64 * 1024, 7));
const timer = setInterval(() => {
try {
res.write(Buffer.alloc(16 * 1024, 9));
} catch {
}
}, 100);
openTimers.add(timer);
res.on("close", () => {
clearInterval(timer);
openTimers.delete(timer);
openResponses.delete(res);
});
});
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}/direct`;
const stop = async (): Promise<void> => {
for (const timer of openTimers) {
clearInterval(timer);
}
openTimers.clear();
for (const res of openResponses) {
try {
res.destroy();
} catch {
}
}
openResponses.clear();
server.close();
await once(server, "close");
};
return { directUrl, stop };
}
function mockUnrestrict(directUrl: string): void {
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("/unrestrict/link")) {
return new Response(
JSON.stringify({ download: directUrl, filename: "episode.mkv", filesize: 64 * 1024 * 1024 }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
return originalFetch(input, init);
};
}
async function driveActiveDownload(root: string): Promise<{ manager: DownloadManager; paths: ReturnType<typeof createStoragePaths>; serverStop: () => Promise<void> }> {
const { directUrl, stop: serverStop } = await startTricklingServer();
mockUnrestrict(directUrl);
const paths = createStoragePaths(path.join(root, "state"));
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
autoReconnect: false,
retryLimit: 0
},
emptySession(),
paths
);
manager.addPackages([{ name: "park", links: ["https://dummy/park"] }]);
await manager.start();
await waitFor(() => {
const item = Object.values(manager.getSnapshot().session.items)[0];
return item?.status === "downloading" && (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size > 0;
});
return { manager, paths, serverStop };
}
describe("update restart resume", () => {
it("characterization: a plain stop() leaves an in-flight item cancelled across a restart", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root);
try {
manager.stop();
manager.persistNowSync();
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
manager.prepareForShutdown();
const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0];
expect(item).toBeTruthy();
expect(item.status).toBe("cancelled");
} finally {
await serverStop();
}
});
it("parks an in-flight item as queued for an update restart so it auto-resumes", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root);
try {
manager.stop({ parkForRestart: true });
manager.persistNowSync();
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
manager.prepareForShutdown();
const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0];
expect(item).toBeTruthy();
expect(Object.keys(reloaded.packages).length).toBe(1);
expect(item.status).toBe("queued");
} finally {
await serverStop();
}
});
});
+629
View File
@@ -0,0 +1,629 @@
import fs from "node:fs";
import crypto from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => {
const unref = vi.fn();
const once = vi.fn((_event: string, _handler: (...args: unknown[]) => void) => ({
unref
}));
const spawn = vi.fn(() => ({
once,
unref
}));
return {
spawnMock: spawn,
unrefMock: unref,
onceMock: once
};
});
vi.mock("node:child_process", () => ({
spawn: spawnMock
}));
import { buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
import { APP_VERSION } from "../src/main/constants";
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
const originalFetch = globalThis.fetch;
function sha256Hex(buffer: Buffer): string {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
function sha512Hex(buffer: Buffer): string {
return crypto.createHash("sha512").update(buffer).digest("hex");
}
afterEach(() => {
globalThis.fetch = originalFetch;
spawnMock.mockClear();
unrefMock.mockClear();
onceMock.mockClear();
vi.restoreAllMocks();
});
describe("update", () => {
it("normalizes update repo input", () => {
expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://github.com/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo");
expect(normalizeUpdateRepo("github.com/owner/repo.git")).toBe("owner/repo");
expect(normalizeUpdateRepo("git@github.com:owner/repo.git")).toBe("owner/repo");
});
it("uses normalized repo slug for API requests", async () => {
let requestedUrl = "";
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
requestedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(
JSON.stringify({
tag_name: `v${APP_VERSION}`,
html_url: "https://github.com/owner/repo/releases/tag/v1.0.0",
assets: []
}),
{
status: 200,
headers: { "Content-Type": "application/json" }
}
);
}) as typeof fetch;
const result = await checkGitHubUpdate("https://github.com/owner/repo/releases");
expect(requestedUrl).toBe("https://api.github.com/repos/owner/repo/releases/latest");
expect(result.currentVersion).toBe(APP_VERSION);
expect(result.latestVersion).toBe(APP_VERSION);
expect(result.updateAvailable).toBe(false);
});
it("picks setup executable asset from release list", async () => {
globalThis.fetch = (async (): Promise<Response> => new Response(
JSON.stringify({
tag_name: "v9.9.9",
html_url: "https://github.com/owner/repo/releases/tag/v9.9.9",
assets: [
{
name: "Real-Debrid-Downloader-9.9.9-portable.exe",
browser_download_url: "https://example.invalid/portable.exe"
},
{
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup.exe",
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}),
{
status: 200,
headers: { "Content-Type": "application/json" }
}
)) as typeof fetch;
const result = await checkGitHubUpdate("owner/repo");
expect(result.updateAvailable).toBe(true);
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe");
expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe");
});
it("uses silent NSIS install flags with auto-run after update", () => {
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
});
it("falls back to alternate download URL when setup asset URL returns 404", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const executableDigest = sha256Hex(executablePayload);
const requestedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
requestedUrls.push(url);
if (url.includes("stale-setup.exe")) {
return new Response("missing", { status: 404 });
}
if (url.includes("/releases/download/v9.9.9/")) {
return new Response(executablePayload, {
status: 200,
headers: { "Content-Type": "application/octet-stream" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/stale-setup.exe",
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: `sha256:${executableDigest}`
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(true);
expect(requestedUrls.some((url) => url.includes("/releases/download/v9.9.9/"))).toBe(true);
expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1);
});
it("skips draft tag payload and resolves setup asset from stable latest release", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const requestedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
requestedUrls.push(url);
if (url.endsWith("/releases/tags/v9.9.9")) {
return new Response(JSON.stringify({
tag_name: "v9.9.9",
draft: true,
prerelease: false,
assets: [
{
name: "Draft Setup 9.9.9.exe",
browser_download_url: "https://example.invalid/draft-setup.exe"
}
]
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.endsWith("/releases/latest")) {
const stableDigest = sha256Hex(executablePayload);
return new Response(JSON.stringify({
tag_name: "v9.9.9",
draft: false,
prerelease: false,
assets: [
{
name: "Stable Setup 9.9.9.exe",
browser_download_url: "https://example.invalid/stable-setup.exe",
digest: `sha256:${stableDigest}`
}
]
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("stable-setup.exe")) {
return new Response(executablePayload, {
status: 200,
headers: { "Content-Type": "application/octet-stream" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "",
setupAssetName: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(true);
expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true);
expect(requestedUrls.some((url) => url.endsWith("/releases/latest"))).toBe(true);
expect(requestedUrls.some((url) => url.includes("stable-setup.exe"))).toBe(true);
expect(requestedUrls.some((url) => url.includes("draft-setup.exe"))).toBe(false);
});
it("times out hanging release JSON body reads", async () => {
vi.useFakeTimers();
try {
const cancelSpy = vi.fn(async () => undefined);
globalThis.fetch = (async (): Promise<Response> => ({
ok: true,
status: 200,
headers: new Headers({ "Content-Type": "application/json" }),
json: () => new Promise(() => undefined),
body: {
cancel: cancelSpy
}
} as unknown as Response)) as typeof fetch;
const pending = checkGitHubUpdate("owner/repo");
await vi.advanceTimersByTimeAsync(13000);
const result = await pending;
expect(result.updateAvailable).toBe(false);
expect(String(result.error || "")).toMatch(/timeout/i);
expect(cancelSpy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("aborts hanging update body downloads on idle timeout", async () => {
const previousTimeout = process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = "1000";
try {
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("hang-setup.exe")) {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
}
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/octet-stream" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/hang-setup.exe",
setupAssetName: "",
setupAssetDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/timeout/i);
} finally {
if (previousTimeout === undefined) {
delete process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
} else {
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = previousTimeout;
}
}
}, 20000);
it("blocks installer start when SHA256 digest mismatches", async () => {
const executablePayload = fs.readFileSync(process.execPath);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("mismatch-setup.exe")) {
return new Response(executablePayload, {
status: 200,
headers: { "Content-Type": "application/octet-stream" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/mismatch-setup.exe",
setupAssetName: "setup.exe",
setupAssetDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
});
it("blocks installer start when no digest can be resolved", async () => {
const executablePayload = fs.readFileSync(process.execPath);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("unsigned-setup.exe")) {
return new Response(executablePayload, {
status: 200,
headers: { "Content-Type": "application/octet-stream" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/unsigned-setup.exe",
setupAssetName: "setup.exe",
setupAssetDigest: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/digest|integrit|sha/i);
});
it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const digestSha512Hex = sha512Hex(executablePayload);
const digestSha512Base64 = Buffer.from(digestSha512Hex, "hex").toString("base64");
const requestedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
requestedUrls.push(url);
if (url.endsWith("/releases/tags/v9.9.9")) {
return new Response(JSON.stringify({
tag_name: "v9.9.9",
draft: false,
prerelease: false,
assets: [
{
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup-no-digest.exe"
},
{
name: "latest.yml",
browser_download_url: "https://example.invalid/latest.yml"
}
]
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("latest.yml")) {
return new Response(
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${digestSha512Base64}\n`,
{
status: 200,
headers: { "Content-Type": "text/yaml" }
}
);
}
if (url.includes("setup-no-digest.exe")) {
return new Response(executablePayload, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(executablePayload.length)
}
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(true);
expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true);
expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true);
});
it("rejects installer when latest.yml SHA512 digest does not match", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const wrongDigestBase64 = Buffer.alloc(64, 0x13).toString("base64");
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.endsWith("/releases/tags/v9.9.9")) {
return new Response(JSON.stringify({
tag_name: "v9.9.9",
draft: false,
prerelease: false,
assets: [
{
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup-no-digest.exe"
},
{
name: "latest.yml",
browser_download_url: "https://example.invalid/latest.yml"
}
]
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("latest.yml")) {
return new Response(
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
{
status: 200,
headers: { "Content-Type": "text/yaml" }
}
);
}
if (url.includes("setup-no-digest.exe")) {
return new Response(executablePayload, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(executablePayload.length)
}
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/sha512|integrit|mismatch/i);
});
it("emits install progress events while downloading and launching update", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const digest = sha256Hex(executablePayload);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("progress-setup.exe")) {
return new Response(executablePayload, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(executablePayload.length)
}
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/progress-setup.exe",
setupAssetName: "setup.exe",
setupAssetDigest: `sha256:${digest}`
};
const progressEvents: UpdateInstallProgress[] = [];
const result = await installLatestUpdate("owner/repo", prechecked, (progress) => {
progressEvents.push(progress);
});
expect(result.started).toBe(true);
expect(spawnMock).toHaveBeenCalledWith(expect.any(String), ["/S", "--updated", "--force-run"], expect.objectContaining({
detached: true,
stdio: "ignore",
windowsHide: true
}));
expect(unrefMock).toHaveBeenCalledTimes(1);
expect(progressEvents.some((entry) => entry.stage === "starting")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "downloading")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "verifying")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "launching")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true);
});
});
describe("normalizeUpdateRepo extended", () => {
it("handles trailing slashes and extra path segments", () => {
expect(normalizeUpdateRepo("owner/repo/")).toBe("owner/repo");
expect(normalizeUpdateRepo("/owner/repo/")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://github.com/owner/repo/tree/main/src")).toBe("owner/repo");
});
it("handles ssh-style git URLs", () => {
expect(normalizeUpdateRepo("git@github.com:user/project.git")).toBe("user/project");
});
it("returns default for malformed inputs", () => {
expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader");
});
it("rejects traversal-like owner or repo segments", () => {
expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
});
it("handles www prefix", () => {
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo");
});
});
describe("isRemoteNewer", () => {
it("detects newer major version", () => {
expect(isRemoteNewer("1.0.0", "2.0.0")).toBe(true);
});
it("detects newer minor version", () => {
expect(isRemoteNewer("1.2.0", "1.3.0")).toBe(true);
});
it("detects newer patch version", () => {
expect(isRemoteNewer("1.2.3", "1.2.4")).toBe(true);
});
it("returns false for same version", () => {
expect(isRemoteNewer("1.2.3", "1.2.3")).toBe(false);
});
it("returns false for older version", () => {
expect(isRemoteNewer("2.0.0", "1.0.0")).toBe(false);
expect(isRemoteNewer("1.3.0", "1.2.0")).toBe(false);
expect(isRemoteNewer("1.2.4", "1.2.3")).toBe(false);
});
it("handles versions with different segment counts", () => {
expect(isRemoteNewer("1.2", "1.2.1")).toBe(true);
expect(isRemoteNewer("1.2.1", "1.2")).toBe(false);
expect(isRemoteNewer("1", "1.0.1")).toBe(true);
});
it("handles v-prefix in version strings", () => {
expect(isRemoteNewer("v1.0.0", "v2.0.0")).toBe(true);
expect(isRemoteNewer("v1.0.0", "v1.0.0")).toBe(false);
});
});
describe("parseVersionParts", () => {
it("parses standard version strings", () => {
expect(parseVersionParts("1.2.3")).toEqual([1, 2, 3]);
expect(parseVersionParts("10.20.30")).toEqual([10, 20, 30]);
});
it("strips v prefix", () => {
expect(parseVersionParts("v1.2.3")).toEqual([1, 2, 3]);
expect(parseVersionParts("V1.2.3")).toEqual([1, 2, 3]);
});
it("handles single segment", () => {
expect(parseVersionParts("5")).toEqual([5]);
});
it("handles version with pre-release suffix", () => {
expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]);
expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]);
});
it("handles empty and whitespace", () => {
expect(parseVersionParts("")).toEqual([0]);
expect(parseVersionParts(" ")).toEqual([0]);
});
it("handles versions with extra dots", () => {
expect(parseVersionParts("1.2.3.4")).toEqual([1, 2, 3, 4]);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { extractHttpLinksFromText, parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta, filenameFromUrl, looksLikeOpaqueFilename } from "../src/main/utils";
describe("utils", () => {
it("validates http links", () => {
expect(isHttpLink("https://example.com/file")).toBe(true);
expect(isHttpLink("http://example.com/file")).toBe(true);
expect(isHttpLink("ftp://example.com")).toBe(false);
expect(isHttpLink("foo bar")).toBe(false);
});
it("extracts links from text and trims trailing punctuation", () => {
const links = extractHttpLinksFromText("See (https://example.com/test) and https://rapidgator.net/file/abc123, plus https://example.com/a.b.");
expect(links).toEqual([
"https://example.com/test",
"https://rapidgator.net/file/abc123",
"https://example.com/a.b"
]);
});
it("sanitizes filenames", () => {
expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz");
expect(sanitizeFilename(" ")).toBe("Paket");
expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt");
expect(sanitizeFilename("\0\0\0")).toBe("Paket");
expect(sanitizeFilename("..")).toBe("Paket");
expect(sanitizeFilename(".")).toBe("Paket");
expect(sanitizeFilename("release... ")).toBe("release");
expect(sanitizeFilename(" con ")).toBe("con_");
});
it("parses package markers", () => {
const parsed = parsePackagesFromLinksText(
"# package: A\nhttps://a.com/1\nhttps://a.com/2\n# package: B\nhttps://b.com/1\n",
"Default"
);
expect(parsed).toHaveLength(2);
expect(parsed[0].name).toBe("A");
expect(parsed[0].links).toHaveLength(2);
expect(parsed[1].name).toBe("B");
});
it("parses optional file markers for roundtrip imports", () => {
const parsed = parsePackagesFromLinksText(
"# rd-link-export: 1\n# package: Dave Staffel 1\n# file: Folge 001.rar\nhttps://a.com/1\n# file: Folge 002.rar\nhttps://a.com/2\n",
"Default"
);
expect(parsed).toHaveLength(1);
expect(parsed[0].name).toBe("Dave Staffel 1");
expect(parsed[0].links).toEqual(["https://a.com/1", "https://a.com/2"]);
expect(parsed[0].fileNames).toEqual(["Folge 001.rar", "Folge 002.rar"]);
});
it("does not carry a file marker across package boundaries", () => {
const parsed = parsePackagesFromLinksText(
"# package: Dave Staffel 1\n# file: Folge 001.rar\n# package: Dave Staffel 2\nhttps://a.com/2\n",
"Default"
);
expect(parsed).toHaveLength(1);
expect(parsed[0].name).toBe("Dave Staffel 2");
expect(parsed[0].links).toEqual(["https://a.com/2"]);
expect(parsed[0].fileNames).toBeUndefined();
});
it("formats eta", () => {
expect(formatEta(-1)).toBe("--");
expect(formatEta(65)).toBe("01:05");
expect(formatEta(3661)).toBe("01:01:01");
});
it("normalizes filenames from links", () => {
expect(filenameFromUrl("https://rapidgator.net/file/id/show.part1.rar.html")).toBe("show.part1.rar");
expect(filenameFromUrl("https://debrid.example/dl/abc?filename=Movie.S01E01.mkv")).toBe("Movie.S01E01.mkv");
expect(filenameFromUrl("https://debrid.example/dl/%E0%A4%A")).toBe("%E0%A4%A");
expect(filenameFromUrl("https://debrid.example/dl/e51f6809bb6ca615601f5ac5db433737")).toBe("e51f6809bb6ca615601f5ac5db433737");
expect(filenameFromUrl("data:text/plain;base64,SGVsbG8=")).toBe("download.bin");
expect(filenameFromUrl("blob:https://example.com/12345678-1234-1234-1234-1234567890ab")).toBe("download.bin");
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
expect(looksLikeOpaqueFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(true);
expect(looksLikeOpaqueFilename("movie.part1.rar")).toBe(false);
});
it("preserves unicode filenames", () => {
expect(sanitizeFilename("日本語ファイル.txt")).toBe("日本語ファイル.txt");
expect(sanitizeFilename("Ünïcödé Tëst.mkv")).toBe("Ünïcödé Tëst.mkv");
expect(sanitizeFilename("파일이름.rar")).toBe("파일이름.rar");
expect(sanitizeFilename("файл.zip")).toBe("файл.zip");
});
it("handles very long filenames", () => {
const longName = "a".repeat(300);
const result = sanitizeFilename(longName);
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
expect(result).toBe(longName);
});
it("formats eta with very large values without crashing", () => {
const result = formatEta(999999);
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
expect(result).toBe("277:46:39");
});
it("formats eta with edge cases", () => {
expect(formatEta(0)).toBe("00:00");
expect(formatEta(NaN)).toBe("--");
expect(formatEta(Infinity)).toBe("--");
expect(formatEta(Number.MAX_SAFE_INTEGER)).toMatch(/^\d+:\d{2}:\d{2}$/);
});
it("extracts filenames from URLs with encoded characters", () => {
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("handles looksLikeOpaqueFilename edge cases", () => {
expect(looksLikeOpaqueFilename("")).toBe(false);
expect(looksLikeOpaqueFilename("a")).toBe(false);
expect(looksLikeOpaqueFilename("ab")).toBe(false);
expect(looksLikeOpaqueFilename("abc")).toBe(false);
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
});
});
+359
View File
@@ -0,0 +1,359 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
stripDualLangMarker,
hasDualLangMarker,
isRemuxableVideoFile,
looksLikeGermanRelease,
pickAudioTrack,
parseFfprobeAudioStreams,
buildFfprobeArgs,
buildFfmpegRemuxArgs,
computeRemuxTimeoutMs,
processVideoFile,
renameWithRetry,
type VideoSpawnResult
} from "../src/main/video-processor";
describe("stripDualLangMarker", () => {
it("strips a mid-name .DL. token", () => {
expect(stripDualLangMarker("Show.S01E01.German.DL.720p.WEB.x264.mkv")).toBe("Show.S01E01.German.720p.WEB.x264.mkv");
});
it("strips a .DL. directly before the extension", () => {
expect(stripDualLangMarker("Movie.DL.mkv")).toBe("Movie.mkv");
});
it("strips a trailing .DL token before extension", () => {
expect(stripDualLangMarker("Movie.German.DL.mp4")).toBe("Movie.German.mp4");
});
it("is case-insensitive", () => {
expect(stripDualLangMarker("Show.dl.1080p.mkv")).toBe("Show.1080p.mkv");
});
it("leaves files without the marker unchanged", () => {
expect(stripDualLangMarker("Show.S01E01.German.1080p.mkv")).toBe("Show.S01E01.German.1080p.mkv");
});
it("does not strip unrelated tokens containing DL", () => {
expect(stripDualLangMarker("Show.HANDLES.1080p.mkv")).toBe("Show.HANDLES.1080p.mkv");
});
});
describe("hasDualLangMarker", () => {
it("detects the marker", () => {
expect(hasDualLangMarker("X.German.DL.720p.mkv")).toBe(true);
expect(hasDualLangMarker("X.DL.mkv")).toBe(true);
});
it("returns false without the marker", () => {
expect(hasDualLangMarker("X.German.720p.mkv")).toBe(false);
});
});
describe("isRemuxableVideoFile", () => {
it("accepts mkv/mp4 only", () => {
expect(isRemuxableVideoFile("a.mkv")).toBe(true);
expect(isRemuxableVideoFile("a.MP4")).toBe(true);
expect(isRemuxableVideoFile("a.avi")).toBe(false);
expect(isRemuxableVideoFile("a.srt")).toBe(false);
});
});
describe("pickAudioTrack", () => {
const ger = { language: "ger", title: "" };
const eng = { language: "eng", title: "" };
const untagged = { language: "", title: "" };
it("no audio -> skip", () => {
expect(pickAudioTrack([], "tag").action).toBe("skip");
});
it("first mode keeps first of many", () => {
const d = pickAudioTrack([eng, ger], "first");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0 });
});
it("first mode with single audio -> single (no remux)", () => {
expect(pickAudioTrack([eng], "first")).toMatchObject({ action: "single" });
});
it("tag mode picks the German track even if not first", () => {
const d = pickAudioTrack([eng, ger], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
});
it("tag mode picks German via title when language untagged", () => {
const d = pickAudioTrack([{ language: "", title: "Englisch" }, { language: "", title: "Deutsch" }], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
});
it("tag mode does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => {
// Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken
// for a German track; with no real German signal this falls back to first.
const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
});
it("tag mode does NOT let an eng track titled 'German' beat a correctly-tagged German track", () => {
const engTitledGerman = { language: "eng", title: "German Commentary" };
const d = pickAudioTrack([engTitledGerman, ger], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
});
it("tag mode does NOT pick a non-German-tagged track just because its title contains 'Deutsch'", () => {
const d = pickAudioTrack([{ language: "eng", title: "Deutsch entfernt" }, { language: "fre", title: "" }], "tag");
expect(d).toMatchObject({ action: "skip", reason: "no-german-track" });
});
it("tag mode with single German -> single (no remux)", () => {
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
});
it("tag mode, fully untagged multi -> fallback to first", () => {
const d = pickAudioTrack([untagged, untagged], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
});
it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => {
expect(pickAudioTrack([eng, { language: "fre", title: "" }], "tag")).toMatchObject({ action: "skip", reason: "no-german-track" });
});
it("tag mode, no German tag but GERMAN release -> fall back to first track (mislabeled dub)", () => {
expect(pickAudioTrack([eng, eng], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" });
});
it("tag mode, single mislabeled track on a German release -> keep it (no remux)", () => {
expect(pickAudioTrack([eng], "tag", true)).toMatchObject({ action: "single", reason: "single-german-mislabeled" });
});
it("tag mode, no German tag and NOT flagged German -> still SKIP (safety preserved)", () => {
expect(pickAudioTrack([eng, eng], "tag", false)).toMatchObject({ action: "skip", reason: "no-german-track" });
});
it("correctly tagged German still wins even on a German release (fallback not needed)", () => {
expect(pickAudioTrack([eng, ger], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
});
});
describe("looksLikeGermanRelease", () => {
it("detects German/Dubbed release names", () => {
expect(looksLikeGermanRelease("Desperate.Housewives.S02E01.German.DD51.Dubbed.DL.720p.WEB-DL.x264.mkv")).toBe(true);
expect(looksLikeGermanRelease("1899.S01E01.German.DL.720p.WEB-x264-WvF.mkv")).toBe(true);
expect(looksLikeGermanRelease("Show.S01E01.Deutsch.1080p.mkv")).toBe(true);
});
it("does not flag a bare .DL. name without an explicit German token", () => {
expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false);
expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false);
});
it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => {
expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false);
expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false);
});
});
describe("parseFfprobeAudioStreams", () => {
it("parses language/title tags", () => {
const json = JSON.stringify({ streams: [{ index: 1, tags: { language: "ger", title: "Deutsch" } }, { index: 2, tags: { language: "eng" } }] });
expect(parseFfprobeAudioStreams(json)).toEqual([{ language: "ger", title: "Deutsch" }, { language: "eng", title: "" }]);
});
it("returns [] on invalid json", () => {
expect(parseFfprobeAudioStreams("not json")).toEqual([]);
});
it("returns [] when streams missing", () => {
expect(parseFfprobeAudioStreams("{}")).toEqual([]);
});
});
describe("buildFfprobeArgs", () => {
it("requests audio streams as json", () => {
const args = buildFfprobeArgs("in.mkv");
expect(args).toContain("-select_streams");
expect(args).toContain("a");
expect(args[args.length - 1]).toBe("in.mkv");
expect(args).toContain("json");
});
});
describe("buildFfmpegRemuxArgs", () => {
it("maps video + chosen audio, stream-copy, keeps metadata (language tag), no subs by default", () => {
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 1 });
expect(args).toEqual([
"-i", "in.mkv", "-map", "0:v:0", "-map", "0:a:1",
"-c", "copy", "-disposition:a:0", "default", "-y", "out.mkv"
]);
expect(args).not.toContain("-map_metadata"); // language tag of kept track must survive
});
it("adds optional German subtitle maps when keepSubs", () => {
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 0, keepSubs: true });
expect(args.join(" ")).toContain("0:s:m:language:ger?");
});
});
describe("computeRemuxTimeoutMs", () => {
it("has a floor", () => {
expect(computeRemuxTimeoutMs(0)).toBe(120_000);
});
it("scales with size and caps at 60 min", () => {
expect(computeRemuxTimeoutMs(50 * 1024 * 1024 * 1024)).toBe(60 * 60 * 1000);
});
});
// Exercises the REAL file-mutating body (temp -> replace -> utimes -> rm) with a
// fake ffmpeg/ffprobe runner. This is the irreversible-overwrite path that the
// download-manager integration test (which mocks processVideoFile wholesale)
// cannot cover.
describe("processVideoFile (real fs body, fake runner)", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const d of tempDirs.splice(0)) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
tempDirs.push(dir);
const file = path.join(dir, name);
fs.writeFileSync(file, content);
return file;
}
function fakeRunner(opts: { probeJson: string; ffmpegOk?: boolean }): typeof import("../src/main/video-processor").runVideoProcess {
return async (_command: string, args: string[]): Promise<VideoSpawnResult> => {
const base = { aborted: false, timedOut: false, missing: false } as const;
if (args.includes("-show_entries")) {
return { ...base, ok: true, exitCode: 0, stdout: opts.probeJson, stderr: "" };
}
const output = args[args.length - 1];
if (opts.ffmpegOk !== false) {
fs.writeFileSync(output, "REMUXED-GERMAN-ONLY");
return { ...base, ok: true, exitCode: 0, stdout: "", stderr: "" };
}
return { ...base, ok: false, exitCode: 1, stdout: "", stderr: "ffmpeg boom" };
};
}
// Any sidecar the replace machinery may leave behind (unique "~rd…" temp names).
function leftoverTemps(file: string): string[] {
return fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith("~rd"));
}
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] });
it("replaces the original in place and preserves mtime on success", async () => {
const file = makeFile("ORIGINAL");
const oldTime = new Date(Date.now() - 5 * 60 * 1000);
fs.utimesSync(file, oldTime, oldTime);
const beforeMtime = fs.statSync(file).mtimeMs;
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: twoTracksGerSecond })
});
expect(result.action).toBe("remuxed");
expect(result.keptTrackIndex).toBe(1); // German was second
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten
expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved
expect(leftoverTemps(file)).toEqual([]); // unique temp cleaned up
});
it("leaves the original intact and removes temp when ffmpeg fails", async () => {
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: twoTracksGerSecond, ffmpegOk: false })
});
expect(result.action).toBe("error");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost
expect(leftoverTemps(file)).toEqual([]);
});
it("keeps the original intact and cleans the temp when the atomic replace rename fails (no zero-copy window)", async () => {
// Simulate a Windows file lock that defeats the replace even after retries.
// The original must survive: the old rm-then-rename fallback could leave the
// file with NEITHER the original nor the remux on disk.
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: twoTracksGerSecond }),
rename: async () => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); }
});
expect(result.action).toBe("error");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // original never destroyed
expect(leftoverTemps(file)).toEqual([]); // remux temp removed
});
it("does not touch a single-audio file (no remux)", async () => {
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "ger" } }] }) })
});
expect(result.action).toBe("kept-single");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
});
it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => {
// Name says German, but both audio tracks are tagged eng/fre (the dub is
// mislabeled). The fallback keeps the first track instead of skipping.
const file = makeFile("ORIGINAL"); // name contains "German"
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
});
expect(result.action).toBe("remuxed");
expect(result.keptTrackIndex).toBe(0);
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY");
});
it("leaves a NON-German-named file untouched when tagged but no German track (safety preserved)", async () => {
const file = makeFile("ORIGINAL", "Show.S01E01.MULTi.DL.720p.mkv");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
});
expect(result.action).toBe("skipped-no-german");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
});
it("returns skipped-no-tool when ffmpeg/ffprobe are absent", async () => {
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: async () => null });
expect(result.action).toBe("skipped-no-tool");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
});
});
describe("renameWithRetry", () => {
afterEach(() => { vi.restoreAllMocks(); });
const busy = (): NodeJS.ErrnoException => Object.assign(new Error("locked"), { code: "EBUSY" });
it("retries a transient EBUSY and then succeeds", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
calls += 1;
if (calls <= 2) { throw busy(); }
});
await expect(renameWithRetry("a", "b")).resolves.toBeUndefined();
expect(calls).toBe(3); // failed twice, succeeded on the third attempt
});
it("gives up after exhausting retries on a persistent lock", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => { calls += 1; throw busy(); });
await expect(renameWithRetry("a", "b")).rejects.toThrow("locked");
expect(calls).toBe(4); // initial attempt + 3 backoff retries
});
it("does not retry a non-retryable error (e.g. EXDEV) — fails fast", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
calls += 1;
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
});
await expect(renameWithRetry("a", "b")).rejects.toThrow("cross-device");
expect(calls).toBe(1);
});
});