Complete provider waits and lifecycle visibility
Release aborted Real-Debrid, AllDebrid, and BestDebrid web callers even when underlying requests ignore cancellation while retaining terminal rejection observers. Publish provider cooldown deadlines and emit a fresh idle snapshot at the earliest expiry. Abort and visibly drain post-processing before dispatching one pending restart. Surface lifecycle phase, reason, retry countdown, and remaining work in the download controls. Add focused regressions for provider abort races, cooldown expiry, post-processing drain, pending start visibility, and the updated rapid stop contract.
This commit is contained in:
@@ -142,6 +142,28 @@ describe("alldebrid-web", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
const fallback = new AllDebridWebFallback(() => true);
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
|
||||
.then(() => "resolved" as const, (error) => String(error));
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
controller.abort("test-stop");
|
||||
const outcome = await Promise.race([
|
||||
running,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:alldebrid-web");
|
||||
rejectRequest(new Error("late alldebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("opens the login window after login_required and retries generation with the same session partition", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response("login", { status: 200 }))
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("bestdebrid-web", () => {
|
||||
expect(mockCookiesSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
|
||||
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"
|
||||
@@ -162,6 +162,34 @@ describe("bestdebrid-web", () => {
|
||||
|
||||
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/");
|
||||
});
|
||||
});
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
|
||||
});
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
const filePath = createCookieFile([
|
||||
"# Netscape HTTP Cookie File",
|
||||
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
|
||||
].join("\n"));
|
||||
tempFiles.push(filePath);
|
||||
const fallback = new BestDebridWebFallback(() => true);
|
||||
await fallback.importCookiesFromFile(filePath);
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://1fichier.com/?abort-race", controller.signal)
|
||||
.then(() => "resolved" as const, (error) => String(error));
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
controller.abort("test-stop");
|
||||
const outcome = await Promise.race([
|
||||
running,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:bestdebrid-web");
|
||||
rejectRequest(new Error("late bestdebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -899,6 +899,109 @@ describe("deterministic stop and restart lifecycle", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(internal.activeTasks.get(itemId)).toBe(newOwner);
|
||||
});
|
||||
|
||||
it("emits an idle snapshot when the earliest provider cooldown expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z"));
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-"));
|
||||
tempDirs.push(root);
|
||||
const accountId = "rda_cooldown_event";
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
|
||||
providerOrder: ["realdebrid"],
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "cooldown", links: ["https://rapidgator.net/file/cooldown"] }]);
|
||||
const internal = manager as unknown as { stateEmitTimer: NodeJS.Timeout | null };
|
||||
if (internal.stateEmitTimer) {
|
||||
clearTimeout(internal.stateEmitTimer);
|
||||
internal.stateEmitTimer = null;
|
||||
}
|
||||
primeRealDebridRuntimeCooldownForTests(accountId, 1_000);
|
||||
const events: Array<ReturnType<typeof manager.getSnapshot>> = [];
|
||||
manager.on("state", (snapshot) => events.push(snapshot));
|
||||
|
||||
const waiting = manager.getSnapshot();
|
||||
expect(waiting).toMatchObject({
|
||||
canStart: false,
|
||||
lifecycle: {
|
||||
phase: "waiting_provider",
|
||||
retryAt: Date.parse("2026-08-22T08:00:01.000Z")
|
||||
}
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(events.some((snapshot) => snapshot.canStart)).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
canStart: true,
|
||||
lifecycle: { phase: "idle", retryAt: null }
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps post-processing drain visible and starts the pending run after its abort settles", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-stop-drain-"));
|
||||
tempDirs.push(root);
|
||||
const packageId = "postprocess-drain";
|
||||
const session = emptySession();
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "postprocess-drain",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
status: "extracting",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", autoExtract: true, autoExtractWhenStopped: true },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
let postProcessSignal!: AbortSignal;
|
||||
let finishPostProcessing!: () => void;
|
||||
const recoverRetryableItems = vi.fn().mockResolvedValue(0);
|
||||
const internal = manager as unknown as {
|
||||
handlePackagePostProcessing: (packageId: string, signal: AbortSignal) => Promise<void>;
|
||||
recoverRetryableItems: () => Promise<number>;
|
||||
runPackagePostProcessing: (packageId: string) => Promise<void>;
|
||||
};
|
||||
internal.handlePackagePostProcessing = async (_requestedPackageId, signal) => {
|
||||
postProcessSignal = signal;
|
||||
await new Promise<void>((resolve) => {
|
||||
finishPostProcessing = resolve;
|
||||
});
|
||||
};
|
||||
internal.recoverRetryableItems = recoverRetryableItems;
|
||||
|
||||
const processing = internal.runPackagePostProcessing(packageId);
|
||||
await waitFor(() => postProcessSignal !== undefined);
|
||||
manager.stop();
|
||||
|
||||
expect(postProcessSignal.aborted).toBe(true);
|
||||
await manager.start();
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({
|
||||
phase: "stopping",
|
||||
activePostProcessing: 1,
|
||||
pendingStart: true
|
||||
});
|
||||
|
||||
finishPostProcessing();
|
||||
await processing;
|
||||
await waitFor(() => manager.getSnapshot().lifecycle?.phase === "idle");
|
||||
expect(recoverRetryableItems).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({ activePostProcessing: 0, pendingStart: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
||||
@@ -14146,8 +14249,12 @@ describe("download manager", () => {
|
||||
manager.start().then(() => "started" as const, (error) => String(error)),
|
||||
timeout
|
||||
]);
|
||||
expect(result).toContain("Kein aktiver Download-Account verfügbar");
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
expect(result).toBe("started");
|
||||
await waitFor(() => manager.getSnapshot().lifecycle?.reason.includes("Kein aktiver Download-Account verfügbar") === true);
|
||||
expect(manager.getSnapshot()).toMatchObject({
|
||||
session: { running: false },
|
||||
lifecycle: { phase: "idle", pendingStart: false }
|
||||
});
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
|
||||
@@ -797,6 +797,15 @@ function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsVie
|
||||
packageSpeedBps: { "package-a": 12_000_000 },
|
||||
disclosureRevision: 0,
|
||||
animationsEnabled: true,
|
||||
lifecycle: {
|
||||
phase: "running",
|
||||
reason: "Downloads laufen",
|
||||
retryAt: null,
|
||||
activeDownloads: 1,
|
||||
activePostProcessing: 0,
|
||||
pendingStart: false
|
||||
},
|
||||
lifecycleNow: now,
|
||||
editingPackageId: null,
|
||||
editingName: "",
|
||||
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
||||
@@ -1148,6 +1157,61 @@ describe("downloads view", () => {
|
||||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
||||
});
|
||||
|
||||
it("accepts a start during stopping and then shows the accepted pending request", () => {
|
||||
const actions = createActions();
|
||||
const stopping = {
|
||||
...withRuntime(createInput(), { running: false, canStart: true, canPause: false }),
|
||||
lifecycle: {
|
||||
phase: "stopping",
|
||||
reason: "Laufende Arbeit wird beendet",
|
||||
retryAt: null,
|
||||
activeDownloads: 1,
|
||||
activePostProcessing: 0,
|
||||
pendingStart: false
|
||||
},
|
||||
lifecycleNow: now
|
||||
} as DownloadsViewModel;
|
||||
const pending = {
|
||||
...stopping,
|
||||
canStart: false,
|
||||
lifecycle: { ...stopping.lifecycle, pendingStart: true, reason: "Start vorgemerkt, laufende Arbeit wird beendet" }
|
||||
} as DownloadsViewModel;
|
||||
|
||||
expect(findButton(DownloadsToolbar({ actions, model: stopping }), "Start").props.disabled).toBe(false);
|
||||
expect(findButton(DownloadsToolbar({ actions, model: pending }), "Start vorgemerkt").props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows lifecycle phase, reason, retry countdown and remaining drain work", () => {
|
||||
const stopping = {
|
||||
...withRuntime(createInput(), { running: false }),
|
||||
lifecycle: {
|
||||
phase: "stopping",
|
||||
reason: "Laufende Arbeit wird beendet",
|
||||
retryAt: null,
|
||||
activeDownloads: 2,
|
||||
activePostProcessing: 1,
|
||||
pendingStart: false
|
||||
},
|
||||
lifecycleNow: now
|
||||
} as DownloadsViewModel;
|
||||
const waiting = {
|
||||
...stopping,
|
||||
lifecycle: {
|
||||
phase: "waiting_provider",
|
||||
reason: "Provider vorübergehend nicht verfügbar",
|
||||
retryAt: now + 3_200,
|
||||
activeDownloads: 0,
|
||||
activePostProcessing: 0,
|
||||
pendingStart: false
|
||||
}
|
||||
} as DownloadsViewModel;
|
||||
|
||||
const stoppingHtml = renderToStaticMarkup(<DownloadsFooter actions={createActions()} model={stopping} />);
|
||||
const waitingHtml = renderToStaticMarkup(<DownloadsFooter actions={createActions()} model={waiting} />);
|
||||
expect(stoppingHtml).toContain("Stoppt · Laufende Arbeit wird beendet · Restarbeit: 2 Downloads, 1 Nachbearbeitung");
|
||||
expect(waitingHtml).toContain("Wartet auf Provider · Provider vorübergehend nicht verfügbar · Noch 4 s");
|
||||
});
|
||||
|
||||
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
|
||||
const pausedToolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
|
||||
@@ -191,6 +191,28 @@ describe("realdebrid-web", () => {
|
||||
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockSessionFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_abort_race", () => true);
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
|
||||
.then(() => "resolved" as const, (error) => String(error));
|
||||
|
||||
await vi.waitFor(() => expect(mockSessionFetch).toHaveBeenCalledTimes(1));
|
||||
controller.abort("test-stop");
|
||||
const outcome = await Promise.race([
|
||||
running,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:realdebrid-web");
|
||||
rejectRequest(new Error("late realdebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("does not open a login window for an authenticated account with a fair-use error", async () => {
|
||||
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user