mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Background tasks often couldn't be cancelled, or cancelling didn't stop them: - A cancel that arrived before the backend registered the request was dropped (build while saving files, multi-repository fetch/pull between repositories). The host now remembers early cancels (bounded, 2 min), and request ids start at a random value per page. - There was no queue and no common cancel path: backend-reported tasks (LSP progress, deployment transfers) couldn't be cancelled, and tool installs, VCS operations and update checks had no cancel button in Background Tasks. - Only the direct child process was killed, and JDK / tool archives were extracted without checking for cancellation. Tool installs and JDK downloads now share a downloads queue; cancelling a queued task removes it without starting any work. Every task cancels through one path that shows "Cancelling…" and then removes it; tasks that can't be interrupted show a disabled cancel button with the reason. IdeEvent::Task carries `cancellable` with a cancel_task command, cancellable LSP progress sends window/workDoneProgress/cancel, installers, curl and tar run in their own process group and are killed as a group, and extraction checks for cancellation between entries. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
|
|
const cancelTaskCommand = vi.fn(async (_id: number) => true);
|
|
vi.mock("@/platform/ipc", async (orig) => {
|
|
const actual = await orig<typeof import("@/platform/ipc")>();
|
|
return { ...actual, commands: { ...actual.commands, cancelTask: cancelTaskCommand } };
|
|
});
|
|
|
|
const { emitLocalIdeEvent, isCancelled } = await import("@/platform/ipc");
|
|
const { cancelTask, installTaskEvents, queueTask, setIndexing, upsertTask, useTasks } = await import("./tasks");
|
|
const { TaskStatus } = await import("./StatusBar");
|
|
|
|
const task = (id: string) => useTasks.getState().tasks.find((t) => t.id === id);
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((r) => (resolve = r));
|
|
return { promise, resolve };
|
|
}
|
|
|
|
afterEach(() => {
|
|
useTasks.setState({ tasks: [], indexing: null });
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("task queues", () => {
|
|
it("runs one task at a time; the others wait, listed as queued", async () => {
|
|
const first = deferred();
|
|
const started: string[] = [];
|
|
const a = queueTask("q1", { id: "a", title: "A" }, async () => {
|
|
started.push("a");
|
|
upsertTask({ id: "a", title: "A", message: null, fraction: null, done: false });
|
|
await first.promise;
|
|
return "a";
|
|
});
|
|
const b = queueTask("q1", { id: "b", title: "B" }, async () => {
|
|
started.push("b");
|
|
return "b";
|
|
});
|
|
expect(started).toEqual(["a"]);
|
|
expect(task("b")).toMatchObject({ queued: true, message: "Waiting for other tasks" });
|
|
first.resolve();
|
|
await expect(a).resolves.toBe("a");
|
|
await expect(b).resolves.toBe("b");
|
|
expect(started).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("cancelling a queued task drops it at once: its work never starts and its caller sees a cancellation", async () => {
|
|
const first = deferred();
|
|
const work = vi.fn(async () => "never");
|
|
const running = queueTask("q2", { id: "run", title: "Running" }, () => first.promise);
|
|
const queued = queueTask("q2", { id: "wait", title: "Waiting" }, work);
|
|
const next = queueTask("q2", { id: "next", title: "Next" }, async () => "next");
|
|
|
|
cancelTask("wait");
|
|
await expect(queued).rejects.toSatisfy(isCancelled);
|
|
expect(task("wait")).toBeUndefined();
|
|
|
|
first.resolve();
|
|
await running;
|
|
await expect(next).resolves.toBe("next");
|
|
expect(work).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("the queue's Cancel is gone once the work starts (the work brings its own)", async () => {
|
|
const first = deferred();
|
|
const second = deferred();
|
|
void queueTask("q3", { id: "x", title: "X" }, () => first.promise);
|
|
const y = queueTask("q3", { id: "y", title: "Y" }, () => second.promise);
|
|
first.resolve();
|
|
await vi.waitFor(() => expect(task("y")?.queued).toBe(false));
|
|
expect(task("y")?.cancel).toBeUndefined();
|
|
second.resolve();
|
|
await y;
|
|
});
|
|
});
|
|
|
|
describe("cancelling", () => {
|
|
it("shows Cancelling… until the task ends, then removes it without the finished bar", () => {
|
|
vi.useFakeTimers();
|
|
const cancel = vi.fn();
|
|
upsertTask({ id: "sync", title: "Syncing", message: "Resolving", fraction: 0.3, done: false, cancel });
|
|
cancelTask("sync");
|
|
cancelTask("sync");
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
expect(task("sync")).toMatchObject({ cancelling: true, message: "Cancelling…" });
|
|
upsertTask({ id: "sync", title: "Syncing", message: "Downloading", fraction: 0.4, done: false, cancel });
|
|
expect(task("sync")?.message).toBe("Cancelling…");
|
|
upsertTask({ id: "sync", title: "Syncing", message: null, fraction: 1, done: true });
|
|
expect(task("sync")).toBeUndefined();
|
|
});
|
|
|
|
it("the popup's cancel buttons: working, Cancelling…, and disabled with the reason", () => {
|
|
const cancel = vi.fn();
|
|
upsertTask({ id: "dl", title: "Downloading JDK 25", message: null, fraction: null, done: false, cancel });
|
|
upsertTask({
|
|
id: "inst",
|
|
title: "Installing update",
|
|
message: null,
|
|
fraction: null,
|
|
done: false,
|
|
uncancellable: "This step can't be cancelled",
|
|
});
|
|
render(<TaskStatus />);
|
|
fireEvent.click(screen.getByRole("button", { name: /Background tasks/ }));
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel Downloading JDK 25" }));
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
expect(screen.getByRole("button", { name: "Cancelling…" })).toBeDisabled();
|
|
const off = screen.getByRole("button", { name: "This step can't be cancelled" });
|
|
expect(off).toBeDisabled();
|
|
expect(off.parentElement).toHaveAttribute("data-tooltip", "This step can't be cancelled");
|
|
});
|
|
|
|
it("indexing says why it can't be stopped", () => {
|
|
setIndexing(true, 10, 20);
|
|
expect(task("indexing")).toMatchObject({ uncancellable: expect.any(String) });
|
|
expect(task("indexing")?.cancel).toBeUndefined();
|
|
});
|
|
|
|
it("backend tasks: cancellable ones cancel through cancel_task, the others say they can't", () => {
|
|
vi.useFakeTimers();
|
|
const uninstall = installTaskEvents();
|
|
const base = { type: "task" as const, message: null, fraction: null, done: false };
|
|
act(() => {
|
|
emitLocalIdeEvent({ ...base, id: 41, title: "Building workspace", cancellable: true });
|
|
emitLocalIdeEvent({ ...base, id: 42, title: "Loading", cancellable: false });
|
|
vi.advanceTimersByTime(600);
|
|
});
|
|
cancelTask(41);
|
|
expect(cancelTaskCommand).toHaveBeenCalledWith(41);
|
|
expect(useTasks.getState().tasks.find((t) => t.id === 42)).toMatchObject({
|
|
cancel: undefined,
|
|
uncancellable: "This task can't be cancelled",
|
|
});
|
|
uninstall();
|
|
});
|
|
});
|