Files
kandev/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts
T
2026-06-08 17:39:37 +01:00

419 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import type { Page } from "@playwright/test";
import { KanbanPage } from "../../pages/kanban-page";
// E2E coverage for the multi-row chip-based Remote tab in the create-task
// dialog. Tests cover: picker selection, paste URL, mixed picker+paste
// rows, add/remove rows, the "GitHub not configured" banner, and mode-switch
// preservation. Modeled on create-task-github-url.spec.ts but exercises the
// new chip-popover UI (testids: source-mode-remote, remote-repo-chip,
// remote-repo-chip-trigger, remote-paste-url-input, remote-add-row,
// remote-chip-remove, remote-repo-search, remote-repo-option).
type TaskRepoAPI = {
repositories?: Array<{
repository_id: string;
base_branch?: string;
position: number;
}>;
};
type RepoAPI = {
id: string;
name: string;
provider?: string;
provider_owner?: string;
provider_name?: string;
};
async function fetchTaskRepos(
apiClient: ApiClient,
taskId: string,
): Promise<NonNullable<TaskRepoAPI["repositories"]>> {
const res = await apiClient.rawRequest("GET", `/api/v1/tasks/${taskId}`);
const body = (await res.json()) as TaskRepoAPI;
return body.repositories ?? [];
}
async function fetchRepoById(apiClient: ApiClient, repoId: string): Promise<RepoAPI> {
const res = await apiClient.rawRequest("GET", `/api/v1/repositories/${repoId}`);
return (await res.json()) as RepoAPI;
}
async function openCreateDialog(testPage: Page, kanban: KanbanPage): Promise<void> {
await kanban.goto();
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
}
async function clickRemoteMode(testPage: Page): Promise<void> {
const remoteBtn = testPage.getByTestId("source-mode-remote");
await expect(remoteBtn).toBeVisible();
await remoteBtn.click();
}
async function seedAccessibleRepos(apiClient: ApiClient): Promise<void> {
// The mock's ListUserRepos returns repos under the authenticated user's
// login (default "mock-user"). Seeding under that key surfaces the repos
// in the Remote-tab picker without needing to also add orgs.
await apiClient.mockGitHubAddRepos("mock-user", [
{ full_name: "mock-user/alpha", owner: "mock-user", name: "alpha", private: false },
{ full_name: "mock-user/beta", owner: "mock-user", name: "beta", private: true },
{ full_name: "mock-user/gamma", owner: "mock-user", name: "gamma", private: false },
]);
await apiClient.mockGitHubAddBranches("mock-user", "alpha", [
{ name: "main" },
{ name: "develop" },
]);
await apiClient.mockGitHubAddBranches("mock-user", "beta", [{ name: "main" }]);
await apiClient.mockGitHubAddBranches("mock-user", "gamma", [{ name: "main" }]);
}
async function pickRepoInChip(testPage: Page, repoFullName: string, chipIndex = 0): Promise<void> {
const triggers = testPage.getByTestId("remote-repo-chip-trigger");
await triggers.nth(chipIndex).click();
// Pick by repo full_name instead of relying on list order — the picker's
// sort can change without notice and we want each call site to spell out
// which repo it expects to land on (later assertions reference the same
// name). Filtering the option testid by text scopes the click to the
// intended row even when the description / private badge add extra text.
const option = testPage.getByTestId("remote-repo-option").filter({ hasText: repoFullName });
await expect(option).toBeVisible({ timeout: 10_000 });
await option.first().click();
}
async function pasteUrlInChip(testPage: Page, url: string, chipIndex = 0): Promise<void> {
const triggers = testPage.getByTestId("remote-repo-chip-trigger");
await triggers.nth(chipIndex).click();
// The chip-popover content is rendered inline with `portal=false`; when
// multiple chips have popovers open (or Radix is mid-transition between
// opens), the same testid can briefly resolve to more than one element,
// so target the last (currently focused) one.
const pasteInput = testPage.getByTestId("remote-paste-url-input").last();
await expect(pasteInput).toBeVisible();
await pasteInput.fill(url);
await pasteInput.press("Enter");
}
test.describe("Task creation from Remote tab (chip picker)", () => {
test.describe.configure({ retries: 1 });
test.beforeEach(async ({ apiClient }) => {
// Reset mock state so a previous test's seeded repos / 503 toggle
// don't bleed into this one. mockGitHubReset clears the toggle and
// mock-controller.reset on the backend additionally clears the
// service's accessible-repos / user-orgs caches.
await apiClient.mockGitHubReset();
});
test("scenario 1: picker selection submits one repo", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
await seedAccessibleRepos(apiClient);
const kanban = new KanbanPage(testPage);
await openCreateDialog(testPage, kanban);
await clickRemoteMode(testPage);
await pickRepoInChip(testPage, "mock-user/alpha");
// After picking, the chip trigger shows owner/name (full_name).
const trigger = testPage.getByTestId("remote-repo-chip-trigger").first();
await expect(trigger).toContainText("mock-user/alpha", { timeout: 5_000 });
// The branch pill auto-loads — once branches resolve, it becomes enabled.
const branchPill = testPage.getByTestId("remote-branch-chip-trigger").first();
await expect(branchPill).toBeEnabled({ timeout: 10_000 });
await testPage.getByTestId("task-title-input").fill("Remote picker happy path");
await testPage.getByTestId("task-description-input").fill("test");
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
await startBtn.click();
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible({
timeout: 10_000,
});
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// Verify the task carries exactly one repository row resolved from the
// picked URL.
const taskList = await apiClient.listTasks(seedData.workspaceId);
const created = taskList.tasks.find((t) => t.title === "Remote picker happy path");
expect(created).toBeDefined();
const repos = await fetchTaskRepos(apiClient, created!.id);
expect(repos).toHaveLength(1);
const repoRow = await fetchRepoById(apiClient, repos[0].repository_id);
expect(repoRow.provider_owner).toBe("mock-user");
expect(repoRow.provider_name).toBe("alpha");
});
test("scenario 2: single paste URL submits one repo", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Seed the branches for the URL we'll paste so the branch pill can
// resolve. The picker is not exercised here so the repo list doesn't
// need to contain this repo.
await apiClient.mockGitHubAddBranches("paste-owner", "paste-repo", [{ name: "main" }]);
const kanban = new KanbanPage(testPage);
await openCreateDialog(testPage, kanban);
await clickRemoteMode(testPage);
await pasteUrlInChip(testPage, "https://github.com/paste-owner/paste-repo");
// After paste, the chip trigger displays a middle-truncated form of the
// URL (e.g. "github.com/pas…ner/paste-repo"). The trailing repo name
// survives the truncation, so assert on that.
const trigger = testPage.getByTestId("remote-repo-chip-trigger").first();
await expect(trigger).toContainText("paste-repo", { timeout: 5_000 });
await testPage.getByTestId("task-title-input").fill("Remote paste happy path");
await testPage.getByTestId("task-description-input").fill("test");
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
await startBtn.click();
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible({
timeout: 10_000,
});
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const taskList = await apiClient.listTasks(seedData.workspaceId);
const created = taskList.tasks.find((t) => t.title === "Remote paste happy path");
expect(created).toBeDefined();
const repos = await fetchTaskRepos(apiClient, created!.id);
expect(repos).toHaveLength(1);
const repoRow = await fetchRepoById(apiClient, repos[0].repository_id);
expect(repoRow.provider_owner).toBe("paste-owner");
expect(repoRow.provider_name).toBe("paste-repo");
});
test("scenario 3: three mixed rows (picker + paste-repo + paste-PR)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await seedAccessibleRepos(apiClient);
// Seed branches + a PR for the paste-PR row so PR auto-resolution works.
await apiClient.mockGitHubAddBranches("paste-owner", "paste-repo", [{ name: "main" }]);
await apiClient.mockGitHubAddBranches("pr-owner", "pr-repo", [
{ name: "main" },
{ name: "feature/pr-branch" },
]);
await apiClient.mockGitHubAddPRs([
{
number: 42,
title: "PR for scenario 3",
state: "open",
head_branch: "feature/pr-branch",
base_branch: "main",
author_login: "mock-user",
repo_owner: "pr-owner",
repo_name: "pr-repo",
},
]);
const kanban = new KanbanPage(testPage);
await openCreateDialog(testPage, kanban);
await clickRemoteMode(testPage);
// Row 0: picker selection (mock-user/alpha)
await pickRepoInChip(testPage, "mock-user/alpha", 0);
await expect(testPage.getByTestId("remote-repo-chip-trigger").nth(0)).toContainText(
"mock-user/alpha",
{ timeout: 5_000 },
);
// Add row 1 and paste a repo URL. Trigger label is middle-truncated
// (e.g. "github.com/pas…ner/paste-repo"), so assert on the trailing
// repo name.
await testPage.getByTestId("remote-add-row").click();
await pasteUrlInChip(testPage, "https://github.com/paste-owner/paste-repo", 1);
await expect(testPage.getByTestId("remote-repo-chip-trigger").nth(1)).toContainText(
"paste-repo",
{ timeout: 5_000 },
);
// Add row 2 and paste a PR URL.
await testPage.getByTestId("remote-add-row").click();
await pasteUrlInChip(testPage, "https://github.com/pr-owner/pr-repo/pull/42", 2);
await expect(testPage.getByTestId("remote-repo-chip-trigger").nth(2)).toContainText("pull/42", {
timeout: 5_000,
});
// All three chips visible.
await expect(testPage.getByTestId("remote-repo-chip")).toHaveCount(3);
await testPage.getByTestId("task-title-input").fill("Remote multi-row");
await testPage.getByTestId("task-description-input").fill("test");
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
await startBtn.click();
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible({
timeout: 10_000,
});
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// Verify the task carries three TaskRepository rows in the order they
// were added.
const taskList = await apiClient.listTasks(seedData.workspaceId);
const created = taskList.tasks.find((t) => t.title === "Remote multi-row");
expect(created).toBeDefined();
const repos = await fetchTaskRepos(apiClient, created!.id);
expect(repos).toHaveLength(3);
const sorted = [...repos].sort((a, b) => a.position - b.position);
const ownersInOrder = await Promise.all(
sorted.map(async (r) => {
const repo = await fetchRepoById(apiClient, r.repository_id);
return `${repo.provider_owner}/${repo.provider_name}`;
}),
);
expect(ownersInOrder).toEqual([
"mock-user/alpha",
"paste-owner/paste-repo",
"pr-owner/pr-repo",
]);
});
test("scenario 4: add then immediately remove the new row", async ({ testPage, apiClient }) => {
await seedAccessibleRepos(apiClient);
const kanban = new KanbanPage(testPage);
await openCreateDialog(testPage, kanban);
await clickRemoteMode(testPage);
await pickRepoInChip(testPage, "mock-user/alpha", 0);
// Add a second row, then remove it via the per-chip × button.
await testPage.getByTestId("remote-add-row").click();
await expect(testPage.getByTestId("remote-repo-chip")).toHaveCount(2);
await testPage.getByTestId("remote-chip-remove").nth(1).click();
await expect(testPage.getByTestId("remote-repo-chip")).toHaveCount(1);
// The original chip still carries its picker selection.
await expect(testPage.getByTestId("remote-repo-chip-trigger").first()).toContainText(
"mock-user/alpha",
);
await testPage.getByTestId("task-title-input").fill("Add then remove");
await testPage.getByTestId("task-description-input").fill("test");
// Form is still submittable.
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
});
test("scenario 5: GitHub not configured shows banner, paste still works", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Flip the mock so /api/v1/github/repos responds with 503
// `github_not_configured`. Paste-URL paths (branch fetch) still work —
// the GET /repos/:owner/:repo/branches endpoint uses the mock client's
// ListRepoBranches, which we leave unaffected by the toggle.
await apiClient.mockGitHubSetReposUnavailable(true);
await apiClient.mockGitHubAddBranches("banner-owner", "banner-repo", [{ name: "main" }]);
const kanban = new KanbanPage(testPage);
await openCreateDialog(testPage, kanban);
await clickRemoteMode(testPage);
// Open the chip popover — picker section should show the "Connect a
// GitHub account" CTA pointing at the integrations settings page.
const trigger = testPage.getByTestId("remote-repo-chip-trigger").first();
await trigger.click();
const banner = testPage.getByText(
"Connect a GitHub account in Settings to pick from your repositories.",
{ exact: false },
);
await expect(banner).toBeVisible({ timeout: 10_000 });
const settingsLink = testPage.locator('a[href="/settings/integrations/github"]');
await expect(settingsLink).toBeVisible();
// The paste input is still rendered and usable — paste a URL.
const pasteInput = testPage.getByTestId("remote-paste-url-input");
await expect(pasteInput).toBeVisible();
await pasteInput.fill("https://github.com/banner-owner/banner-repo");
await pasteInput.press("Enter");
await expect(trigger).toContainText("banner-repo", { timeout: 5_000 });
await testPage.getByTestId("task-title-input").fill("Banner paste fallback");
await testPage.getByTestId("task-description-input").fill("test");
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
await startBtn.click();
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible({
timeout: 10_000,
});
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// Confirm the task was created with the pasted repo.
const taskList = await apiClient.listTasks(seedData.workspaceId);
const created = taskList.tasks.find((t) => t.title === "Banner paste fallback");
expect(created).toBeDefined();
const repos = await fetchTaskRepos(apiClient, created!.id);
expect(repos).toHaveLength(1);
const repoRow = await fetchRepoById(apiClient, repos[0].repository_id);
expect(repoRow.provider_owner).toBe("banner-owner");
expect(repoRow.provider_name).toBe("banner-repo");
});
test("scenario 6: mode-switch preserves remote rows", async ({ testPage, apiClient }) => {
await seedAccessibleRepos(apiClient);
await apiClient.mockGitHubAddBranches("paste-owner", "paste-repo", [{ name: "main" }]);
const kanban = new KanbanPage(testPage);
await openCreateDialog(testPage, kanban);
await clickRemoteMode(testPage);
await pickRepoInChip(testPage, "mock-user/alpha", 0);
await testPage.getByTestId("remote-add-row").click();
await pasteUrlInChip(testPage, "https://github.com/paste-owner/paste-repo", 1);
await expect(testPage.getByTestId("remote-repo-chip")).toHaveCount(2);
// Switch to workspace mode (Repo) and back to Remote — both chips
// should still be present with their selections intact.
await testPage.getByTestId("source-mode-workspace").click();
// Remote chips should not be rendered in workspace mode.
await expect(testPage.getByTestId("remote-repo-chip")).toHaveCount(0);
await clickRemoteMode(testPage);
await expect(testPage.getByTestId("remote-repo-chip")).toHaveCount(2);
await expect(testPage.getByTestId("remote-repo-chip-trigger").nth(0)).toContainText(
"mock-user/alpha",
);
await expect(testPage.getByTestId("remote-repo-chip-trigger").nth(1)).toContainText(
"paste-repo",
);
});
});