Files
kandev/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts
T
2026-06-12 09:50:35 +01:00

281 lines
11 KiB
TypeScript

import { execSync } from "node:child_process";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
async function waitForReviewTasksInBackend(
apiClient: ApiClient,
workspaceId: string,
reviewStepId: string,
titles: string[],
) {
await expect
.poll(
async () => {
const { tasks } = await apiClient.listTasks(workspaceId);
return titles.filter((title) =>
tasks.some((task) => task.title === title && task.workflow_step_id === reviewStepId),
).length;
},
{
timeout: 60_000,
intervals: [500, 1_000, 2_000],
message: `Expected ${titles.length} PR tasks to exist in the Review step`,
},
)
.toBe(titles.length);
}
async function waitForReviewTaskCards(
testPage: Page,
kanban: KanbanPage,
reviewStepId: string,
titles: string[],
) {
async function visibleCardCount() {
const visible = await Promise.all(
titles.map((title) => kanban.taskCardInColumn(title, reviewStepId).isVisible()),
);
return visible.filter(Boolean).length;
}
const expected = titles.length;
await expect
.poll(visibleCardCount, { timeout: 10_000, intervals: [500, 1_000] })
.toBe(expected)
.catch(async () => {
// The watcher creates tasks from a background poll. If task.created events
// arrive before the kanban subscription is fully attached, backend state is
// correct but one card can be absent until SSR rehydrates the board.
await testPage.reload();
await kanban.board.waitFor({ state: "visible", timeout: 15_000 });
});
await expect
.poll(visibleCardCount, {
timeout: 90_000,
intervals: [500, 1_000, 2_000],
message: `Expected all ${expected} PR task cards (${titles.join(", ")}) to appear in the Review column`,
})
.toBe(expected);
}
test.describe("PR watcher dockview layout stability", () => {
/**
* Verifies the dockview layout stays correct when switching between
* PR tasks auto-created by the review watcher.
*
* Flow:
* 1. PR watcher detects 3 PRs and auto-creates tasks in the background
* 2. Open PR task 1 from the kanban homepage
* 3. Toggle plan mode (adds plan panel)
* 4. Switch to PR task 2 via sidebar → should have default layout
* 5. Switch to PR task 3 via sidebar → should have default layout
*
* Setup:
* Review step with auto_start_agent on_enter — all 3 tasks get primary
* sessions immediately, so sidebar navigation takes the fast (synchronous) path.
*/
test("layout remains correct when switching between PR watcher tasks with plan mode", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// The review watcher auto-starts a mock agent for each of the 3 PR tasks,
// so 3 git checkouts + agent boots run concurrently up front. Under CI shard
// contention that inherent workload, plus three subsequent session
// navigations, can outlast the default budget — give the whole flow headroom.
test.setTimeout(180_000);
// --- Register the GitHub repo so the PR watcher can resolve it to a real
// local path (the seed repo created in test-base.ts has no provider info,
// and clone-from-real-GitHub fails for the mocked testorg/testrepo). ---
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "GitHub Test Repo",
provider: "github",
provider_owner: "testorg",
provider_name: "testrepo",
});
// Create the PR head branches in the local seed repo so the executor's
// git checkout for auto-started review tasks succeeds (in production these
// branches would have been fetched during clone).
const gitEnv = makeGitEnv(backend.tmpDir);
for (const branch of ["fix/auth", "feat/dashboard", "docs/update"]) {
execSync(`git branch -f ${branch} main`, { cwd: repoDir, env: gitEnv });
}
// --- Seed workflow ---
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
"PR Watcher Layout Workflow",
);
const reviewStep = await apiClient.createWorkflowStep(workflow.id, "Review", 0);
// Configure auto-start so the review watcher immediately launches mock agents
// for all 3 PR tasks. By the time the test reaches the sidebar navigation
// steps, tasks 2 and 3 already have a primarySessionId in kanbanMulti.snapshots
// → handleSelectTask takes the fast (synchronous) path instead of the slow
// HTTP + WS round-trip that times out in CI.
await apiClient.updateWorkflowStep(reviewStep.id, {
events: { on_enter: [{ type: "auto_start_agent" }] },
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
// --- Seed mock GitHub data ---
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
// Add 3 PRs with requested_reviewers so the review watcher picks them up
await apiClient.mockGitHubAddPRs([
{
number: 101,
title: "Fix auth bug",
state: "open",
head_branch: "fix/auth",
base_branch: "main",
author_login: "alice",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 10,
deletions: 2,
requested_reviewers: [{ login: "test-user", type: "User" }],
},
{
number: 202,
title: "Add dashboard",
state: "open",
head_branch: "feat/dashboard",
base_branch: "main",
author_login: "bob",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 80,
deletions: 5,
requested_reviewers: [{ login: "test-user", type: "User" }],
},
{
number: 303,
title: "Update docs",
state: "open",
head_branch: "docs/update",
base_branch: "main",
author_login: "charlie",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 30,
deletions: 10,
requested_reviewers: [{ login: "test-user", type: "User" }],
},
]);
// Navigate to kanban BEFORE creating the review watch so the WS is
// subscribed when task.created/task.updated events fire.
const kanban = new KanbanPage(testPage);
await kanban.goto();
// --- Create review watch (triggers initial poll → auto-creates 3 tasks) ---
await apiClient.createReviewWatch(
seedData.workspaceId,
workflow.id,
reviewStep.id,
seedData.agentProfileId,
{
repos: [{ owner: "testorg", name: "testrepo" }],
prompt: "Review {{pr.link}}",
},
);
// --- Wait for all 3 PR tasks to appear in the Review column ---
const prTask1Title = "PR #101: Fix auth bug";
const prTask2Title = "PR #202: Add dashboard";
const prTask3Title = "PR #303: Update docs";
// Poll for all 3 cards together in one shared budget rather than three
// independent per-card waits. The cards are created concurrently and render
// out of order under load; waiting for the whole set at once avoids
// cumulative per-card timeout pressure and settles as soon as all 3 appear.
const prTaskTitles = [prTask1Title, prTask2Title, prTask3Title];
await waitForReviewTasksInBackend(apiClient, seedData.workspaceId, reviewStep.id, prTaskTitles);
await waitForReviewTaskCards(testPage, kanban, reviewStep.id, prTaskTitles);
// --- Click PR task 1 to enter session view ---
await kanban.taskCardInColumn(prTask1Title, reviewStep.id).click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
// Verify initial default layout for task 1
await expect(session.chat).toBeVisible({ timeout: 10_000 });
await expect(session.sidebar).toBeVisible();
// Wait for the mock agent to complete and the layout to be stable before toggling
// plan mode. Without this, an in-flight layout restore could swallow the panel add.
// Use `waitForChatIdle` (vs. raw `idleInput().waitFor`) so the helper's
// reload-and-retry recovery covers the rare case where the WS-driven idle
// signal misses its window under shard pressure.
await session.waitForChatIdle({ timeout: 30_000 });
// --- Toggle plan mode on task 1 ---
await session.togglePlanMode();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
await expect(session.chat).toBeVisible();
await expect(session.sidebar).toBeVisible();
// --- Switch to PR task 2 via sidebar ---
await session.clickTaskInSidebar(prTask2Title);
// With auto_start_agent configured, task 2 already has a primarySessionId in
// kanbanMulti.snapshots → handleSelectTask takes the synchronous fast path
// and setActiveSession is called immediately.
await expect(
testPage
.getByRole("navigation", { name: "breadcrumb" })
.getByText(prTask2Title, { exact: false }),
).toBeVisible({ timeout: 30_000 });
// Task 2: full layout intact — sidebar, chat accessible, changes panel visible,
// plan panel must NOT leak from task 1, and layout fills the viewport
await expect(session.sidebar).toBeVisible({ timeout: 15_000 });
await session.expectNoLayoutGap();
await expect(session.planPanel).not.toBeVisible({ timeout: 10_000 });
// Changes panel verifies the right column rendered
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
// Chat tab may be renamed (e.g. "#1 Mock • Mock Default") — use stable testid
await session.clickSessionChatTab();
await expect(session.chat).toBeVisible({ timeout: 10_000 });
// --- Switch to PR task 3 via sidebar ---
await session.clickTaskInSidebar(prTask3Title);
// Same fast-path navigation as task 2
await expect(
testPage
.getByRole("navigation", { name: "breadcrumb" })
.getByText(prTask3Title, { exact: false }),
).toBeVisible({ timeout: 30_000 });
// Task 3: same layout checks
await expect(session.sidebar).toBeVisible({ timeout: 15_000 });
await session.expectNoLayoutGap();
await expect(session.planPanel).not.toBeVisible({ timeout: 10_000 });
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
// Chat tab may be renamed (e.g. "#1 Mock • Mock Default") — use stable testid
await session.clickSessionChatTab();
await expect(session.chat).toBeVisible({ timeout: 10_000 });
});
});