Files
kandev/apps/web/e2e/tests/pr/pr-detection.spec.ts
T
João Salavisa c853044f1d feat(ui): collapse commits and PR changes by default in changes panel (#781)
* feat(ui): collapse commits and PR changes by default in changes panel

* fix(e2e): re-expand commits section after remount in squash and focus tests

CommitsSection unmounts mid-transition (when both hasStaged and hasCommits
are briefly false), so it remounts with defaultCollapsed=true and loses
the user's expanded state. Re-expand before assertions that follow such
remounts.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-02 21:54:28 +01:00

466 lines
16 KiB
TypeScript

import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
test.describe("PR auto-detection", () => {
/**
* Tests the full PR auto-detection pipeline: the backend poller detects
* a PR on GitHub (mock) and associates it with the task -- without
* manually calling mockGitHubAssociateTaskPR.
*
* Detection flow:
* Poller (backend, every 30s)
* -> reconcileWatches -> EnsurePRWatch (if missing)
* -> checkPRWatches -> FindPRByBranch (mock)
* -> AssociatePRWithTask -> github.task_pr.updated (WS event)
* -> PR icon appears on kanban card
*
* Prerequisites for detection to work:
* - Repository has provider=github with owner/name set
* - TaskRepository has checkout_branch set
* - Mock GitHub has a PR matching the checkout branch
* - Agent has started (creates PR watch via ensureSessionPRWatch)
*/
test("detects PR automatically via backend poller", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(180_000);
// --- Seed workflow: Inbox -> Working (auto_start -> Done) -> Done ---
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
"PR Auto-Detection Workflow",
);
const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const workingStep = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
await apiClient.updateWorkflowStep(workingStep.id, {
prompt: 'e2e:message("done")\n{{task_prompt}}',
events: {
on_enter: [{ type: "auto_start_agent" }],
on_turn_complete: [{ type: "move_to_step", config: { step_id: doneStep.id } }],
},
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
// --- Create repository with GitHub provider info ---
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const githubRepo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "GitHub Test Repo",
provider: "github",
provider_owner: "testorg",
provider_name: "testrepo",
});
// --- Setup mock GitHub ---
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
// --- Create task with checkout_branch set ---
const task = await apiClient.createTask(seedData.workspaceId, "Auto-Detect PR Task", {
workflow_id: workflow.id,
workflow_step_id: inboxStep.id,
agent_profile_id: seedData.agentProfileId,
repositories: [
{
repository_id: githubRepo.id,
checkout_branch: "main",
},
],
});
// Navigate to kanban BEFORE moving tasks so the WebSocket is subscribed
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Move task to Working -> auto_start -> mock agent completes -> Done
// This creates a PR watch via ensureSessionPRWatch during task start
await apiClient.moveTask(task.id, workflow.id, workingStep.id);
await expect(kanban.taskCardInColumn("Auto-Detect PR Task", doneStep.id)).toBeVisible({
timeout: 45_000,
});
// --- Add PR to mock GitHub AFTER task completion ---
await apiClient.mockGitHubAddPRs([
{
number: 99,
title: "Agent-created PR",
state: "open",
head_branch: "main",
base_branch: "develop",
author_login: "test-user",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 30,
deletions: 5,
},
]);
// --- Open the task to trigger on-demand sync (github.task_pr.sync) ---
await kanban.taskCardInColumn("Auto-Detect PR Task", doneStep.id).click();
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
// The useTaskPR hook triggers github.task_pr.sync which calls TriggerPRSync.
// Since a PR watch was created during task start (ensureSessionPRWatch),
// TriggerPRSync finds the PR via FindPRByBranch and associates it.
await expect(session.prTopbarButton()).toBeVisible({ timeout: 60_000 });
await expect(session.prTopbarButton()).toContainText("#99");
});
test("does not show PR for tasks without a matching PR", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
// --- Seed workflow ---
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "PR Negative Workflow");
const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const workingStep = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
await apiClient.updateWorkflowStep(workingStep.id, {
prompt: 'e2e:message("done")\n{{task_prompt}}',
events: {
on_enter: [{ type: "auto_start_agent" }],
on_turn_complete: [{ type: "move_to_step", config: { step_id: doneStep.id } }],
},
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const githubRepo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "GitHub No-PR Repo",
provider: "github",
provider_owner: "testorg",
provider_name: "testrepo",
});
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
// No PRs added to mock GitHub -- backend poller should find nothing
const task = await apiClient.createTask(seedData.workspaceId, "No PR Task", {
workflow_id: workflow.id,
workflow_step_id: inboxStep.id,
agent_profile_id: seedData.agentProfileId,
repositories: [
{
repository_id: githubRepo.id,
checkout_branch: "main",
},
],
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await apiClient.moveTask(task.id, workflow.id, workingStep.id);
await expect(kanban.taskCardInColumn("No PR Task", doneStep.id)).toBeVisible({
timeout: 45_000,
});
// Open the session
await kanban.taskCardInColumn("No PR Task", doneStep.id).click();
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
// Verify no PR button
await expect(session.prTopbarButton()).not.toBeVisible({ timeout: 10_000 });
});
/**
* Verifies the sync-before-delete bug fix: when a PR is merged on GitHub,
* the backend poller syncs the merged state to the TaskPR record before
* deleting the watch. The PR icon on the kanban card should update to
* show the merged state.
*/
test("updates PR status to merged", async ({ testPage, apiClient, seedData, backend }) => {
test.setTimeout(180_000);
// --- Seed workflow: Inbox -> Working (auto_start -> Done) -> Done ---
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
"PR Merged Status Workflow",
);
const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const workingStep = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
await apiClient.updateWorkflowStep(workingStep.id, {
prompt: 'e2e:message("done")\n{{task_prompt}}',
events: {
on_enter: [{ type: "auto_start_agent" }],
on_turn_complete: [{ type: "move_to_step", config: { step_id: doneStep.id } }],
},
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
// --- Create repository with GitHub provider info ---
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const githubRepo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "GitHub Merged PR Repo",
provider: "github",
provider_owner: "testorg",
provider_name: "testrepo",
});
// --- Setup mock GitHub ---
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
// --- Create task with checkout_branch ---
const task = await apiClient.createTask(seedData.workspaceId, "Merged PR Task", {
workflow_id: workflow.id,
workflow_step_id: inboxStep.id,
agent_profile_id: seedData.agentProfileId,
repositories: [
{
repository_id: githubRepo.id,
checkout_branch: "main",
},
],
});
// Navigate to kanban and subscribe to WS events
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Move task to Working -> auto_start -> mock agent completes -> Done
await apiClient.moveTask(task.id, workflow.id, workingStep.id);
await expect(kanban.taskCardInColumn("Merged PR Task", doneStep.id)).toBeVisible({
timeout: 45_000,
});
// --- Add OPEN PR to mock GitHub ---
await apiClient.mockGitHubAddPRs([
{
number: 101,
title: "Feature branch PR",
state: "open",
head_branch: "main",
base_branch: "develop",
author_login: "test-user",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 20,
deletions: 3,
},
]);
// --- Open the task to trigger on-demand sync and detect the PR ---
await kanban.taskCardInColumn("Merged PR Task", doneStep.id).click();
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
// useTaskPR triggers github.task_pr.sync -> TriggerPRSync -> FindPRByBranch
await expect(session.prTopbarButton()).toBeVisible({ timeout: 60_000 });
await expect(session.prTopbarButton()).toContainText("#101");
// --- Update mock PR to MERGED state ---
await apiClient.mockGitHubAddPRs([
{
number: 101,
title: "Feature branch PR",
state: "merged",
head_branch: "main",
base_branch: "develop",
author_login: "test-user",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 20,
deletions: 3,
merged_at: "2026-03-30T12:00:00Z",
},
]);
// --- Verify PR topbar button updates to merged state ---
// The backend poller (every 30s) syncs the merged state and broadcasts
// via WS. The PRTopbarButton re-renders with the purple merged icon.
await expect(session.prTopbarButton().locator(".text-purple-500").first()).toBeVisible({
timeout: 90_000,
});
});
});
test.describe("PR external detection", () => {
/**
* Verifies that when a PR is created externally (e.g. via `gh pr create`)
* after a task session is already running, the PR button appears in the
* topbar and the Changes panel shows the PR files and commits.
*
* This uses mockGitHubAssociateTaskPR which directly creates the TaskPR
* record and publishes the WS event -- no poller needed.
*/
test("shows PR button and changes after external PR creation", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
// --- Seed workflow ---
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "PR Detection Workflow");
const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const workingStep = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
await apiClient.updateWorkflowStep(workingStep.id, {
prompt: 'e2e:message("done")\n{{task_prompt}}',
events: {
on_enter: [{ type: "auto_start_agent" }],
on_turn_complete: [{ type: "move_to_step", config: { step_id: doneStep.id } }],
},
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
// --- Setup mock GitHub ---
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
// --- Create 2 tasks ---
const featureTask = await apiClient.createTask(seedData.workspaceId, "Feature Task", {
workflow_id: workflow.id,
workflow_step_id: inboxStep.id,
agent_profile_id: seedData.agentProfileId,
repository_ids: [seedData.repositoryId],
});
const helperTask = await apiClient.createTask(seedData.workspaceId, "Helper Task", {
workflow_id: workflow.id,
workflow_step_id: inboxStep.id,
agent_profile_id: seedData.agentProfileId,
repository_ids: [seedData.repositoryId],
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Move tasks to Working -> auto_start -> mock agent completes -> Done
await apiClient.moveTask(featureTask.id, workflow.id, workingStep.id);
await apiClient.moveTask(helperTask.id, workflow.id, workingStep.id);
await expect(kanban.taskCardInColumn("Feature Task", doneStep.id)).toBeVisible({
timeout: 45_000,
});
await expect(kanban.taskCardInColumn("Helper Task", doneStep.id)).toBeVisible({
timeout: 45_000,
});
// --- Open Feature Task session ---
await kanban.taskCardInColumn("Feature Task", doneStep.id).click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
// --- Verify NO PR button initially ---
await expect(session.prTopbarButton()).not.toBeVisible({ timeout: 5_000 });
// --- Simulate external PR creation ---
await apiClient.mockGitHubAddPRs([
{
number: 42,
title: "Add feature X",
state: "open",
head_branch: "feat/feature-x",
base_branch: "main",
author_login: "test-user",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 50,
deletions: 10,
},
]);
await apiClient.mockGitHubAddPRFiles("testorg", "testrepo", 42, [
{ filename: "feature.ts", status: "added", additions: 40, deletions: 0 },
{ filename: "index.ts", status: "modified", additions: 10, deletions: 10 },
]);
await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 42, [
{
sha: "abc1111222233334444555566667777aaaabbbb",
message: "implement feature X",
author_login: "test-user",
author_date: "2026-03-03T12:00:00Z",
},
]);
// Associate PR with task directly via mock API
await apiClient.mockGitHubAssociateTaskPR({
task_id: featureTask.id,
owner: "testorg",
repo: "testrepo",
pr_number: 42,
pr_url: "https://github.com/testorg/testrepo/pull/42",
pr_title: "Add feature X",
head_branch: "feat/feature-x",
base_branch: "main",
author_login: "test-user",
additions: 50,
deletions: 10,
});
// --- Switch to Helper Task and back to trigger PR re-fetch ---
await session.taskInSidebar("Helper Task").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await session.taskInSidebar("Feature Task").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// --- Verify PR button appears in topbar ---
await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 });
await expect(session.prTopbarButton()).toContainText("#42");
// --- Verify PR data in Changes panel ---
await session.clickTab("Changes");
await expect(session.prFilesSection()).toBeVisible({ timeout: 15_000 });
await session.expandPRChangesSection();
await session.expandCommitsSection();
await expect(session.prFilesSection().getByText("feature.ts")).toBeVisible();
await expect(session.prFilesSection().getByText("index.ts")).toBeVisible();
await expect(session.commitsSection()).toBeVisible();
await expect(session.commitsSection().getByText("implement feature X")).toBeVisible();
});
});