Files
kandev/apps/web/e2e/tests/improve-kandev.spec.ts
T
2026-07-28 21:25:39 +01:00

378 lines
15 KiB
TypeScript

import type { Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
/**
* E2E tests for the Improve Kandev dialog. The bootstrap endpoint is mocked
* because it would otherwise clone https://github.com/kdlbs/kandev and shell
* out to the gh CLI. The system/health endpoint is mocked to keep the intro
* screen out of the GhAuthMissing branch regardless of backend state.
*/
const BOOTSTRAP_URL = "**/api/v1/system/improve-kandev/bootstrap";
const FRONTEND_LOG_URL = "**/api/v1/system/improve-kandev/bundle/frontend-log";
const HEALTH_URL = "**/api/v1/system/health";
type ForkStatus = "writable" | "ready" | "blocked_emu" | "unknown";
type BootstrapOverrides = {
github_login?: string;
has_write_access?: boolean;
fork_status?: ForkStatus;
fork_message?: string;
issueWorkflowId?: string;
/**
* When provided, the bootstrap route handler awaits this promise before
* fulfilling the response. Tests use it to keep the dialog in its
* `loading` state long enough to assert the disabled submit UI.
*/
bootstrapHold?: Promise<void>;
};
async function mockImproveKandevApis(
page: Page,
seed: { repositoryId: string; workflowId: string },
overrides: BootstrapOverrides = {},
): Promise<void> {
const bundleDir = "/tmp/kandev-improve-e2e";
const hasWrite = overrides.has_write_access ?? false;
// Default fork_status mirrors how the backend would respond for the given
// write-access value: writable when the user has push access, otherwise
// unknown (the safe fall-through that lets the dialog proceed normally).
const forkStatus: ForkStatus = overrides.fork_status ?? (hasWrite ? "writable" : "unknown");
await page.route(HEALTH_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ healthy: true, issues: [] }),
}),
);
await page.route(BOOTSTRAP_URL, async (route) => {
if (overrides.bootstrapHold) {
await overrides.bootstrapHold;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
repository_id: seed.repositoryId,
workflow_id: seed.workflowId,
issue_workflow_id: overrides.issueWorkflowId ?? seed.workflowId,
branch: "main",
bundle_dir: bundleDir,
bundle_files: {
metadata: `${bundleDir}/metadata.json`,
backend_log: `${bundleDir}/backend.log`,
frontend_log: `${bundleDir}/frontend.log`,
},
github_login: overrides.github_login ?? "octocat",
has_write_access: hasWrite,
fork_status: forkStatus,
...(overrides.fork_message ? { fork_message: overrides.fork_message } : {}),
}),
});
});
await page.route(FRONTEND_LOG_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ path: `${bundleDir}/frontend.log` }),
}),
);
}
test.describe("Improve Kandev dialog", () => {
test("dismissed intro is persisted and later opens the create dialog directly", async ({
testPage,
seedData,
}) => {
await mockImproveKandevApis(testPage, seedData);
await testPage.goto("/");
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const introDialog = testPage.getByRole("dialog", { name: "Improve Kandev" });
const dismissPreference = introDialog.getByTestId("improve-kandev-skip-intro");
await expect(dismissPreference).toBeVisible();
await dismissPreference.click();
await expect
.poll(() =>
testPage.evaluate(() => window.localStorage.getItem("kandev.improveKandev.skipIntro")),
)
.toBe("true");
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible({ timeout: 10_000 });
await testPage.keyboard.press("Escape");
await expect(testPage.getByTestId("create-task-dialog")).toBeHidden();
await testPage.getByTestId("sidebar-improve-kandev-button").click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByText("Preparing kandev repository…")).toHaveCount(0);
await expect(testPage.getByText(/Kandev is open source/)).toHaveCount(0);
});
test("Open issue creates a task in the report-only workflow", async ({
testPage,
apiClient,
seedData,
}) => {
const issueWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Report Kandev issue",
"simple",
);
const issueSteps = await apiClient.listWorkflowSteps(issueWorkflow.id);
const issueStartStep =
issueSteps.steps.find((step) => step.is_start_step) ?? issueSteps.steps[0];
await mockImproveKandevApis(testPage, seedData, { issueWorkflowId: issueWorkflow.id });
await testPage.goto("/");
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
const issueTab = createDialog.getByRole("tab", { name: "Open issue" });
await expect(issueTab).toBeVisible();
await issueTab.click();
await expect(
createDialog.getByText(/only create a GitHub issue.*will not implement/i),
).toBeVisible();
const title = "Document task ownership in parallel sessions";
await createDialog.getByTestId("task-title-input").fill(title);
await createDialog
.getByTestId("task-description-input")
.fill("Users cannot tell which task owns a worktree when several agents run.");
const submit = createDialog.getByTestId("submit-start-agent");
await expect(submit).toBeEnabled({ timeout: 10_000 });
await submit.click();
await expect(createDialog).toBeHidden({ timeout: 10_000 });
await expect
.poll(async () => (await apiClient.listTasks(seedData.workspaceId)).tasks)
.toContainEqual(
expect.objectContaining({
title,
workflow_step_id: issueStartStep.id,
}),
);
});
test("intro → create flow shows workflow preview, useful info, and fork banner", async ({
testPage,
seedData,
}) => {
await mockImproveKandevApis(testPage, seedData, {
github_login: "octocat",
has_write_access: false,
});
await testPage.goto("/");
// Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
await testPage.getByTestId("sidebar-improve-kandev-button").click();
// Intro screen
const introDialog = testPage.getByRole("dialog", { name: "Improve Kandev" });
await expect(introDialog).toBeVisible();
await expect(introDialog.getByText(/Kandev is open source/)).toBeVisible();
await expect(introDialog.getByText(/forks .* to your GitHub account/)).toBeVisible();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
// Create dialog mounts after Contribute is clicked
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
// Bug fix / Feature request kind tabs
await expect(createDialog.getByRole("tab", { name: "Bug fix" })).toBeVisible();
await expect(createDialog.getByRole("tab", { name: "Feature request" })).toBeVisible();
// Contributor banner (fork mode)
await expect(createDialog.getByText("@octocat")).toBeVisible();
await expect(
createDialog.getByText(/agent will fork kdlbs\/kandev to your account/i),
).toBeVisible();
// Workflow preview header
await expect(createDialog.getByText("Workflow", { exact: true })).toBeVisible();
// Useful info collapsible expands and lists shell + skill commands
const usefulInfoTrigger = createDialog.getByRole("button", { name: /useful commands/i });
await expect(usefulInfoTrigger).toBeVisible();
await usefulInfoTrigger.click();
await expect(createDialog.getByText("make install && make dev")).toBeVisible();
await expect(createDialog.getByText("/commit", { exact: true })).toBeVisible();
await expect(createDialog.getByText("/pr-fixup", { exact: true })).toBeVisible();
// Remote-tab toggle is hidden because the repository is locked to kandev
await expect(createDialog.getByTestId("source-mode-remote")).toHaveCount(0);
});
test("contributor banner shows direct-push copy when user has write access", async ({
testPage,
seedData,
}) => {
await mockImproveKandevApis(testPage, seedData, {
github_login: "kandev-maint",
has_write_access: true,
});
await testPage.goto("/");
// Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
await expect(createDialog.getByText("@kandev-maint")).toBeVisible();
await expect(
createDialog.getByText(/push directly to a branch on the upstream repo/i),
).toBeVisible();
});
test("blocks implementation but allows issue reporting for an Enterprise Managed User", async ({
testPage,
seedData,
}) => {
const blockedMessage =
"Your GitHub account appears to be an Enterprise Managed User (EMU) account, " +
"which typically cannot fork repositories outside your owning enterprise. " +
"The PR step would fail when forking kdlbs/kandev. Contact your GitHub admin " +
"if you'd like to enable this, or contribute via another account.";
await mockImproveKandevApis(testPage, seedData, {
github_login: "alice_corp",
has_write_access: false,
fork_status: "blocked_emu",
fork_message: blockedMessage,
});
await testPage.goto("/");
// Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
await expect(createDialog.getByText(blockedMessage)).toBeVisible();
await createDialog.getByTestId("task-title-input").fill("EMU contribution");
await createDialog.getByTestId("task-description-input").fill("Describe the problem");
await expect(createDialog.getByTestId("submit-start-agent")).toBeDisabled();
await createDialog.getByRole("tab", { name: "Open issue" }).click();
await expect(createDialog.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 10_000 });
});
test("locked mode hides workflow picker and source-mode switch (URL / None)", async ({
testPage,
apiClient,
seedData,
}) => {
// Add a second workflow to the seeded workspace so the create dialog
// would normally render the workflow selector (workflows.length > 1).
// The locked-fields path in WorkflowSection must still suppress it.
await apiClient.createWorkflow(seedData.workspaceId, "Extra Workflow", "simple");
await mockImproveKandevApis(testPage, seedData);
await testPage.goto("/");
// Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
// The workflow selector trigger must not render — it would let the user
// switch away from the kandev contribution workflow that the bootstrap
// probe already locked in.
await expect(createDialog.getByTestId("workflow-selector-trigger")).toHaveCount(0);
// Source-mode switch must be hidden entirely when the repo is locked:
// neither the Remote nor the None ("scratch") modes can be reached, so
// the dialog can only ever submit against the bootstrapped kandev repo.
await expect(createDialog.getByTestId("source-mode-remote")).toHaveCount(0);
await expect(createDialog.getByTestId("source-mode-scratch")).toHaveCount(0);
await expect(createDialog.getByTestId("source-mode-workspace")).toHaveCount(0);
});
test("submit button stays disabled with bootstrap reason while bootstrap is in flight", async ({
testPage,
seedData,
}) => {
let releaseBootstrap: () => void = () => {};
const bootstrapHold = new Promise<void>((resolve) => {
releaseBootstrap = resolve;
});
await mockImproveKandevApis(testPage, seedData, { bootstrapHold });
await testPage.goto("/");
// Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
// Banner that explains why the form is partially blocked.
await expect(
createDialog.getByText(/Preparing kandev repository in background/i),
).toBeVisible();
// Fill in title and description so every other submit-blocking reason is
// satisfied — the only remaining blocker should be the pending bootstrap.
await createDialog.getByTestId("task-title-input").fill("Fix overlapping header");
await createDialog
.getByTestId("task-description-input")
.fill("Steps to reproduce: open kanban on a narrow viewport.");
const submit = createDialog.getByTestId("submit-start-agent");
await expect(submit).toBeDisabled();
// Hover the wrapper so the keyboard-shortcut tooltip (which carries the
// disabled reason as its description) becomes visible.
await createDialog.getByTestId("submit-start-agent-wrapper").hover();
await expect(testPage.getByText(/Preparing kandev repository/i).first()).toBeVisible({
timeout: 5_000,
});
// Pressing the Cmd/Ctrl+Enter shortcut must also be gated by the bootstrap
// guard — if it bypassed guardedHandleSubmit, the dialog would close and
// a task would be created with an empty repositoryId.
await createDialog.getByTestId("task-description-input").focus();
await testPage.keyboard.press("ControlOrMeta+Enter");
await expect(createDialog).toBeVisible();
await expect(
createDialog.getByText(/Preparing kandev repository in background/i),
).toBeVisible();
// Releasing the bootstrap response transitions the dialog to "ready" and
// the submit button must become actionable.
releaseBootstrap();
await expect(submit).toBeEnabled({ timeout: 10_000 });
});
});