Files
kandev/apps/web/components/kanban/pipeline-kanban-shared-source.test.tsx
T

287 lines
10 KiB
TypeScript

/**
* AC-UI-PIPELINE-ROW-002.4 and AC-UI-PIPELINE-ROW-003.8: the Kanban card and
* the pipeline row must render the same task menu and the same inline status
* strip from the same shared source, not two implementations that happen to
* agree. See "Shared-source tests" in
* docs/specs/ui/system-design/pipeline-task-row-parity.md#observability —
* without this pair, a duplicated-JSX fork would pass every other test.
*/
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { t } from "@/lib/i18n";
import { KanbanCard, type Task } from "@/components/kanban-card";
import { resolveTaskRepositoryChips } from "@/components/kanban-card-repositories";
import type { WorkflowStep } from "@/components/kanban-column";
import type { Repository } from "@/lib/types/http";
import type { TaskPR } from "@/lib/types/github";
import { Graph2TaskPipeline } from "./graph2-task-pipeline";
afterEach(() => {
cleanup();
});
const WORKSPACE_ID = "workspace-1";
const STEPS: WorkflowStep[] = [
{ id: "step-1", title: "Triage", color: "#888" },
{ id: "step-2", title: "In Progress", color: "#888" },
{ id: "step-3", title: "Done", color: "#888" },
];
const REPOSITORIES: Repository[] = [
{
id: "repo-1",
workspace_id: WORKSPACE_ID,
name: "alpha",
provider_owner: "acme",
provider_name: "alpha",
} as Repository,
{
id: "repo-2",
workspace_id: WORKSPACE_ID,
name: "beta",
provider_owner: "acme",
provider_name: "beta",
} as Repository,
];
/**
* Carries a blocked state, a queued step, a review state, two repositories,
* more than one session, a subagent count and a linked PR, per the
* Observability fixture.
*/
const TASK: Task = {
id: "task-1",
title: "Shared source fixture task",
workflowStepId: "step-2",
state: "IN_PROGRESS",
repositoryId: "repo-1",
repositories: [
{ id: "link-1", repository_id: "repo-1", position: 0 },
{ id: "link-2", repository_id: "repo-2", position: 1 },
],
sessionCount: 2,
activeSubagentCount: 3,
reviewStatus: "changes_requested",
queuedForStepId: "step-3",
queuedForStepTitle: "Done",
blocked: true,
blockedReason: "pending",
dependsOn: [{ id: "dep-1", title: "Predecessor task" }],
};
const TASK_PR: TaskPR = {
id: "pr-1",
workspace_id: WORKSPACE_ID,
task_id: TASK.id,
owner: "acme",
repo: "alpha",
pr_number: 7,
pr_url: "",
pr_title: "Fix",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open",
review_state: "",
checks_state: "",
mergeable_state: "unknown",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 0,
checks_passing: 0,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
};
const EXTERNAL_LINK_AVAILABILITY = { jira: false, linear: false, sentry: false };
const SHARED_INITIAL_STATE = {
workspaces: { activeId: WORKSPACE_ID, items: [] },
taskPRs: {
byTaskId: { [TASK.id]: [TASK_PR] },
workspaceId: WORKSPACE_ID,
workspaceContextGeneration: 0,
deletedAssociationIdsByTaskId: {},
},
};
function renderKanbanCard() {
return render(
<ToastProvider>
<StateProvider initialState={SHARED_INITIAL_STATE}>
<TooltipProvider delayDuration={0}>
<KanbanCard
task={TASK}
workspaceId={WORKSPACE_ID}
externalLinkAvailability={EXTERNAL_LINK_AVAILABILITY}
repositoryChips={resolveTaskRepositoryChips(TASK, REPOSITORIES)}
steps={STEPS}
onDelete={() => undefined}
/>
</TooltipProvider>
</StateProvider>
</ToastProvider>,
);
}
function renderPipelineRow(task: Task = TASK, isMultiSelectMode = false) {
return render(
<ToastProvider>
<StateProvider initialState={SHARED_INITIAL_STATE}>
<TooltipProvider delayDuration={0}>
<Graph2TaskPipeline
task={task}
steps={STEPS}
moveTargetSteps={STEPS}
workspaceId={WORKSPACE_ID}
externalLinkAvailability={EXTERNAL_LINK_AVAILABILITY}
repositories={REPOSITORIES}
onMoveTask={() => undefined}
onPreviewTask={() => undefined}
onOpenTask={() => undefined}
onDeleteTask={() => undefined}
isMultiSelectMode={isMultiSelectMode}
/>
</TooltipProvider>
</StateProvider>
</ToastProvider>,
);
}
describe("Pipeline title preview in multi-select mode", () => {
// @covers AC-TASKS-RICH-TASK-TITLE-PREVIEWS-001.2
it.each([
["outside", false, true],
["inside", true, false],
] as const)("is %s multi-select mode", (_mode, isMultiSelectMode, previewEnabled) => {
renderPipelineRow({ ...TASK, parentTaskId: "parent-1" }, isMultiSelectMode);
if (previewEnabled) {
expect(screen.getByTestId("task-title-preview-trigger")).not.toBeNull();
} else {
expect(screen.queryByTestId("task-title-preview-trigger")).toBeNull();
}
});
});
async function openMenuAndSnapshot() {
const trigger = screen.getByRole("button", { name: t("kanban:moreOptions") });
fireEvent.pointerDown(trigger, { button: 0, pointerId: 1 });
fireEvent.click(trigger);
const items = await waitFor(() => {
const found = screen.getAllByRole("menuitem");
expect(found.length).toBeGreaterThan(0);
return found;
});
return items.map((item) => ({
text: item.textContent,
disabled: item.getAttribute("data-disabled") !== null,
// Token match, not substring: every DropdownMenuItem's base class
// literally contains "text-destructive" as part of the always-present
// `data-[variant=destructive]:text-destructive` selector, so a substring
// check matches every item regardless of its actual destructive state.
destructive: item.className.split(/\s+/).includes("text-destructive"),
}));
}
describe("Shared-source parity — task menu (AC-UI-PIPELINE-ROW-002.4)", () => {
it("renders identical menu entry identities, order, and disabled/destructive state", async () => {
renderKanbanCard();
const cardEntries = await openMenuAndSnapshot();
cleanup();
renderPipelineRow();
const rowEntries = await openMenuAndSnapshot();
expect(rowEntries).toEqual(cardEntries);
expect(rowEntries.length).toBeGreaterThan(0);
});
});
/**
* The indicator identities the AC-UI-PIPELINE-ROW-003.8 order names, each
* with a selector stable on both surfaces. Order here is the order asserted
* below, not the AC's left-to-right order — the two surfaces group these
* differently around the title and step run, so what must match is that
* *this* subsequence renders in the *same relative order* on both, not that
* either surface's absolute layout matches the other.
*/
const SESSION_COUNT_TEXT = t("kanban:sessionCount", { count: 2 });
const SESSION_COUNT_ID = "session-count";
function indicatorSelectors(): Array<{ id: string; selector: string }> {
return [
{ id: "pr", selector: `[data-testid="pr-task-icon-${TASK.id}"]` },
{ id: "blocked", selector: '[data-testid="kanban-card-blocked-badge"]' },
{ id: "queued", selector: `text:${t("kanban:queuedForStep", { step: "Done" })}` },
{ id: SESSION_COUNT_ID, selector: `text:${SESSION_COUNT_TEXT}` },
{ id: "review-state", selector: `text:${t("kanban:changesRequested")}` },
];
}
/** Resolves each selector against `container` and returns the ids present, ordered by DOM position. */
function orderedIndicatorIds(container: HTMLElement): string[] {
const found = indicatorSelectors().flatMap(({ id, selector }) => {
const element = selector.startsWith("text:")
? Array.from(container.querySelectorAll("*")).find(
(el) => el.textContent === selector.slice("text:".length) && el.children.length === 0,
)
: container.querySelector(selector);
return element ? [{ id, element }] : [];
});
return found
.sort((a, b) => {
const position = a.element.compareDocumentPosition(b.element);
// eslint-disable-next-line no-bitwise
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
// eslint-disable-next-line no-bitwise
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
})
.map(({ id }) => id);
}
describe("Shared-source parity — inline status strip (AC-UI-PIPELINE-ROW-003.8)", () => {
it("renders the same indicator set, in the same relative order, on both surfaces", async () => {
const { container: cardContainer } = renderKanbanCard();
await waitFor(() =>
expect(cardContainer.querySelector(`[data-testid="pr-task-icon-${TASK.id}"]`)).not.toBeNull(),
);
const cardOrder = orderedIndicatorIds(cardContainer);
// Every indicator in the fixture must actually be present on the card —
// otherwise a fixture that silently fails to seed one would make this
// comparison vacuously pass.
expect(cardOrder).toEqual(["pr", "blocked", "queued", SESSION_COUNT_ID, "review-state"]);
cleanup();
const { container: rowContainer } = renderPipelineRow();
await waitFor(() =>
expect(rowContainer.querySelector(`[data-testid="pr-task-icon-${TASK.id}"]`)).not.toBeNull(),
);
const rowOrder = orderedIndicatorIds(rowContainer);
// Session count is the one member the row seats outside the shared
// strip: it belongs to the information column, which precedes every
// inline indicator, so it is compared for presence rather than position.
const withoutSessionCount = (ids: string[]) => ids.filter((id) => id !== SESSION_COUNT_ID);
expect(withoutSessionCount(rowOrder)).toEqual(withoutSessionCount(cardOrder));
expect(rowOrder).toContain(SESSION_COUNT_ID);
// Exactly one session-count element on the row — the information column
// renders it, and the shared badges block suppresses its own.
expect(screen.getAllByText(SESSION_COUNT_TEXT)).toHaveLength(1);
const info = screen.getByTestId("pipeline-row-info");
expect(info.textContent).toContain(SESSION_COUNT_TEXT);
});
});