fix(gitlab): stop MR badge specs racing workflow auto-advance (#2443)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-08 22:45:32 +02:00
committed by GitHub
parent f344fc3774
commit 3829bea5a1
4 changed files with 254 additions and 36 deletions
@@ -0,0 +1,195 @@
/**
* Regression cover for the MR detail panel being silently discarded when the
* user clicks the topbar button while a task's session is still loading.
*
* Navigating straight to /t/:id runs dockview's `onReady` before the
* session→env mapping hydrates, so it builds a throwaway DEFAULT layout with a
* null env; `switchEnvLayout` then replaces that layout wholesale via
* `api.fromJSON` once the env arrives. A panel added in that gap is thrown
* away and the click appears to do nothing. `useMRDesktopReviewOpener` must
* therefore re-apply the open once the env's layout has settled.
*/
import { createElement, useSyncExternalStore } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MRTopbarButton } from "./mr-topbar-button";
import type { TaskMR } from "@/lib/types/gitlab";
const TRIGGER_TESTID = "mr-topbar-button";
const gitlabMocks = vi.hoisted(() => ({ mrs: [] as TaskMR[] }));
// A genuinely reactive stand-in for the dockview store: MRTopbarButton is
// memo()-wrapped, so a plain rerender with identical props would never re-read
// module-level mock values. Subscribers make store changes drive the re-render
// the same way zustand does in the app.
const dockviewMocks = vi.hoisted(() => {
const listeners = new Set<() => void>();
const state = {
addMRPanel: vi.fn(),
api: {} as object | null,
currentLayoutEnvId: null as string | null,
isRestoringLayout: false,
};
return {
state,
listeners,
get addMRPanel() {
return state.addMRPanel;
},
set(patch: Partial<typeof state>) {
Object.assign(state, patch);
listeners.forEach((l) => l());
},
};
});
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: unknown) => unknown) =>
useSyncExternalStore(
(onChange: () => void) => {
dockviewMocks.listeners.add(onChange);
return () => dockviewMocks.listeners.delete(onChange);
},
() => selector(dockviewMocks.state),
),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
tasks: { activeTaskId: "task-1", activeSessionId: "session-1" },
workspaces: { activeId: "workspace-1" },
repositories: { itemsByWorkspaceId: { "workspace-1": [] } },
removeTaskMR: vi.fn(),
setMobileSessionReview: vi.fn(),
}),
}));
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
useGitLabAvailable: () => true,
useTaskMRs: () => gitlabMocks.mrs,
useWorkspaceMRs: vi.fn(),
}));
vi.mock("@/hooks/domains/kanban/use-task-by-id", () => ({
useTaskById: () => ({ id: "task-1", repositories: [] }),
}));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("./task-mr-link-dialog", () => ({ TaskMRLinkDialog: () => null }));
vi.mock("./mr-automation-controls", () => ({ MRAutomationControls: () => null }));
vi.mock("./mr-ci-popover", () => ({ MRCIPopover: () => null }));
vi.mock("@/hooks/use-compact-task-chrome", () => ({ useTouchDrawer: () => false }));
function singleMR(): 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: "opened",
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: "",
} as TaskMR;
}
describe("MRTopbarButton desktop review opener — layout settling", () => {
beforeEach(() => {
gitlabMocks.mrs = [singleMR()];
dockviewMocks.addMRPanel.mockClear();
dockviewMocks.set({ api: {}, currentLayoutEnvId: null, isRestoringLayout: false });
});
afterEach(cleanup);
it("re-opens the panel after the env-switch restore that would have discarded it", () => {
// Session still loading: dockview api exists, but no env has been adopted.
render(createElement(MRTopbarButton));
act(() => {
fireEvent.click(screen.getByTestId(TRIGGER_TESTID));
});
// Opened immediately so the click gives instant feedback...
expect(dockviewMocks.addMRPanel).toHaveBeenCalled();
const callsBeforeRestore = dockviewMocks.addMRPanel.mock.calls.length;
// ...then the env hydrates and switchEnvLayout replaces the layout,
// discarding that panel. Once it settles, the open must be re-applied.
act(() => {
dockviewMocks.set({ currentLayoutEnvId: "env-1", isRestoringLayout: false });
});
expect(dockviewMocks.addMRPanel.mock.calls.length).toBeGreaterThan(callsBeforeRestore);
});
it("does not re-open the panel on a later layout change once the env has settled", () => {
// Env already adopted at click time — the common case for an established
// session. The intent must not be retained, or a later preset switch or
// maximize would resurrect a panel the user has since closed.
dockviewMocks.set({ currentLayoutEnvId: "env-1" });
render(createElement(MRTopbarButton));
act(() => {
fireEvent.click(screen.getByTestId(TRIGGER_TESTID));
});
const callsAfterClick = dockviewMocks.addMRPanel.mock.calls.length;
expect(callsAfterClick).toBeGreaterThan(0);
// A later user-initiated layout restore (preset switch / maximize).
act(() => {
dockviewMocks.set({ isRestoringLayout: true });
});
act(() => {
dockviewMocks.set({ isRestoringLayout: false });
});
expect(dockviewMocks.addMRPanel.mock.calls.length).toBe(callsAfterClick);
});
it("retains the intent when a stale env id makes the layout look settled mid-remount", () => {
// `setApi(null)` does not reset `currentLayoutEnvId`, so navigating between
// tasks leaves the previous env id in place while the new dockview mounts.
// "Settled" must not be trusted on its own — without the dockviewReady
// check the click would be dropped and no later API init could open it.
dockviewMocks.set({ api: null, currentLayoutEnvId: "stale-env", isRestoringLayout: false });
render(createElement(MRTopbarButton));
act(() => {
fireEvent.click(screen.getByTestId(TRIGGER_TESTID));
});
expect(dockviewMocks.addMRPanel).not.toHaveBeenCalled();
act(() => {
dockviewMocks.set({ api: {}, currentLayoutEnvId: "env-1" });
});
expect(dockviewMocks.addMRPanel).toHaveBeenCalled();
});
it("defers the open until dockview exists when clicked before onReady", () => {
dockviewMocks.set({ api: null });
render(createElement(MRTopbarButton));
act(() => {
fireEvent.click(screen.getByTestId(TRIGGER_TESTID));
});
expect(dockviewMocks.addMRPanel).not.toHaveBeenCalled();
act(() => {
dockviewMocks.set({ api: {}, currentLayoutEnvId: "env-1" });
});
expect(dockviewMocks.addMRPanel).toHaveBeenCalled();
});
});
@@ -242,26 +242,43 @@ function useMRDesktopReviewOpener(mobile: boolean) {
const addMRPanel = useDockviewStore((state) => state.addMRPanel);
const dockviewReady = useDockviewStore((state) => state.api !== null);
const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout);
// Not restoring right now is necessary but NOT sufficient. Navigating
// straight to /t/:id runs dockview's `onReady` while the session→env mapping
// is still hydrating, so it builds a throwaway DEFAULT layout with a null env
// (see tryRestoreLayout) and `isRestoringLayout` is false the whole time.
// `switchEnvLayout` only replaces that layout wholesale via `api.fromJSON`
// once the env arrives, discarding any panel added in the gap — the user
// clicks the MR button during session load and nothing ever opens. The env
// being adopted is what marks the layout as actually settled.
const envAdopted = useDockviewStore((state) => state.currentLayoutEnvId !== null);
const layoutSettled = envAdopted && !isRestoringLayout;
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const setMobileSessionReview = useAppStore((state) => state.setMobileSessionReview);
const [pendingDesktopMR, setPendingDesktopMR] = useState<TaskMR | null>(null);
useEffect(() => {
if (!dockviewReady || isRestoringLayout || !pendingDesktopMR) return;
if (!dockviewReady || !layoutSettled || !pendingDesktopMR) return;
// Only re-apply once the layout has actually settled: at that point no
// switchEnvLayout restore can land afterwards and discard this add. The
// intent is cleared here so a later user-initiated layout change (preset
// switch, maximize) cannot resurrect a panel the user has since closed.
openDesktopMRReview(addMRPanel, pendingDesktopMR);
setPendingDesktopMR(null);
}, [addMRPanel, dockviewReady, isRestoringLayout, pendingDesktopMR]);
}, [addMRPanel, dockviewReady, layoutSettled, pendingDesktopMR]);
return (mr: TaskMR) => {
if (mobile) {
if (activeSessionId) openMobileMRReview(setMobileSessionReview, activeSessionId, mr);
return;
}
if (!dockviewReady || isRestoringLayout) {
setPendingDesktopMR(mr);
} else {
openDesktopMRReview(addMRPanel, mr);
}
// Open straight away when we can, so the panel appears on click rather
// than after the session finishes loading — but never into a layout that
// is being replaced right now. Retain the intent until dockview exists AND
// the env layout has settled: `currentLayoutEnvId` outlives `setApi(null)`,
// so a stale env id can leave `layoutSettled` true while dockview is still
// remounting, and dropping the intent there would lose the click entirely.
if (dockviewReady && !isRestoringLayout) openDesktopMRReview(addMRPanel, mr);
if (!dockviewReady || !layoutSettled) setPendingDesktopMR(mr);
};
}
@@ -43,17 +43,15 @@ async function seedBoardTaskWithMR(apiClient: ApiClient, seedData: SeedData, tit
updated_at: now,
},
]);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// No agent, deliberately — see the desktop spec's seedBoardTask. An
// auto-started session's `on_turn_complete` moves the card out of the start
// column mid-test, which is what made the desktop badge specs flaky.
const task = await apiClient.createTask(seedData.workspaceId, title, {
description: "MR badge fixture task",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
task_id: task.id,
repository_id: seedData.repositoryId,
@@ -73,9 +71,7 @@ test.describe("mobile GitLab MR badge on the Kanban card", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardInColumn("Mobile MR badge task", seedData.startStepId)).toBeVisible(
{ timeout: 45_000 },
);
await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 45_000 });
const icon = kanban.board.getByTestId(`mr-task-icon-${task.id}`);
await expect(icon).toBeVisible({ timeout: 15_000 });
@@ -74,9 +74,24 @@ async function ensureGitLabConfigured(apiClient: ApiClient, seedData: SeedData):
});
}
/**
* Seeds a board card with NO agent, deliberately.
*
* `createTaskWithAgent` auto-starts a session on the start step (the Kanban
* template's `on_enter: auto_start_agent`), and that same step carries
* `on_turn_complete: move_to_step review` — so the card leaves the start
* column the instant the mock agent's turn ends. Every assertion here was
* therefore racing that transition: on a loaded CI runner the agent won and
* the card was already in Review, and when it moved mid-assertion the badge
* detached from under `.hover()` and the test burned its full timeout.
*
* The badge is a pure function of the task's linked MRs, so an agent turn
* adds nothing to these ACs. Without one, no turn ever completes and the
* card stays put.
*/
async function seedBoardTask(apiClient: ApiClient, seedData: SeedData, title: string) {
return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
description: "/e2e:simple-message",
return apiClient.createTask(seedData.workspaceId, title, {
description: "MR badge fixture task",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
@@ -98,9 +113,10 @@ test.describe("GitLab MR badge on the Kanban card", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardInColumn("Single MR badge task", seedData.startStepId)).toBeVisible(
{ timeout: 45_000 },
);
// Located by task id rather than by (title, column): which column a card
// sits in is irrelevant to the badge, and coupling to it makes the
// assertion race any workflow transition (see seedBoardTask).
await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 45_000 });
const icon = kanban.board.getByTestId(`mr-task-icon-${task.id}`);
await expect(icon).toBeVisible({ timeout: 15_000 });
@@ -118,9 +134,7 @@ test.describe("GitLab MR badge on the Kanban card", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardInColumn("No MR badge task", seedData.startStepId)).toBeVisible({
timeout: 45_000,
});
await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 45_000 });
await expect(kanban.board.getByTestId(`mr-task-icon-${task.id}`)).toHaveCount(0);
});
@@ -159,9 +173,7 @@ test.describe("GitLab MR badge on the Kanban card", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardInColumn("Multi MR badge task", seedData.startStepId)).toBeVisible({
timeout: 45_000,
});
await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 45_000 });
const icon = kanban.board.getByTestId(`mr-task-icon-${task.id}`);
await expect(icon).toBeVisible({ timeout: 15_000 });
@@ -200,11 +212,9 @@ test.describe("GitLab MR badge on the Kanban card", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardInColumn("PR and MR badge task", seedData.startStepId)).toBeVisible(
{ timeout: 45_000 },
);
const card = kanban.taskCard(task.id);
await expect(card).toBeVisible({ timeout: 45_000 });
const card = kanban.taskCardInColumn("PR and MR badge task", seedData.startStepId);
const prIcon = card.getByTestId(`pr-task-icon-${task.id}`);
const mrIcon = card.getByTestId(`mr-task-icon-${task.id}`);
await expect(prIcon).toBeVisible({ timeout: 15_000 });