Files
AgentHub/client/src/components/__tests__/BackgroundJobsDock.test.tsx
T
nntrivi2001 2b26fe1efa fix(jobs): a failed clone's chip is readable and clickable
A failed or stopped clone also raised a toast on the same corner, a layer
above the dock, so it covered the chip's x / Retry / Stop and repeated the
same long message; the chip now says it alone, and every toast stacks above
the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH)
wraps inside the chip and inside a toast instead of running off the screen.
Open Settings -> Git is a router link; a plain <a href> reloaded the app.
2026-11-02 11:26:48 +07:00

318 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
* announced once when the store sees it finish (toast + window event); a
* fresh mount (F5) shows what the server kept — an interrupted clone with
* Retry, a login failure with an inline login form that retries with it —
* without toasting it again; × dismisses; Stop (click twice) cancels a
* running clone, which then reads as stopped and can be retried; an older
* server that cannot stop says so. An upload shows with Stop, and one a
* reload cut short is shown as interrupted until dismissed. Several chips
* collapse to one pill.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../i18n";
vi.mock("../../lib/api", () => ({
api: {
lanes: {
clone: vi.fn(),
cloneFolder: vi.fn(),
cloneJobs: vi.fn(),
retryCloneJob: vi.fn(),
dismissCloneJob: vi.fn(),
cancelCloneJob: vi.fn(),
},
},
}));
vi.mock("../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
import { api, type CloneJob } from "../../lib/api";
import { toast } from "../../lib/toast";
import {
CLONE_JOB_EVENT,
refreshCloneJobs,
resetCloneJobsForTests,
startClone,
} from "../../lib/cloneJobs";
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
import { BackgroundJobsDock } from "../BackgroundJobsDock";
const base: CloneJob = {
id: "j1",
kind: "workspace",
state: "running",
url: "https://git.example/o/repo.git",
name: "repo",
parent: "/home/an",
target: "/home/an/repo",
laneId: null,
position: null,
phase: "receiving",
label: null,
percent: 40,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
};
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
const renderDock = () =>
render(
<MemoryRouter>
<BackgroundJobsDock />
</MemoryRouter>
);
beforeEach(() => {
vi.clearAllMocks();
resetCloneJobsForTests();
try {
localStorage.clear();
} catch {
/* none */
}
reloadUploadsForTests();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
});
describe("cloneJobs store", () => {
it("announces a background clone once, when it sees it finish", async () => {
const onDone = vi.fn();
const events: CloneJob[] = [];
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
window.addEventListener(CLONE_JOB_EVENT, listen);
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
await startClone(
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
{ onDone }
);
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
expect(toast.success).not.toHaveBeenCalled();
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
await refreshCloneJobs();
await refreshCloneJobs();
expect(toast.success).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
expect(events).toEqual([done]);
expect(onDone).toHaveBeenCalledWith(done, undefined);
window.removeEventListener(CLONE_JOB_EVENT, listen);
});
});
describe("BackgroundJobsDock", () => {
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("Cloning repo");
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
expect(toast.success).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it("shows an interrupted clone after F5 and retries it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "interrupted",
error: { code: "EINTERRUPTED", message: "server restarted" },
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
job: job({ id: "j2", state: "queued", position: 1 }),
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
expect(toast.error).not.toHaveBeenCalled();
await user.click(within(chip).getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
);
});
it("asks for a login on ECREDENTIALS and retries with it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
error: {
code: "ECREDENTIALS",
message: "login",
auth: { protocol: "https", host: "git.example" },
},
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const form = await screen.findByTestId("clone-job-login");
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
await user.type(within(form).getByLabelText("Username"), "ann");
await user.type(within(form).getByLabelText("Password or access token"), "tok");
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
username: "ann",
password: "tok",
remember: true,
});
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("dismisses a finished clone", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
await user.click(within(chip).getByTestId("clone-job-dismiss"));
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
expect(screen.queryByTestId("clone-job")).toBeNull();
});
it("stops a running clone on a second click, then lets it be retried", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const stop = await screen.findByTestId("clone-job-stop");
await user.click(stop);
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
await user.click(screen.getByTestId("clone-job-stop"));
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
const chip = await screen.findByText("You stopped this clone.");
expect(chip).toBeInTheDocument();
// The chip says it; no toast repeats it on top of the chip.
expect(toast.info).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("says so when the server cannot stop a clone yet", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
Object.assign(new Error("HTTP 404"), { status: 404 })
);
renderDock();
await user.click(await screen.findByTestId("clone-job-stop"));
await user.click(screen.getByTestId("clone-job-stop"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
);
});
it("shows an upload with Stop, which aborts it", async () => {
renderDock();
let up!: ReturnType<typeof beginUpload>;
act(() => {
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
up.update({ name: "a.txt", done: 1, sent: 150 });
});
const chip = await screen.findByTestId("fx-progress");
expect(chip).toHaveTextContent("Uploading 2/3");
expect(chip).toHaveTextContent("one/src · a.txt");
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(false);
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(true);
act(() => up.end());
expect(screen.queryByTestId("fx-progress")).toBeNull();
});
it("shows an upload a reload cut short until it is dismissed", async () => {
localStorage.setItem(
"agenthub.uploads.inflight",
JSON.stringify([
{
id: "up-old",
folder: "one/src",
name: "b.txt",
done: 2,
total: 5,
sent: 10,
bytes: 50,
state: "running",
beat: Date.now() - 60_000,
},
])
);
act(() => reloadUploadsForTests());
renderDock();
const chip = await screen.findByTestId("upload-job");
expect(chip).toHaveTextContent(
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
);
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("upload-job")).toBeNull();
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
});
it("collapses several chips to one pill", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job(), job({ id: "j2", name: "other" })],
});
renderDock();
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
await user.click(screen.getByTestId("jobs-dock-collapse"));
expect(screen.queryByTestId("clone-job")).toBeNull();
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
await user.click(screen.getByTestId("jobs-dock-pill"));
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
});
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
url: long,
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
}),
],
});
renderDock();
const alert = await screen.findByRole("alert");
// A URL with no space to break at must still wrap inside the chip.
expect(alert.className).toContain("[overflow-wrap:anywhere]");
// A router link, not a full page reload.
const link = screen.getByRole("link", { name: /Settings/ });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
await waitFor(() =>
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
);
});
});