57cbab916a
Clone repository (Files), Add workspace -> Clone and a workspace's extra-folder clones now start server-side jobs (background: true) and close at once; the app-wide BackgroundJobsDock (Layout) shows each job's queue place, git progress, Stop (click twice), and outcome: Open workspace, Retry, an inline login form on ECREDENTIALS, a Settings -> Git link on ESSHAUTH, dismiss. lib/cloneJobs.ts polls GET /workspaces/clone/jobs while any job is active, announces a finish it saw once (toast + agenthub:clone-job event + the starter's onDone), and treats an older server's synchronous answer as a finished clone. File Explorer uploads report to lib/uploadJobs.ts (Stop aborts the request in flight; a reload-cut upload shows as interrupted from localStorage). CloneProgress renders a job's fields instead of polling a token.
168 lines
7.2 KiB
TypeScript
168 lines
7.2 KiB
TypeScript
/**
|
|
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
|
* an extra folder, a host that wants a login opens the login row, and Retry
|
|
* re-runs only the failed URLs with that login. An SSH link while the
|
|
* account has no key is never sent — the user is pointed at Settings → Git —
|
|
* and an HTTPS host with no saved login is flagged before Clone. The
|
|
* partial-clone box adds `partial: true` to every clone.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import "../../../i18n";
|
|
|
|
vi.mock("../../../lib/api", () => ({
|
|
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
|
}));
|
|
|
|
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
|
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
|
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
|
|
|
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
|
|
|
describe("CloneSourcesForm", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(api.lanes.cloneFolder).mockReset();
|
|
// A finished clone (an older server's answer, or a job the store saw end)
|
|
// makes the form read the folder list again.
|
|
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
|
|
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
|
resetCloneJobsForTests();
|
|
// Unknown identity: nothing is checked up front.
|
|
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
|
});
|
|
|
|
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
|
const user = userEvent.setup();
|
|
const onAdded = vi.fn();
|
|
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
|
|
if (body?.url.includes("private") && !body.credentials)
|
|
throw Object.assign(new Error("login"), {
|
|
code: "ECREDENTIALS",
|
|
auth: { protocol: "https", host: "private.test" },
|
|
});
|
|
return view;
|
|
});
|
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
|
|
|
await user.type(
|
|
screen.getByTestId("workspace-clone-input"),
|
|
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
|
|
);
|
|
await user.click(screen.getByTestId("workspace-clone-run"));
|
|
|
|
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
|
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
|
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
|
// Only what is left stays in the box.
|
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
|
|
|
await user.type(screen.getByLabelText("Username"), "dev");
|
|
await user.type(screen.getByLabelText("Password or access token"), "tok");
|
|
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
|
|
|
|
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
|
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
|
background: true,
|
|
url: "https://private.test/b.git",
|
|
credentials: { username: "dev", password: "tok", remember: true },
|
|
});
|
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
|
});
|
|
|
|
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
|
|
const user = userEvent.setup();
|
|
const onAdded = vi.fn();
|
|
const job = {
|
|
id: "s1",
|
|
kind: "source",
|
|
state: "queued",
|
|
url: "https://h/a.git",
|
|
name: "a",
|
|
parent: "/",
|
|
target: "/a",
|
|
laneId: 3,
|
|
position: 1,
|
|
phase: null,
|
|
label: null,
|
|
percent: null,
|
|
detail: null,
|
|
error: null,
|
|
result: null,
|
|
createdAt: "",
|
|
finishedAt: null,
|
|
request: { url: "https://h/a.git", parent: "/", name: "a" },
|
|
} as const;
|
|
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
|
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
|
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
|
|
await user.click(screen.getByTestId("workspace-clone-run"));
|
|
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
|
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
|
expect(onAdded).not.toHaveBeenCalled();
|
|
// Another workspace's clone is not ours.
|
|
window.dispatchEvent(
|
|
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
|
|
);
|
|
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
|
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
|
expect(api.lanes.sources).toHaveBeenCalledWith(3);
|
|
});
|
|
|
|
it("asks for partial clones when the box is ticked", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
|
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
|
|
await user.click(screen.getByTestId("workspace-clone-partial"));
|
|
await user.click(screen.getByTestId("workspace-clone-run"));
|
|
await waitFor(() =>
|
|
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
|
background: true,
|
|
url: "https://h/big.git",
|
|
partial: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("tells an SSH link from an HTTPS one", () => {
|
|
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
|
|
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
|
|
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
|
|
expect(cloneTarget("not a url")).toBeNull();
|
|
});
|
|
|
|
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.gitIdentity.get).mockResolvedValue({
|
|
ssh: null,
|
|
sshKeys: [],
|
|
https: [],
|
|
} as unknown as GitIdentity);
|
|
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
|
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
|
|
|
|
await user.type(
|
|
screen.getByTestId("workspace-clone-input"),
|
|
"git@github.com:o/r.git\nhttps://h.test/a.git"
|
|
);
|
|
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
|
|
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
|
|
|
|
await user.click(screen.getByTestId("workspace-clone-run"));
|
|
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
|
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
|
url: "https://h.test/a.git",
|
|
background: true,
|
|
});
|
|
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
|
|
);
|
|
});
|
|
});
|