Files
kandev/apps/web/components/task-create-dialog-remote-repo-chips.test.tsx
T

510 lines
15 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { DialogFormState, TaskRemoteRepoRow } from "./task-create-dialog-types";
import { TooltipProvider } from "@kandev/ui/tooltip";
// The chips-row now owns the single `useAccessibleRepos()` call. Stub it so
// these tests don't hit the network layer; the chip stub below ignores the
// prop entirely so its exact shape doesn't matter for these assertions.
vi.mock("@/hooks/domains/integrations/use-remote-repositories", () => ({
useRemoteRepositories: () => ({
repos: [],
availableProviders: [],
loading: false,
unavailable: false,
error: null,
search: () => undefined,
}),
}));
// Stub out the chip's heavy popover content (we test that separately). The
// chips-row's only job is to render N chips + the Add button and pipe
// branchesByUrl.ensure() for non-empty URLs — so the stub just emits a
// data-testid and exposes onRemove on the row for click assertions, plus
// two buttons that invoke onURLChange in picker- and paste-mode so we can
// assert the parent's metadata-application logic without touching the
// chip's real popover.
type ChipURLChange = (
url: string,
source: "picker" | "paste",
metadata?: { provider: "github" | "gitlab"; fullName: string; defaultBranch: string },
) => void;
const PICKER_URL = vi.hoisted(() => "https://github.com/acme/site");
const REMOTE_REPO_CHIP_TEST_ID = "remote-repo-chip";
const SELECTED_IDENTITIES_ATTRIBUTE = "data-selected-repository-identities";
vi.mock("./task-create-dialog-remote-repo-chip", () => ({
selectedRemoteRepositoryIdentity: (row: TaskRemoteRepoRow) =>
row.provider && row.providerRepoId ? `${row.provider}:id:${row.providerRepoId}` : undefined,
RemoteRepoChip: ({
row,
onRemove,
onURLChange,
onRetry,
selectedRepositoryIdentities = [],
}: {
row: TaskRemoteRepoRow;
onRemove: () => void;
onURLChange: ChipURLChange;
onRetry?: () => void;
selectedRepositoryIdentities?: string[];
}) => (
<div
data-testid={REMOTE_REPO_CHIP_TEST_ID}
data-url={row.url}
{...{ [SELECTED_IDENTITIES_ATTRIBUTE]: selectedRepositoryIdentities.join(",") }}
>
<span data-testid="remote-repo-chip-url">{row.url}</span>
<button type="button" data-testid="remote-chip-remove" onClick={onRemove}>
x
</button>
<button
type="button"
data-testid="remote-chip-fire-picker"
onClick={() =>
onURLChange(PICKER_URL, "picker", {
provider: "github",
fullName: "acme/site",
defaultBranch: "trunk",
})
}
>
pick
</button>
<button
type="button"
data-testid="remote-chip-fire-paste"
onClick={() => onURLChange("https://github.com/foo/bar", "paste")}
>
paste
</button>
{onRetry ? (
<button type="button" data-testid="remote-chip-retry" onClick={onRetry}>
retry
</button>
) : null}
</div>
),
}));
import { RemoteRepoChipsRow } from "./task-create-dialog-remote-repo-chips";
const URL_AB = "https://github.com/a/b";
const URL_CD = "https://github.com/c/d";
afterEach(cleanup);
function makeBranchesByUrl(ensure = vi.fn()) {
return {
branches: () => [],
loading: () => false,
error: () => undefined,
ensure,
clear: () => undefined,
};
}
function makePrInfoByUrl(ensure = vi.fn()) {
return {
info: () => undefined,
loading: () => false,
settled: () => true,
error: () => undefined,
ensure,
clear: () => undefined,
};
}
function makeFs(overrides: Partial<DialogFormState>): DialogFormState {
return {
remoteRepos: [] as TaskRemoteRepoRow[],
branchesByUrl: makeBranchesByUrl(),
prInfoByUrl: makePrInfoByUrl(),
...overrides,
} as unknown as DialogFormState;
}
function renderInProvider(ui: Parameters<typeof render>[0]) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
// eslint-disable-next-line max-lines-per-function -- groups related identity transitions.
describe("RemoteRepoChipsRow identity tracking", () => {
it("passes another row's provider/id identity to the current chip", () => {
const fs = makeFs({
remoteRepos: [
{
key: "remote-0",
url: PICKER_URL,
branch: "main",
source: "picker",
provider: "github",
providerRepoId: "site-id",
},
{ key: "remote-1", url: "", branch: "", source: "paste" },
],
});
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={fs}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[1]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:site-id");
});
it("excludes the current row and clears another row's identity after it changes or is removed", () => {
const initial = makeFs({
remoteRepos: [
{
key: "remote-0",
url: PICKER_URL,
branch: "main",
source: "picker",
provider: "github",
providerRepoId: "site-id",
},
{
key: "remote-1",
url: "https://github.com/acme/docs",
branch: "main",
source: "picker",
provider: "github",
providerRepoId: "docs-id",
},
],
});
const { rerender } = renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={initial}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[0]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:docs-id");
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[1]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:site-id");
rerender(
<TooltipProvider>
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={makeFs({
remoteRepos: [
initial.remoteRepos[0]!,
{ ...initial.remoteRepos[1]!, providerRepoId: "other-id" },
],
})}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>
</TooltipProvider>,
);
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[0]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:other-id");
rerender(
<TooltipProvider>
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={makeFs({ remoteRepos: [initial.remoteRepos[0]!] })}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>
</TooltipProvider>,
);
expect(
screen.getByTestId(REMOTE_REPO_CHIP_TEST_ID).getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("");
});
});
describe("RemoteRepoChipsRow controls", () => {
it("hydrates a pasted provider URL with its inspected descriptor", () => {
const onUpdateRow = vi.fn();
const fs = makeFs({
remoteRepos: [
{
key: "remote-0",
url: "https://bitbucket.example.test/bitbucket/projects/PLATFORM/repos/web/pull-requests/42",
branch: "feature/dc",
source: "paste",
},
],
prInfoByUrl: {
...makePrInfoByUrl(),
inspection: () => ({
providerId: "bitbucket",
providerHost: "https://bitbucket.example.test/bitbucket",
ownerOrProject: "PLATFORM",
repositoryId: "web-42",
repositoryName: "web",
cloneUrl: "https://bitbucket.example.test/bitbucket/scm/PLATFORM/web.git",
baseBranch: "main",
headBranch: "feature/dc",
pullRequest: { number: 42, title: "Data Center pull request" },
}),
} as unknown as DialogFormState["prInfoByUrl"],
});
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={fs}
onUpdateRow={onUpdateRow}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
expect(onUpdateRow).toHaveBeenCalledWith("remote-0", {
remoteUrl: "https://bitbucket.example.test/bitbucket/scm/PLATFORM/web.git",
provider: "bitbucket",
providerHost: "https://bitbucket.example.test/bitbucket",
providerRepoId: "web-42",
providerOwner: "PLATFORM",
providerName: "web",
fullName: "PLATFORM/web",
prNumber: 42,
prBaseBranch: "main",
prHeadBranch: "feature/dc",
});
});
it("uses the inspected provider default when a pasted repository has no branch", () => {
const onUpdateRow = vi.fn();
const fs = makeFs({
remoteRepos: [
{
key: "remote-0",
url: "https://bitbucket.example.test/projects/PLATFORM/repos/web",
branch: "",
source: "paste",
},
],
prInfoByUrl: {
...makePrInfoByUrl(),
inspection: () => ({
providerId: "bitbucket",
providerHost: "https://bitbucket.example.test",
ownerOrProject: "PLATFORM",
repositoryId: "web-42",
repositoryName: "web",
cloneUrl: "https://bitbucket.example.test/scm/PLATFORM/web.git",
defaultBranch: "trunk",
}),
} as unknown as DialogFormState["prInfoByUrl"],
});
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={fs}
onUpdateRow={onUpdateRow}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
expect(onUpdateRow).toHaveBeenCalledWith(
"remote-0",
expect.objectContaining({ branch: "trunk" }),
);
});
});
describe("RemoteRepoChipsRow retry", () => {
it("retries both resolution paths for the failed row", () => {
const branchEnsure = vi.fn();
const branchClear = vi.fn();
const prEnsure = vi.fn();
const prClear = vi.fn();
const fs = makeFs({
remoteRepos: [{ key: "remote-0", url: URL_AB, branch: "main", source: "paste" }],
branchesByUrl: {
...makeBranchesByUrl(branchEnsure),
error: () => new Error("GitHub failed"),
clear: branchClear,
},
prInfoByUrl: { ...makePrInfoByUrl(prEnsure), clear: prClear },
});
renderInProvider(
<RemoteRepoChipsRow fs={fs} onUpdateRow={vi.fn()} onAddRow={vi.fn()} onRemoveRow={vi.fn()} />,
);
fireEvent.click(screen.getByTestId("remote-chip-retry"));
expect(branchClear).toHaveBeenCalledWith(URL_AB);
expect(prClear).toHaveBeenCalledWith(URL_AB);
expect(branchEnsure).toHaveBeenLastCalledWith(URL_AB);
expect(prEnsure).toHaveBeenLastCalledWith(URL_AB);
});
});
describe("RemoteRepoChipsRow controls", () => {
it("renders one chip per row in fs.remoteRepos", () => {
const fs = makeFs({
remoteRepos: [
{ key: "remote-0", url: URL_AB, branch: "", source: "paste" },
{ key: "remote-1", url: URL_CD, branch: "", source: "paste" },
],
});
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={fs}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
expect(screen.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)).toHaveLength(2);
});
it("renders a placeholder chip when remoteRepos is empty", () => {
const fs = makeFs({ remoteRepos: [] });
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={fs}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
// Defends against the seed-effect edge case — at minimum, the add button
// must be available so the user can add a row from nothing.
expect(screen.getByTestId("remote-add-row")).toBeTruthy();
});
it("clicking + Add calls onAddRow once", () => {
const onAddRow = vi.fn();
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={makeFs({
remoteRepos: [{ key: "remote-0", url: "", branch: "", source: "paste" }],
})}
onUpdateRow={vi.fn()}
onAddRow={onAddRow}
onRemoveRow={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId("remote-add-row"));
expect(onAddRow).toHaveBeenCalledOnce();
});
it("clicking remove on a chip calls onRemoveRow with the row key", () => {
const onRemoveRow = vi.fn();
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={makeFs({
remoteRepos: [
{ key: "remote-0", url: URL_AB, branch: "", source: "paste" },
{ key: "remote-1", url: URL_CD, branch: "", source: "paste" },
],
})}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={onRemoveRow}
/>,
);
fireEvent.click(screen.getAllByTestId("remote-chip-remove")[0]);
expect(onRemoveRow).toHaveBeenCalledWith("remote-0");
});
it("calls branchesByUrl.ensure for every non-empty URL row", () => {
const ensure = vi.fn();
const fs = makeFs({
remoteRepos: [
{ key: "remote-0", url: URL_AB, branch: "", source: "paste" },
{ key: "remote-1", url: "", branch: "", source: "paste" }, // empty — not ensured
{ key: "remote-2", url: URL_CD, branch: "", source: "paste" },
],
branchesByUrl: makeBranchesByUrl(ensure),
});
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={fs}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
expect(ensure).toHaveBeenCalledWith(URL_AB);
expect(ensure).toHaveBeenCalledWith(URL_CD);
expect(ensure).not.toHaveBeenCalledWith("");
});
});
describe("RemoteRepoChipsRow — onURLChange wiring", () => {
it("picker onURLChange writes url+metadata AND pre-fills branch with default_branch", () => {
const onUpdateRow = vi.fn();
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={makeFs({
remoteRepos: [{ key: "remote-0", url: "", branch: "", source: "paste" }],
})}
onUpdateRow={onUpdateRow}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId("remote-chip-fire-picker"));
expect(onUpdateRow).toHaveBeenCalledWith("remote-0", {
url: PICKER_URL,
source: "picker",
provider: "github",
fullName: "acme/site",
branch: "trunk",
});
});
it("paste onURLChange clears picker metadata and DOES NOT pre-fill branch", () => {
const onUpdateRow = vi.fn();
renderInProvider(
<RemoteRepoChipsRow
workspaceId="ws-1"
fs={makeFs({
remoteRepos: [{ key: "remote-0", url: "", branch: "", source: "paste" }],
})}
onUpdateRow={onUpdateRow}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId("remote-chip-fire-paste"));
expect(onUpdateRow).toHaveBeenCalledWith("remote-0", {
url: "https://github.com/foo/bar",
source: "paste",
provider: undefined,
fullName: undefined,
branch: "",
});
});
});