Files
kandev/apps/web/e2e/tests/create-task-github-url.spec.ts
T
José Almeida 123b2f6eed feat: start task from GitHub URL (#365)
* feat(backend): accept github_url in task creation and clone on launch

Add github_url field to task creation DTO and propagate through HTTP/WS
handlers and service layer. When a task is created with a GitHub URL
instead of a local repository, the service resolves (or creates) the
repository record via FindOrCreateRepository and triggers a clone/fetch
via repoclone.Cloner during executor pre-flight.

Wire RepoCloner into the orchestrator executor so remote repositories
are fetched before the agent process starts. On resume, RepoUpdater
performs a fetch to ensure the worktree is up-to-date.

* feat(backend): add GitHub branches endpoint for remote repos

Add ListRepoBranches to the GitHub client interface (GHClient,
PATClient, NoopClient, MockClient) and expose it via
GET /api/v1/github/repos/:owner/:repo/branches.

The mock controller gains POST /api/v1/github/mock/branches for
seeding branch data in E2E tests.

* feat(web): add GitHub URL input to task create dialog

Add a toggle in the task create dialog that switches between the
existing repository selector and a free-text GitHub URL input. When a
valid GitHub URL is entered (owner/repo parsed), branches are fetched
from the new backend endpoint and auto-selected.

The dialog state, effects, validation, and submit logic are extended to
support the github_url flow alongside the existing repository flow.

* test(e2e): add tests for task creation from GitHub URL

Add E2E tests verifying:
- Full flow: toggle to GitHub URL mode, enter URL, create task, verify
  agent runs and completes in session view
- Toggle behavior: switching between GitHub URL and repository selector
  modes updates UI correctly

* fix(web): dynamic URL input sizing and extract RepoSourceInput

Replace fixed-width GitHub URL input with size-based auto-sizing
(matching InlineTaskName pattern). Extract RepoSourceInput component
from DialogHeaderContent to stay within cyclomatic complexity limits.

Add testId prop to Combobox and wire it to ExecutorProfileSelector
for E2E test targeting.

* test(e2e): add worktree executor test and fix GitHub URL repo seeding

Update GitHub URL E2E tests to pre-seed a repository with GitHub
provider info pointing to the local git repo, avoiding actual clone
attempts during task creation.

Add test for GitHub URL task creation with worktree executor type,
verifying the full flow including executor profile selection.

Extend ApiClient with listExecutors and provider fields on
createRepository for test seeding.

* fix(backend): use protocol-aware clone URL for provider-backed repos

The ensureRepoCloned function was hardcoding HTTPS clone URLs via
repositoryCloneURL(), but the user's git setup may require SSH.

Add BuildCloneURL to the RepoCloner interface so the executor uses
the Cloner's configured protocol (SSH/HTTPS) instead of always
generating HTTPS URLs. Falls back to HTTPS if BuildCloneURL fails.

This fixes workspace loading issues (terminal, file tree) for cloned
repos when the user has SSH-only authentication configured.

* test(e2e): enhance GitHub URL test with terminal and changes panel verification

* feat(web): add inline validation for GitHub URL input

- Show inline error for invalid URL format (not matching github.com/owner/repo)
- Show inline error when repository is not found or not accessible
- Mock client returns error for unknown repos (matches real GitHub API)
- E2E tests for invalid URL, nonexistent repo, and error-clearing on valid input

* fix(web): fix GitHub URL branch auto-select and E2E test stability

- Pick branch by name (main/master) for GitHub URL mode instead of
  using selectPreferredBranch which returns origin-prefixed names
- Add test timeout for worktree executor test
- Use shorter filename in terminal test to avoid xterm line-wrap issues

* fix(web): fix GitHub URL dialog polish and wrong-repo bug

UI fixes:
- Align toggle button text with dropdown text content (pl-3)
- Match GitHub URL input height to dropdown trigger (min-h-9)
- Position error text absolutely to prevent layout shift
- Remove redundant toast on repo-not-found (inline error suffices)

Bug fix:
- Prevent repo auto-select from overriding GitHub URL mode by
  guarding useRepositoryAutoSelectEffect with useGitHubUrl
- Reorder getRepositoriesPayload to check useGitHubUrl first,
  preventing stale repositoryId from winning over the GitHub URL

Add E2E test verifying correct repo usage across consecutive tasks.

* fix(web): fix dialog height mismatch and error text overlap

- Use exact h-9 on GitHub URL input to match dropdown trigger height
- Move error text from absolute-positioned overlay to inline next to
  the toggle button, preventing overlap while keeping layout stable

* fix(web): make GitHub URL input auto-size to content

Reduce minimum size from 30 to 20 and shorten placeholder so the
"/ task-name" sits closer to the URL text, matching the dropdown's
compact w-auto behavior.

* fix(web): polish GitHub URL input sizing, floating error, and placeholder color

- Match input height (h-7) and padding (px-2) to Combobox trigger button
- Size input width to exactly fit placeholder text
- Show validation error as floating popover below input instead of inline
- Keep placeholder text grey even when input has error state
- Extract useGitHubUrlState hook to fix max-lines-per-function lint

* fix(backend): return proper HTTP status codes from GitHub branches endpoint

Add GitHubAPIError type to distinguish upstream GitHub API status codes.
The branches endpoint now returns 404 (not 500) for non-existent repos,
which the logging middleware correctly logs as DEBUG instead of ERROR.

* feat(web): show error toast when a task session fails to start

Add session failure notification plumbing: WS handler detects FAILED
state with error_message, pushes to Zustand store, and a global bridge
component shows an error toast via the ToastProvider. Also adds
data-testid attributes to toast elements and types error_message on
the session state changed payload.

* style: fix prettier formatting in task-create-dialog and related files

* fix(web): extract ToastList component to fix max-lines-per-function lint

* fix: address code review feedback for GitHub URL task creation

- Preserve 403 status instead of collapsing to 401 in branches endpoint
- Use clone-generic error instead of remote-docker-specific in ensureRepoCloned
- Trim whitespace in GitHub URL validation (backend + frontend)
- Add GitHubURL field to wsUpdateTask path
- Fix DefaultBranch mapping to prefer repoInput.DefaultBranch over BaseBranch
- Clear githubBranchesLoading on early-return paths to prevent stuck state
- Gate GitHub branch loading by mode to avoid false loading states
- Encode owner/repo in API path for special characters
- Add aria-label, aria-invalid, aria-errormessage for accessibility

* fix(e2e): improve diff-update test stability with fresh locator and longer timeout

* fix(backend): add --paginate flag to gh api branches call for repos with >100 branches

* fix: address second round of code review feedback

- Add pagination support to PATClient.ListRepoBranches for repos with >100 branches
- Validate owner/repo in mock controller addBranches endpoint
- Use aria-describedby instead of aria-errormessage for broader AT support
- Add aria-live region to toast container for screen reader announcements
- Assert absence of FIRST_MODIFICATION in diff-update E2E test

* fix(backend): downgrade HTTP request logger from error to warn for 4xx/5xx responses

* fix: address third round of code review feedback

- Validate repositoryID/localPath in UpdateRepositoryLocalPath
- Copy slices in mock client to prevent external mutation
- Extract GitHub header constants and shared setter in PATClient
- Redact clone URL from logs to prevent credential leaks
- Guard against persisting empty localPath after clone
- Show fallback toast message when FAILED state has no error_message
2026-03-05 00:47:58 +00:00

360 lines
14 KiB
TypeScript

import { test, expect } from "../fixtures/test-base";
import { KanbanPage } from "../pages/kanban-page";
import { SessionPage } from "../pages/session-page";
test.describe("Task creation from GitHub URL", () => {
// Allow one retry for transient backend port-allocation issues on cold start.
test.describe.configure({ retries: 1 });
test("can create a task using a GitHub URL with workspace interaction", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// Pre-seed a repository with GitHub provider info pointing to the real local git repo.
// This lets FindOrCreateRepository find the repo (with its local_path) when the
// GitHub URL is submitted, avoiding an actual clone.
const repoDir = `${backend.tmpDir}/repos/e2e-repo`;
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "test-owner/test-repo",
provider: "github",
provider_owner: "test-owner",
provider_name: "test-repo",
});
// Seed mock GitHub branches for the repo we'll reference
await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [
{ name: "main" },
{ name: "develop" },
{ name: "feature/test" },
]);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
// Toggle to GitHub URL mode
const toggleBtn = testPage.getByTestId("toggle-github-url");
await expect(toggleBtn).toBeVisible();
await toggleBtn.click();
// Enter a GitHub URL
const urlInput = testPage.getByTestId("github-url-input");
await expect(urlInput).toBeVisible();
await urlInput.fill("https://github.com/test-owner/test-repo");
// Fill in title and description
await testPage.getByTestId("task-title-input").fill("GitHub URL Task");
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
// Wait for the start button to become enabled (branches + agent profile resolved)
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
// Click "Start task"
await startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// The new task card appears on the kanban board
const card = kanban.taskCardByTitle("GitHub URL Task");
await expect(card).toBeVisible({ timeout: 10_000 });
// Click the card to navigate to the session
await card.click();
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
// Wait for the agent to complete
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 30_000,
});
// Session transitions to idle
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
// ── Workspace interaction: terminal + changes ──
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
// Create a file via the terminal
const testFileName = "ghtest";
await session.typeInTerminal(`touch ${testFileName}`);
// Switch to the Changes tab — the new file proves the terminal command worked
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
await expect(session.changes.getByText(testFileName)).toBeVisible({ timeout: 15_000 });
});
test("can create a GitHub URL task with worktree executor", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// Pre-seed the GitHub-backed repository
const repoDir = `${backend.tmpDir}/repos/e2e-repo`;
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "test-owner/test-repo",
provider: "github",
provider_owner: "test-owner",
provider_name: "test-repo",
});
await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [
{ name: "main" },
{ name: "develop" },
]);
// Look up the worktree executor profile
const { executors } = await apiClient.listExecutors();
const worktreeExec = executors.find((e) => e.type === "worktree");
const worktreeProfile = worktreeExec?.profiles?.[0];
if (!worktreeProfile) {
test.skip(true, "No worktree executor profile available");
return;
}
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
// Toggle to GitHub URL mode
await testPage.getByTestId("toggle-github-url").click();
await testPage.getByTestId("github-url-input").fill("https://github.com/test-owner/test-repo");
// Fill in title and description
await testPage.getByTestId("task-title-input").fill("Worktree GitHub Task");
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
// Wait for selectors to be ready
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
// Select the worktree executor profile
const executorSelector = testPage.getByTestId("executor-profile-selector");
await executorSelector.click();
await testPage.getByRole("option", { name: /Worktree/i }).click();
// Submit
await startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
const card = kanban.taskCardByTitle("Worktree GitHub Task");
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 30_000,
});
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
});
test("shows error for invalid GitHub URL format", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
// Toggle to GitHub URL mode
await testPage.getByTestId("toggle-github-url").click();
const urlInput = testPage.getByTestId("github-url-input");
const errorEl = testPage.getByTestId("github-url-error");
// Type an invalid URL — error should appear
await urlInput.fill("not-a-github-url");
await expect(errorEl).toBeVisible({ timeout: 5_000 });
await expect(errorEl).toContainText("Invalid GitHub URL");
// Clear the input — error should disappear
await urlInput.fill("");
await expect(errorEl).not.toBeVisible({ timeout: 5_000 });
// Type another invalid URL (missing repo)
await urlInput.fill("https://github.com/owner-only");
await expect(errorEl).toBeVisible({ timeout: 5_000 });
});
test("shows error for nonexistent repository", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
// Toggle to GitHub URL mode and enter a repo that isn't seeded in mock data
await testPage.getByTestId("toggle-github-url").click();
await testPage
.getByTestId("github-url-input")
.fill("https://github.com/no-such-owner/no-such-repo");
// The error should appear after the branch fetch fails
const errorEl = testPage.getByTestId("github-url-error");
await expect(errorEl).toBeVisible({ timeout: 10_000 });
await expect(errorEl).toContainText("not found or not accessible");
});
test("clears error when valid repository URL is entered", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// Pre-seed a valid repo
const repoDir = `${backend.tmpDir}/repos/e2e-repo`;
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "test-owner/test-repo",
provider: "github",
provider_owner: "test-owner",
provider_name: "test-repo",
});
await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [{ name: "main" }]);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
await testPage.getByTestId("toggle-github-url").click();
const urlInput = testPage.getByTestId("github-url-input");
const errorEl = testPage.getByTestId("github-url-error");
// Start with an invalid URL — error appears
await urlInput.fill("bad-url");
await expect(errorEl).toBeVisible({ timeout: 5_000 });
// Replace with a valid, seeded URL — error clears, branches load
await urlInput.fill("https://github.com/test-owner/test-repo");
await expect(errorEl).not.toBeVisible({ timeout: 10_000 });
// Branches should load and start button should become enabled
const startBtn = testPage.getByTestId("submit-start-agent");
await testPage.getByTestId("task-title-input").fill("Validation Test");
await testPage.getByTestId("task-description-input").fill("test");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
});
test("uses correct repository when creating tasks from different GitHub URLs", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// Seed two distinct GitHub-backed repositories
const repoDirA = `${backend.tmpDir}/repos/e2e-repo`;
await apiClient.createRepository(seedData.workspaceId, repoDirA, "main", {
name: "owner-a/repo-a",
provider: "github",
provider_owner: "owner-a",
provider_name: "repo-a",
});
await apiClient.mockGitHubAddBranches("owner-a", "repo-a", [{ name: "main" }]);
const repoDirB = `${backend.tmpDir}/repos/e2e-repo-b`;
const { execSync } = await import("child_process");
const fs = await import("fs");
fs.mkdirSync(repoDirB, { recursive: true });
const gitEnv = {
...process.env,
GIT_AUTHOR_NAME: "E2E Test",
GIT_AUTHOR_EMAIL: "e2e@test.local",
GIT_COMMITTER_NAME: "E2E Test",
GIT_COMMITTER_EMAIL: "e2e@test.local",
};
execSync("git init -b main", { cwd: repoDirB, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: repoDirB, env: gitEnv });
await apiClient.createRepository(seedData.workspaceId, repoDirB, "main", {
name: "owner-b/repo-b",
provider: "github",
provider_owner: "owner-b",
provider_name: "repo-b",
});
await apiClient.mockGitHubAddBranches("owner-b", "repo-b", [{ name: "main" }]);
const kanban = new KanbanPage(testPage);
await kanban.goto();
// ── First task from repo-a ──
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await testPage.getByTestId("toggle-github-url").click();
await testPage.getByTestId("github-url-input").fill("https://github.com/owner-a/repo-a");
await testPage.getByTestId("task-title-input").fill("Task A");
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
await startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
await expect(kanban.taskCardByTitle("Task A")).toBeVisible({ timeout: 10_000 });
// ── Second task from repo-b (different URL) ──
await kanban.createTaskButton.first().click();
await expect(dialog).toBeVisible();
await testPage.getByTestId("toggle-github-url").click();
await testPage.getByTestId("github-url-input").fill("https://github.com/owner-b/repo-b");
await testPage.getByTestId("task-title-input").fill("Task B");
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
await startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// Both cards visible — second task created successfully on its own repo
await expect(kanban.taskCardByTitle("Task B")).toBeVisible({ timeout: 10_000 });
// Navigate to Task B session and verify agent completes
await kanban.taskCardByTitle("Task B").click();
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 30_000,
});
});
test("can toggle between GitHub URL and repository selector", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
// Initially the toggle says "or paste a GitHub URL"
const toggleBtn = testPage.getByTestId("toggle-github-url");
await expect(toggleBtn).toHaveText("or paste a GitHub URL");
// Toggle to GitHub URL mode
await toggleBtn.click();
await expect(testPage.getByTestId("github-url-input")).toBeVisible();
await expect(toggleBtn).toHaveText("or select a repository");
// Toggle back to repository selector
await toggleBtn.click();
await expect(testPage.getByTestId("github-url-input")).not.toBeVisible();
await expect(toggleBtn).toHaveText("or paste a GitHub URL");
});
});