2b26fe1efa
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.
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
/**
|
||
* @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("")
|
||
);
|
||
});
|
||
});
|