/** * @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ĩ */ 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 => ({ ...base, ...over }); const renderDock = () => render( ); 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).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; 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("") ); }); });