Files
2026-09-05 18:44:43 +00:00

460 lines
17 KiB
TypeScript

import { createElement } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover";
import { MRStatusChip } from "./mr-status-chip";
import type {
TaskMR,
TaskMRAutomationOptions,
TaskMRAutomationOptionsForMR,
} from "@/lib/types/gitlab";
const OPEN_DELAY_MS = 150;
const CHIP_TESTID = "mr-status-chip";
const DATA_MR_IID = "data-mr-iid";
const DATA_SELECTION_FROZEN = "data-selection-frozen";
const DATA_MR_COUNT = "data-mr-count";
const DATA_STATUS = "data-status";
const POPOVER_STUB_TESTID = "mr-ci-popover-stub";
const gitlabMocks = vi.hoisted(() => ({ mrs: [] as TaskMR[] }));
const touchMocks = vi.hoisted(() => ({ usesTouchDrawer: false }));
const automationMocks = vi.hoisted(() => ({
options: null as TaskMRAutomationOptions | null,
}));
const dialogMocks = vi.hoisted(() => ({ lastProps: null as { open: boolean } | null }));
const unlinkMock = vi.hoisted(() => vi.fn(async () => {}));
const popoverRenderMRIIDs = vi.hoisted(() => [] as number[]);
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
workspaces: { activeId: "workspace-1" },
repositories: { itemsByWorkspaceId: { "workspace-1": [] } },
}),
}));
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
useGitLabAvailable: () => true,
useTaskMRs: () => gitlabMocks.mrs,
useUnlinkTaskMR: () => unlinkMock,
useWorkspaceMRs: () => undefined,
}));
vi.mock("@/hooks/domains/gitlab/use-task-mr-automation", () => ({
useTaskMRAutomationOptions: () => ({ options: automationMocks.options }),
}));
vi.mock("@/hooks/domains/kanban/use-task-by-id", () => ({
useTaskById: () => ({ id: "task-1", repositories: [] }),
}));
vi.mock("@/hooks/use-compact-task-chrome", () => ({
useTouchDrawer: () => touchMocks.usesTouchDrawer,
}));
vi.mock("./task-mr-link-dialog", () => ({
TaskMRLinkDialog: (props: { open: boolean }) => {
dialogMocks.lastProps = props;
return <div data-testid="mr-link-dialog-stub" data-open={props.open ? "true" : "false"} />;
},
}));
vi.mock("./mr-ci-popover", () => ({
MRCIPopover: ({
mr,
onLink,
onUnlink,
}: {
mr: TaskMR;
onLink: () => void;
onUnlink: () => void;
}) => {
popoverRenderMRIIDs.push(mr.mr_iid);
return (
<div data-testid={POPOVER_STUB_TESTID}>
popover for !{mr.mr_iid}
<button type="button" onClick={onLink}>
link another
</button>
<button type="button" onClick={onUnlink}>
unlink
</button>
</div>
);
},
}));
vi.mock("./mr-topbar-button", () => ({
useMRPopoverInteractions: () => {
const hover = useHoverPopover({ openDelayMs: OPEN_DELAY_MS, closeDelayMs: OPEN_DELAY_MS });
return { usesTouchDrawer: false, ...hover };
},
}));
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
return {
id: "association-1",
task_id: "task-1",
host: "https://gitlab.example",
project_path: "group/project",
mr_iid: 81,
mr_url: "https://gitlab.example/group/project/-/merge_requests/81",
mr_title: "Test MR",
head_branch: "feature",
base_branch: "main",
author_username: "alice",
state: "open",
approval_state: "",
pipeline_state: "",
merge_status: "",
draft: false,
approval_count: 0,
required_approvals: 0,
pipeline_jobs_total: 0,
pipeline_jobs_pass: 0,
reviewer_count: 0,
unapproved_reviewers: 0,
unresolved_discussions: 0,
created_at: "",
updated_at: "",
...overrides,
};
}
function makeAutomation(overrides: Partial<TaskMRAutomationOptions> = {}): TaskMRAutomationOptions {
return {
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: false,
auto_fix_max_rounds: 10,
effective_auto_fix_prompt: "",
using_default_prompt: true,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
review_reviewer_username: "",
updated_at: "",
mr_options: [],
mr_states: [],
...overrides,
};
}
/**
* One `mr_options` row for the default `makeMR()` MR. The badges read each
* MR's own switches (the top-level booleans are an aggregate over every
* linked MR), so a fixture with open MRs must carry a matching row —
* `mr_options: []` alongside an open MR is a state the backend cannot
* produce, since it emits one entry per linked MR.
*/
function makeMROptions(
overrides: Partial<TaskMRAutomationOptionsForMR> = {},
): TaskMRAutomationOptionsForMR {
return {
task_id: "task-1",
repository_id: "",
project_path: "group/project",
mr_iid: 81,
auto_fix_enabled: false,
auto_merge_enabled: false,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
...overrides,
};
}
function resetChipMocks() {
vi.useFakeTimers();
touchMocks.usesTouchDrawer = false;
automationMocks.options = null;
dialogMocks.lastProps = null;
unlinkMock.mockClear();
popoverRenderMRIIDs.length = 0;
}
function teardownChipMocks() {
cleanup();
vi.useRealTimers();
}
describe("MRStatusChip rendering and selection", () => {
beforeEach(resetChipMocks);
afterEach(teardownChipMocks);
it("renders nothing when the task has no linked MRs", () => {
gitlabMocks.mrs = [];
render(createElement(MRStatusChip, { taskId: "task-1" }));
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
});
it("renders nothing when the only linked MR is terminal", () => {
gitlabMocks.mrs = [makeMR({ state: "merged" })];
render(createElement(MRStatusChip, { taskId: "task-1" }));
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
});
it("renders the trigger for a single open MR with the expected attributes", () => {
gitlabMocks.mrs = [makeMR({ pipeline_state: "failure" })];
render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("1");
expect(trigger.getAttribute(DATA_MR_IID)).toBe("81");
expect(trigger.getAttribute("data-mr-state")).toBe("open");
expect(trigger.getAttribute("data-mr-ready-to-merge")).toBe("false");
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("false");
});
it("selects the highest-ranked open MR and reports the total open count", () => {
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
makeMR({ id: "c", mr_iid: 3, state: "merged" }),
];
render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
// Terminal MR is not counted toward data-mr-count.
expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("2");
});
it("renders the auto-fix badge at round 0 with no lifecycle state, and the auto-merge badge", () => {
gitlabMocks.mrs = [makeMR()];
automationMocks.options = makeAutomation({
auto_fix_enabled: true,
auto_fix_max_rounds: 5,
auto_merge_enabled: true,
mr_options: [makeMROptions({ auto_fix_enabled: true, auto_merge_enabled: true })],
});
render(createElement(MRStatusChip, { taskId: "task-1" }));
const badge = screen.getByTestId("mr-status-auto-fix-chip");
expect(badge.textContent).toContain("0/5");
expect(badge.getAttribute("data-auto-fix-exhausted")).toBe("false");
expect(screen.getByTestId("mr-status-auto-merge-chip")).toBeTruthy();
});
it("renders neither badge when automation is disabled", () => {
gitlabMocks.mrs = [makeMR()];
automationMocks.options = makeAutomation();
render(createElement(MRStatusChip, { taskId: "task-1" }));
expect(screen.queryByTestId("mr-status-auto-fix-chip")).toBeNull();
expect(screen.queryByTestId("mr-status-auto-merge-chip")).toBeNull();
});
});
describe("MRStatusChip disclosure interactions", () => {
beforeEach(resetChipMocks);
afterEach(teardownChipMocks);
it("opens the hover popover on the fine-pointer variant and freezes the acted-on MR while open", () => {
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
];
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
act(() => {
fireEvent.mouseEnter(trigger);
vi.advanceTimersByTime(OPEN_DELAY_MS);
});
expect(screen.getByTestId(POPOVER_STUB_TESTID).textContent).toContain("!2");
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
// Store update makes MR "a" the new live selection (now also failed,
// and it outranks "b" by the mr_iid tiebreak) while the popover stays
// open on "b": data-status tracks the live selection, data-mr-iid and
// data-selection-frozen stay frozen on "b" (spec: Selection - freezing
// while the disclosure is open). A `rerender()` is required — mutating
// the hoisted mock array alone does not re-render the component.
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "failure" }),
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
];
act(() => {
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
});
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
});
it("keeps the aria-label naming the acted-on MR's own status while frozen, not the live selection's", () => {
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }), // running, rank 4
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // failed, rank 5
];
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
act(() => {
fireEvent.mouseEnter(trigger);
vi.advanceTimersByTime(OPEN_DELAY_MS);
});
// Frozen on MR "b" (failed) — live and acted-on agree here, a sanity check.
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
expect(trigger.getAttribute("aria-label")).toContain("pipeline failed");
// MR "b" (the frozen, acted-on MR) becomes ready; MR "a" is unchanged and
// now outranks it, so the LIVE selection flips to "a" (still running).
// data-mr-iid stays on "b" (still open, so the freeze holds), and
// data-status tracks the live selection ("running") by design — but the
// aria-label describes the acted-on MR, so it must say "ready to merge"
// (b's own status), never "pipeline running" (spec: Accessibility, "does
// NOT follow data-status's live tracking").
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
makeMR({ id: "b", mr_iid: 2, approval_state: "approved", pipeline_state: "success" }),
];
act(() => {
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
});
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
expect(trigger.getAttribute(DATA_STATUS)).toBe("running");
expect(trigger.getAttribute("aria-label")).toContain("ready to merge");
expect(trigger.getAttribute("aria-label")).not.toContain("pipeline running");
});
it("calls the unlink hook with the acted-on MR's association id", () => {
gitlabMocks.mrs = [makeMR({ id: "assoc-xyz" })];
render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
act(() => {
fireEvent.mouseEnter(trigger);
vi.advanceTimersByTime(OPEN_DELAY_MS);
});
fireEvent.click(screen.getByRole("button", { name: "unlink" }));
expect(unlinkMock).toHaveBeenCalledWith("assoc-xyz");
});
it("closes the popover before opening the link dialog", () => {
gitlabMocks.mrs = [makeMR()];
render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
act(() => {
fireEvent.mouseEnter(trigger);
vi.advanceTimersByTime(OPEN_DELAY_MS);
});
expect(screen.getByTestId(POPOVER_STUB_TESTID)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "link another" }));
expect(screen.queryByTestId(POPOVER_STUB_TESTID)).toBeNull();
expect(dialogMocks.lastProps?.open).toBe(true);
});
});
describe("MRStatusChip freeze and forced close", () => {
beforeEach(resetChipMocks);
afterEach(teardownChipMocks);
// spec: Failure modes / Idempotency and re-render — "if the transitioned MR
// is the frozen one, the popover or drawer closes; if another open MR
// remains the trigger re-selects it." Exercises useFrozenChipSelection's
// shouldForceClose path (mr-status-chip-selection.ts:98), which previously
// had zero coverage (Review round 1 finding #4).
it("force-closes the popover and re-selects the remaining open MR when the frozen MR goes terminal", () => {
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }), // running, rank 4
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // failed, rank 5 — gets frozen
];
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
act(() => {
fireEvent.mouseEnter(trigger);
vi.advanceTimersByTime(OPEN_DELAY_MS);
});
expect(screen.getByTestId(POPOVER_STUB_TESTID)).toBeTruthy();
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
// The frozen MR ("b") merges elsewhere while the popover is open; "a"
// remains the task's only open MR.
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
makeMR({ id: "b", mr_iid: 2, state: "merged" }),
];
act(() => {
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
});
expect(screen.queryByTestId(POPOVER_STUB_TESTID)).toBeNull();
expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("1");
expect(trigger.getAttribute(DATA_MR_IID)).toBe("1");
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("false");
});
// Review round 1 finding #6 (codex): shouldForceClose and actedOnMR's
// fallback to the live selection are computed synchronously in the same
// render as the store update, but closing the disclosure is a `useEffect`
// that only fires after that render commits. Without the `!shouldForceClose`
// guard in MRStatusChipPopover/Drawer, MRCIPopover renders — with active
// merge/unlink controls — for the swapped-in MR ("a") for one commit before
// the effect closes the popover. Tracking every mr_iid MRCIPopover was ever
// rendered with (not just the final settled DOM, which act() would show as
// already-closed either way) is what actually catches this.
it("never renders MRCIPopover for the swapped-in MR during the forced-close transition", () => {
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // frozen
];
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
act(() => {
fireEvent.mouseEnter(trigger);
vi.advanceTimersByTime(OPEN_DELAY_MS);
});
expect(popoverRenderMRIIDs).toEqual([2]);
gitlabMocks.mrs = [
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
makeMR({ id: "b", mr_iid: 2, state: "merged" }),
];
act(() => {
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
});
// Across the whole transition (freeze open -> b vanishes -> popover
// closes), MRCIPopover is only ever rendered for "b" (the MR the user
// opened the popover on) — never for "a" (the swapped-in live MR).
expect(popoverRenderMRIIDs).not.toContain(1);
});
});
describe("MRStatusChip responsive variant", () => {
beforeEach(resetChipMocks);
afterEach(teardownChipMocks);
it("renders the coarse-pointer drawer variant instead of the popover", () => {
touchMocks.usesTouchDrawer = true;
gitlabMocks.mrs = [makeMR()];
render(createElement(MRStatusChip, { taskId: "task-1" }));
const trigger = screen.getByTestId(CHIP_TESTID);
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(screen.getByTestId("mr-status-chip-drawer")).toBeTruthy();
expect(screen.getByTestId(POPOVER_STUB_TESTID).textContent).toContain("!81");
act(() => {
fireEvent.click(screen.getByTestId("mr-status-chip-drawer-close"));
vi.advanceTimersByTime(1000);
});
expect(screen.getByTestId("mr-status-chip-drawer").getAttribute("data-state")).toBe("closed");
});
});