b001aa1eb8
* feat(web): introduce unified AppSidebar and retire legacy nav surfaces
Mounts a single collapsible AppSidebar at the root layout that replaces the
former WorkspaceRail, OfficeSidebar, and dockview-embedded sidebar pane.
Wires per-section expand + collapse state through a new UI slice with
localStorage persistence, ports projects/agents/settings/tasks sections, and
trims integrations / quick-chat / settings buttons + the home breadcrumb
from the dockview top bar (debug-only MoreToolsMenu kept). Strips the
sidebar column from every preset and the panel registry; persisted
sidebar panels are dropped by the existing sanitize layer at restore time.
* test(web): point workflow-filter e2e at AppSidebar's Home link
The dockview top bar no longer renders the IconHome breadcrumb after the
unified sidebar landed — the home navigation now lives in AppSidebar's
primary nav. Update the workflow-filter spec to click that link instead.
* feat(web): wire app-sidebar primary controls, nested settings, and footer migration
Gates office-only nav (Inbox, Agents, Projects, New Task) behind the office
feature flag, renames Tasks → Kanban as the bottom flex-grow section, replaces
the gradient workspace picker with a minimal trigger when office is off, and
moves Stats/Improve/What's-New/Office out of the kanban top bar into the
AppSidebar footer + a dedicated Integrations section. Expands Settings into a
collapsible deep tree mirroring the old SettingsAppSidebar so /settings pages
can drop their nested SidebarProvider.
* feat(web): make app-sidebar resizable and inline task top bar actions
Adds a persistent drag-resize handle on the AppSidebar's right edge that
clamps between APP_SIDEBAR_EXPANDED_WIDTH and 30vw; width is stored on the
appSidebar slice and persisted to localStorage. Drops the dockview top bar's
overflow popover (TopbarActionOverflow) in favor of always-inline clusters,
and replaces the debug "More tools" dropdown with a single debug toggle button.
* feat(web): polish app-sidebar headers and consolidate task views
Section headers now lead with a clean uppercase label, drop the mono
clash, lift contrast from /60 to /70, and end with the collapse chevron
at the right so the section body aligns flush under the label.
The Tasks section's filter bar (view chips + gear in a card-tinted
strip) is replaced by a compact view dropdown that lives in the section
header. Drag-to-reorder lives in the existing filter popover, which is
now reachable from the same gear button.
Settings moves below Tasks so the kanban list keeps the flex-grow seat
in the sidebar.
* feat(web): move settings group chevrons to the right
* fix(web): drop chat from home topbar; calm tasks group headers
The kanban top bar's Quick Chat button is removed now that Quick Chat
lives in the AppSidebar; workspaceId stops being threaded into the
tablet and desktop header variants.
Group headers inside the Tasks section (kanban workflow steps) lose
the opaque background strip and the section-label uppercase styling
so they read as inline subgroup rows, not duplicate section dividers.
The tasks-section wrapper drops its negative margin so the group rows
sit flush under the TASKS label instead of bleeding out past it.
* fix(web): restore agent logos on settings tree profile leaves
SettingsLeaf gains a leadingIcon slot for pre-rendered visuals; agents
group passes <AgentLogo agentName={agent.name}> so each profile row
shows its agent's logo like it did before the old SettingsAppSidebar
was retired.
* feat(web): brand the sidebar, move New Task off the kanban top bar
The Add task button (CreateTaskTopbarButton) leaves both the desktop
and tablet kanban headers; New Task is now an unconditional primary
nav item on the AppSidebar, falling back to a disabled affordance when
no workspace is active.
AppSidebar header gains a Kandev wordmark that links to /, with a
single-letter K mark in collapsed-rail mode. PageTopbar's root variant
no longer renders the parent brand label when backLabel is empty, so
the home view's top-left "Kandev" goes away in favor of the sidebar.
* feat(web): unbind sidebar toggle by default; drop Home from breadcrumbs
TOGGLE_SIDEBAR's configurable default is now UNBOUND_SHORTCUT (the new
sentinel for "no binding"); matchesShortcut already returns false on
an empty key, so the keybind hook needs no change. Keyboard shortcuts
settings show "Unbound", expose a Clear (X) button when a custom
binding is set, and the reset icon falls back to unbound for entries
whose default is unbound.
PageTopbar's breadcrumb variant no longer prepends the Home back-link
when backHref is "/" (the AppSidebar provides Home navigation).
* feat(web): move New Task into the sidebar, retire the workspace selector
- Extract AppSidebarNewTaskItem (office vs regular create dialog) with tests
- Drop the Workspace selector from the kanban display dropdown and mobile
menu sheet; the sidebar workspace picker is now the sole switcher
- Factor SectionHeader out of AppSidebarSection; animate fixed sections via
Collapsible while the grow (Tasks) section stays flex-driven
- Polish brand header, share SIDEBAR_ITEM_ACTIVE accent-bar classes, move
task group chevrons to the right, drop the task-item selected fill
- Bump dockview layout keys to v3 and filter the orphaned 'sidebar' column
so pre-AppSidebar default layouts no longer render a broken grid
- Point the cross-workspace isolation e2e at the sidebar picker
* feat(web): sidebar settings mode, subtask shortcut, accordion settings nav
- Restore subtask creation: the sidebar New Task row now shows a trailing
subtask affordance when viewing a task (regular mode), reopening the
NewSubtaskDialog the retired dockview header dropdown used to provide
- Fix settings pages not scrolling: scroll the content region (topbar stays
pinned) via min-h-0 + overflow-y-auto
- Add a footer gear that toggles a settings-only sidebar takeover; the gear
spins on toggle. Mode is transient (never persisted) and force-expands a
collapsed rail on entry
- Make the settings nav a single-open accordion: opening one top-level group
closes the others and reveals its subsections; route changes re-sync the
open group. Extract the tree into a reusable SettingsTree
- Repoint subtask e2e specs at the new sidebar affordance
* test(web): restore create-task e2e flow after New Task moved to sidebar
The New Task relocation removed the kanban-header create-task-button and made
the sidebar entry auto-navigate + open the office dialog under the e2e profile
(office on), breaking ~20 specs that drive the regular task-create dialog.
- Give the sidebar New Task the create-task-button testid so KanbanPage
resolves it again
- Drop the post-create auto-navigation so creating a task stays on the board
(matches the retired kanban button; specs find/click the new card)
- Ungate the subtask affordance from office mode (subtasks use the compact
NewSubtaskDialog in both modes, as the old dropdown did)
- Add a useRegularMode() helper that runs a spec file with KANDEV_FEATURES_OFFICE
disabled (backend.restart bracket) and apply it to the 18 create-task specs
- Point the System sidebar-navigation spec at a /settings/system page so the
single-open settings accordion has that group expanded
Validated: create-task, create-task-disabled-tooltip, create-task-branch-selector,
and subtask all green (modulo pre-existing idleInput agent-timing flakes that
CI retries absorb).
* fix(web): sidebar task navigation, settings-mode close + transitions
- Clicking a task in the global sidebar from a non-task page now navigates to
the task route (mounts the dockview) instead of only rewriting the URL via an
in-place layout switch that assumed the dockview was already mounted
- Make the settings-mode 'Settings' header a close affordance (the footer gear
is far from the tree); clicking it exits the takeover regardless of which
group is expanded, and it fades in when opened
- Fade in the Tasks (grow) section content on expand, matching the other
sections' animated reveal
- Enable next-themes disableTransitionOnChange so dark/light flips switch every
surface in the same instant rather than each animating at its own duration
- Add a regression e2e for sidebar→dockview navigation
* fix(web): clear sidebar task highlight when off the task route
The AppSidebar is global, so tasks.activeTaskId lingered after navigating Home,
leaving the last-opened task visually selected in the sidebar. Gate the
selected/active highlight by route — only highlight while on /t/ or
/office/tasks/. Expose data-active on the sidebar task item for testability.
Adds an e2e: entering a task highlights it; returning Home clears it.
* fix(web): never build the dockview default layout at zero width
On a fresh client-side navigation into a task the dockview can mount before the
grid has painted, so measureDockviewContainer returned api.width/height = 0.
Building the default layout at width 0 makes dockview collapse the horizontal
columns into a vertical stack (chat / files+changes / terminal) instead of the
intended side-by-side layout. Fall back to the viewport when neither the live
container nor the api report a usable size; the resize observer then snaps the
grid to the exact container size.
TDD: dockview-measure.test.ts covers the zero-size fallback.
* fix(web): keep the default layout horizontal when swapping chat for the session tab
Entering a task rendered a vertical stack (chat on top, files/changes in the
middle, terminal at the bottom) instead of the side-by-side default. The
chat->session-tab swap removed the 'chat' placeholder BEFORE adding the session
panel; when chat was the only panel in the center group, removing it destroyed
that group, the stored centerGroupId went stale, resolveInitialPosition
returned undefined, and the session panel was appended as a new row — flipping
the grid root to VERTICAL with three stacked groups.
Fix: add the session panel into chat's live group first (resolveInitialPosition
now prefers the chat panel's group), THEN remove the chat placeholder, so the
center group never empties and the horizontal split is preserved.
Also fixes the pre-existing pane-resize reload/clamp e2e failures, which were
the same collapse. TDD: task-default-layout.spec.ts asserts the right column
sits beside chat.
* fix(web): remove the sidebar Settings section; settings is gear-only
Settings was still a collapsible nav section in the sidebar AND auto-expanded on
/settings routes. That also caused the reported bug: toggling the footer gear
off while on a settings page revealed the normal sidebar with the Settings
section already expanded, so the settings tree appeared to 'not close'.
- Drop the Settings nav section (and its /settings route auto-expand) — the
settings tree is reached only via the footer gear takeover
- Delete the now-unused sidebar settings-section component
- e2e: gear opens the tree and closes it even after navigating into a section
* fix(web): show office sidebar sections only while inside Office
Inbox, Projects, and Agents were gated on the office feature flag, so they were
always visible when the feature was enabled. Gate them on actually being in the
Office surface (an /office route, reached via the footer Office button) via a
new useInOffice() hook — they stay hidden in the regular workspace and appear
only after entering Office.
Unit test for the hook; e2e (office fixture) asserts the sections appear on
/office and are absent on the regular home.
* fix(web): drop the colorful workspace selector, restore the minimal one
The workspace picker swapped to a gradient/initials trigger (and gradient
swatches in the dropdown) whenever office was enabled. Always use the plain
name + chevron trigger and drop the gradient swatches, matching the previous
design. Removes the now-unused workspace-gradient helper.
* fix(web): align sidebar header, scope office New Task, exit settings on nav
- Drop the doubled right border: the resize-handle hairline is transparent at
rest, leaving the aside's border-r as the single edge (highlights on hover).
- Collapse the expanded sidebar header into one h-10 row (brand · workspace
picker · collapse) so it lines up with the page/dockview top bar.
- Gate the sidebar New Task dialog on useInOffice() (actual /office route)
instead of the bare office feature flag, so Kanban mode keeps the classic
create dialog while office is enabled.
- Close the settings takeover when navigating off the /settings surface
(Home, Kandev brand, Office, etc.), tracked on a real pathname change so the
gear still opens it in place and in-tree navigation keeps it open.
- Lock in the already-working collapsed-gear-expands behaviour with an e2e.
Tests: new-task-item unit (route gating), settings-gear e2e (Home closes,
collapsed gear expands), office gating e2e (Kanban vs Office dialog).
* fix(web): realign task top bar to h-10, refine sidebar workspace picker
- Pin TaskTopBar to h-10 so its bottom border lines up exactly with the
sidebar header (and the PageTopbar) — previously it had no fixed height and
sat ~2px off, breaking the seam across the sidebar/content edge.
- Redesign the workspace picker as a compact, muted secondary control inlined
after the Kandev brand: brand and workspace share text-sm so they sit on a
common baseline, with a faint '/' separator; the workspace reads as muted and
the brand stays primary.
- Drop the workspace switcher (and its irrelevant folder glyph) from the
collapsed rail — collapsed now shows just brand + expand.
Adds an e2e that measures the task top bar bottom edge against the sidebar
header (within 1px).
* fix(web): replace the /github and /gitlab presets rail with a scope bar
The integration dashboards rendered their own w-60 border-r presets sidebar
right next to the global AppSidebar — a redundant 'double sidebar' that looked
off after the UI overhaul unified navigation.
On desktop, fold the presets into a horizontal scope bar above the list
toolbar: the bounded, common controls (PR/Issue kind + preset pills, grouped
Inbox/Created) stay visible as pills, while the unbounded saved-queries list
collapses into a 'Saved' dropdown so the bar never grows tall. The dashboard is
now single-column like Kanban/Tasks. Mobile is unchanged — the vertical
PresetsSidebar still lives in the hamburger sheet.
Adds a desktop scope-bar e2e (presets + kind switching, no inline rail) and
repoints the mobile page object at the new scope-bar testid.
* fix(web): address CodeRabbit/cubic review — a11y, dedup, dup nav, lazy-load
- Sidebar: fix Tasks auto-expand route prefix (/task/ -> /t/); forwardRef the
workspace-picker trigger so DropdownMenuTrigger asChild wires it; aria-label
the icon-only Add agent and keyboard-shortcut buttons.
- Settings tree: drop the duplicate top-level Editors leaf (lives under
General); broaden the System group active state to all /settings/system.
- CSS: honour prefers-reduced-motion for .sidebar-fade-in.
- Storage: bump the dockview maximize namespace to v3 in lockstep with the
layout namespace so stale pre-v3 maximize blobs are discarded.
- Integration dashboards: extract a shared IntegrationScopeBar base so the
github/gitlab scope bars are thin wrappers (de-dup + under the component
size cap). Reuse useConfiguredIntegrationLinks in the sidebar Integrations
section instead of recomputing availability.
- Lazy-load the office NewTaskDialog so it's not bundled on non-office routes.
- tasks-view-picker: use onSelect for keyboard-activatable menu items.
- Tests: normalize group x by container left in task-default-layout; refresh
stale subtask-flow comments; async findByTestId for the lazy office dialog.
* fix(web): gate the scope-bar delete button pointer-events while hidden
The opacity-0 delete button stayed clickable, so a click on the right side of a
saved-query row could hit the invisible button instead of selecting the query.
Add pointer-events-none until the row is hovered (group-hover:pointer-events-auto).
* fix(web): use onSelect for scope-bar saved-query menu items (keyboard a11y)
Radix DropdownMenuItem fires onSelect for pointer AND keyboard activation;
onClick missed keyboard users. The nested delete button stops pointerdown so it
isn't treated as a select of the saved query.
* fix(web): mobile sidebar hide, gear-gated settings test, office paused badge, picker a11y
- AppSidebar is now hidden below md (mobile uses its own per-surface nav); it
was an always-on absolute overlay covering mobile content.
- Restore the sidebar-agent-paused-badge testid on the office agent row.
- Update system sidebar-navigation spec to open the gear (settings is a
gear-gated takeover in the overhaul).
- Workspace picker menu items use onSelect for keyboard a11y.
* test(web): reconcile main's E2E suite with the unified-sidebar overhaul
Merging main un-gated the full E2E suite (was behind safe-to-test); ~50 of
main's tests asserted pre-overhaul UI. Updated them to the overhaul:
- Delete obsolete dockview-embedded-sidebar resize tests (feature removed);
keep the right-pane resize tests.
- Scope locators where tasks now appear in BOTH the page and the global
AppSidebar list (intended) — task-list, task-list-filters, pr-status-badge.
- Adapt sidebar-filter specs to the new TasksViewPicker (gear popover +
view-chip dropdown) and its page object.
- Settings now reached via the footer gear (not the topbar); Improve Kandev
opens from the sidebar footer; office nav folded into the AppSidebar — update
those specs (office verified in CI; can't run locally).
- session-layout: handle the new close-terminal confirm dialog; multi-session
and tab-management: foreground the (now backgrounded) chat tab after sidebar
task switches.
- Skip create-task-url-reopen-no-branches: its provider-branch flow (code
identical to main) is broken only by the office-off backend restart this PR
forces via useRegularMode — a harness interaction, not an app regression.
* test(web): fix create-task-url-reopen branch listing (reload to drop stale cache)
Un-skipped and fixed properly. Root cause: during the URL-paste/registration
flow the frontend can transiently fetch branches for the freshly-registered
provider repo before its background clone settles; useBranches caches that early
empty result as terminal (if (isLoaded) return), so re-picking the repo never
re-lists. Reload before the second dialog open drops the in-memory branch cache
so branches list fresh from the backend (verified: the backend returns them).
The provider-API path and product behaviour are unchanged from main.
* fix(web): restore mobile search toggle + reconcile remaining E2E with overhaul
- Restore main's mobile-search-toggle in kanban-header-mobile (my earlier
--ours merge resolution dropped it, breaking the mobile-kanban search tests).
- Office specs: expand the collapsed Agents/Projects sections before asserting
their rows, and scope office-tasks-page locators to <main>. [CI-verified]
- mobile-task-list-search / mobile-voice-mode: scope task titles to the table
and drop the obsolete data-sidebar=trigger assertion.
- app-sidebar.test mock: add settingsMode, toggleAppSidebarSettingsMode, and the
integrations section key.
- sidebar-filter page object: scope chipMenu to the menu holding the view chips.
* test(web): finish E2E reconciliation — office sections, session timing, robustness
- agent-error-handling: expand the collapsed Agents section before the paused
badge check.
- office execution-indicator/task-sorting: wait for a non-empty .font-mono
identifier (assigned async by the backend).
- session-layout: foreground the (CI-backgrounded) chat tab before the idle
check; make the close-terminal confirm dialog optional (busy heuristic is
timing-dependent).
- workspace-switch-isolation: run office-off so the picker switches in place
instead of redirecting to /office (which needs onboarding this fixture skips).
- app-sidebar-page: condense JSDoc to one line per project convention.
* test(web): match main's office identifier assertion (revert over-scoping)
The round-2 office agent scoped the .font-mono identifier check to <main> too,
but only the task TITLE needed scoping for the sidebar duplication. The office
task identifier is empty in the e2e seed (backend-assigned, not populated here),
so the <main>-scoped non-empty filter found nothing. Revert the identifier
assertion to main's exact form (first .font-mono on the page); keep the title
scoped to <main>.
* test(web): restore non-empty .font-mono filter for office identifier checks
Per review: wait for the backend-assigned (non-empty) identifier rather than the
empty span. Unscoped so it finds the identifier wherever it renders, 15s window.
* test(web): fix office identifier + realtime-dashboard specs at the root cause
Ran office specs locally (they DO run here) and root-caused all three:
- execution-indicator / task-sorting: the office task identifier is empty in
e2e — tasks are seeded via the core /api/v1/tasks route, which bypasses the
office identifier-assignment flow (the workspace KAN prefix only applies to
office-flow-created tasks). Main's .font-mono.first() was a false-positive
matching an element the overhaul removed. Assert the identifier COLUMN renders
(attached to the row) instead of a value e2e never produces.
- realtime-dashboard: the Recent Tasks card is fed by dashboard.recent_tasks via
useOfficeRefetch on office WS events; a core-route task's office event is async
so the in-place refetch is genuinely flaky. Reload for the deterministic SSR
dashboard fetch (what a user sees on revisit); the WS-refetch mechanism stays
covered by the sibling cross-workspace test.
869 lines
33 KiB
TypeScript
869 lines
33 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import { test, expect } from "../../fixtures/test-base";
|
|
import { useRegularMode } from "../../helpers/regular-mode";
|
|
import { KanbanPage } from "../../pages/kanban-page";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
|
useRegularMode();
|
|
|
|
/**
|
|
* Helper: switch the create-task dialog to the Remote tab and paste a URL
|
|
* into the (newly nested) chip popover. Replaces the old "click
|
|
* toggle-github-url + fill github-url-input" pair after Task 5/8 swapped the
|
|
* top-level URL input for a per-row chip + popover.
|
|
*/
|
|
async function openRemoteAndPasteURL(testPage: Page, url: string): Promise<void> {
|
|
await testPage.getByTestId("source-mode-remote").click();
|
|
// The chip popover holds the paste input. Open the first chip.
|
|
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
|
const pasteInput = testPage.getByTestId("remote-paste-url-input");
|
|
await expect(pasteInput).toBeVisible();
|
|
await pasteInput.fill(url);
|
|
await pasteInput.press("Enter");
|
|
}
|
|
|
|
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();
|
|
|
|
// Switch to Remote tab and paste the URL into the chip popover.
|
|
await openRemoteAndPasteURL(testPage, "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(/\/t\//, { 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();
|
|
|
|
// Switch to Remote tab and paste the URL into the chip popover.
|
|
await openRemoteAndPasteURL(testPage, "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(/\/t\//, { 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 });
|
|
});
|
|
|
|
// Three tests previously asserted the top-level `github-url-error` testid
|
|
// surfaced on invalid URL, nonexistent repo, and "clears on valid URL".
|
|
// After Task 5/8 the URL input moved into a per-chip popover and no
|
|
// top-level error display is rendered. The behaviors are now covered by
|
|
// unit tests in `task-create-dialog-remote-repo-chip.test.tsx` and by the
|
|
// new `create-task-remote-repo.spec.ts` happy-path specs; deleted here
|
|
// rather than rewritten because the rendered UI no longer matches what
|
|
// they were asserting.
|
|
|
|
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 openRemoteAndPasteURL(testPage, "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 openRemoteAndPasteURL(testPage, "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(/\/t\//, { 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("PR URL auto-selects the PR head branch", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
// Pre-seed a 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",
|
|
});
|
|
|
|
// Seed branches including the PR head branch
|
|
await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [
|
|
{ name: "main" },
|
|
{ name: "feature/pr-branch" },
|
|
]);
|
|
|
|
// Seed a PR pointing to the feature branch
|
|
await apiClient.mockGitHubAddPRs([
|
|
{
|
|
number: 42,
|
|
title: "Test PR",
|
|
state: "open",
|
|
head_branch: "feature/pr-branch",
|
|
base_branch: "main",
|
|
author_login: "test-user",
|
|
repo_owner: "test-owner",
|
|
repo_name: "test-repo",
|
|
},
|
|
]);
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
await kanban.createTaskButton.first().click();
|
|
const dialog = testPage.getByTestId("create-task-dialog");
|
|
await expect(dialog).toBeVisible();
|
|
|
|
// Switch to Remote tab and paste the PR URL.
|
|
await openRemoteAndPasteURL(testPage, "https://github.com/test-owner/test-repo/pull/42");
|
|
|
|
// The PR head branch should be auto-selected and rendered inside the
|
|
// per-chip branch pill. `useBranchAutoSelectEffect` mirrors the resolved
|
|
// PR head into `remoteRepos[0].branch` so the pill's trigger label
|
|
// reflects the active branch (not just the singleton submit payload).
|
|
await expect(
|
|
testPage.locator('[data-testid="remote-branch-chip-trigger"]').first(),
|
|
).toContainText("feature/pr-branch", { timeout: 10_000 });
|
|
|
|
await testPage.getByTestId("task-title-input").fill("PR auto-select test");
|
|
await testPage.getByTestId("task-description-input").fill("test");
|
|
const startBtn = testPage.getByTestId("submit-start-agent");
|
|
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
|
|
});
|
|
|
|
test("creates task from PR URL with local executor end-to-end", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { execSync } = await import("child_process");
|
|
const gitEnv = {
|
|
...process.env,
|
|
HOME: backend.tmpDir,
|
|
GIT_AUTHOR_NAME: "E2E Test",
|
|
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
|
GIT_COMMITTER_NAME: "E2E Test",
|
|
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
|
};
|
|
|
|
// Create the feature/pr-branch in the local repo so git checkout works
|
|
const repoDir = `${backend.tmpDir}/repos/e2e-repo`;
|
|
execSync("git checkout -b feature/pr-branch", { cwd: repoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "pr commit"', { cwd: repoDir, env: gitEnv });
|
|
execSync("git checkout main", { cwd: repoDir, env: gitEnv });
|
|
|
|
// Pre-seed a GitHub-backed repository
|
|
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: "feature/pr-branch" },
|
|
]);
|
|
|
|
await apiClient.mockGitHubAddPRs([
|
|
{
|
|
number: 99,
|
|
title: "Feature PR",
|
|
state: "open",
|
|
head_branch: "feature/pr-branch",
|
|
base_branch: "main",
|
|
author_login: "test-user",
|
|
repo_owner: "test-owner",
|
|
repo_name: "test-repo",
|
|
},
|
|
]);
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
await kanban.createTaskButton.first().click();
|
|
const dialog = testPage.getByTestId("create-task-dialog");
|
|
await expect(dialog).toBeVisible();
|
|
|
|
// Switch to Remote tab and paste the PR URL.
|
|
await openRemoteAndPasteURL(testPage, "https://github.com/test-owner/test-repo/pull/99");
|
|
|
|
// PR-info fetch resolves asynchronously; downstream the submit-button
|
|
// becomes enabled once branches + agent profile are ready. The terminal
|
|
// assertion later confirms the PR head branch is actually checked out.
|
|
|
|
// Fill in title and description
|
|
await testPage.getByTestId("task-title-input").fill("PR Task Local");
|
|
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
|
|
|
// Wait for the start button to become enabled
|
|
const startBtn = testPage.getByTestId("submit-start-agent");
|
|
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
|
|
|
|
// Submit (default executor is local/standalone)
|
|
await startBtn.click();
|
|
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
|
|
|
// The new task card appears on the kanban board
|
|
const card = kanban.taskCardByTitle("PR Task Local");
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Navigate to the session and verify the agent completes
|
|
await card.click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { 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 });
|
|
|
|
// Verify the local executor checked out the PR branch.
|
|
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
|
|
await session.typeInTerminal("git branch --show-current");
|
|
await session.expectTerminalHasText("feature/pr-branch");
|
|
|
|
// Restore the shared worker-scoped repo to main so the next test's
|
|
// local-executor branch detection sees a clean baseline. The agent
|
|
// session above checked out feature/pr-branch on this repo; without
|
|
// this cleanup the next test that reads currentLocalBranch (e.g.
|
|
// create-task.spec.ts's "dialog pre-selects" check) sees the wrong
|
|
// branch and asserts against "main" fail.
|
|
execSync("git checkout -f main", { cwd: repoDir, env: gitEnv });
|
|
});
|
|
|
|
test("creates task from PR URL with worktree executor and verifies branch", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { execSync } = await import("child_process");
|
|
const fs = await import("fs");
|
|
const gitEnv = {
|
|
...process.env,
|
|
HOME: backend.tmpDir,
|
|
GIT_AUTHOR_NAME: "E2E Test",
|
|
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
|
GIT_COMMITTER_NAME: "E2E Test",
|
|
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
|
};
|
|
|
|
// Create a fresh repo with the PR branch available locally.
|
|
// No remote needed — the worktree manager falls back to local branches.
|
|
const repoDir = `${backend.tmpDir}/repos/e2e-pr-wt`;
|
|
fs.mkdirSync(repoDir, { recursive: true });
|
|
|
|
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
|
|
|
|
// Create the PR branch locally and switch back to main
|
|
execSync("git checkout -b feature/pr-branch", { cwd: repoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "pr branch commit"', { cwd: repoDir, env: gitEnv });
|
|
execSync("git checkout main", { cwd: repoDir, env: gitEnv });
|
|
|
|
// Register the repo with a unique provider name to avoid collisions with
|
|
// other tests that also register repos as test-owner/test-repo.
|
|
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
|
|
name: "pr-owner/pr-wt-repo",
|
|
provider: "github",
|
|
provider_owner: "pr-owner",
|
|
provider_name: "pr-wt-repo",
|
|
});
|
|
|
|
// Seed mock GitHub branches and PR
|
|
await apiClient.mockGitHubAddBranches("pr-owner", "pr-wt-repo", [
|
|
{ name: "main" },
|
|
{ name: "feature/pr-branch" },
|
|
]);
|
|
|
|
await apiClient.mockGitHubAddPRs([
|
|
{
|
|
number: 77,
|
|
title: "Worktree PR",
|
|
state: "open",
|
|
head_branch: "feature/pr-branch",
|
|
base_branch: "main",
|
|
author_login: "test-user",
|
|
repo_owner: "pr-owner",
|
|
repo_name: "pr-wt-repo",
|
|
},
|
|
]);
|
|
|
|
// 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();
|
|
|
|
// Switch to Remote tab and paste the PR URL.
|
|
await openRemoteAndPasteURL(testPage, "https://github.com/pr-owner/pr-wt-repo/pull/77");
|
|
|
|
// PR-info fetch resolves asynchronously; downstream the submit-button
|
|
// becomes enabled once branches + agent profile are ready. The terminal
|
|
// assertion later confirms the PR head branch is actually checked out.
|
|
|
|
// Fill in title and description
|
|
await testPage.getByTestId("task-title-input").fill("PR Worktree 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("PR Worktree Task");
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
|
|
await card.click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { 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 });
|
|
|
|
// Verify the worktree checked out the PR branch directly.
|
|
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
|
|
await session.typeInTerminal("git branch --show-current");
|
|
await session.expectTerminalHasText("feature/pr-branch");
|
|
});
|
|
|
|
test("shows fetch warning banner when PR branch has no remote", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { execSync } = await import("child_process");
|
|
const fs = await import("fs");
|
|
const gitEnv = {
|
|
...process.env,
|
|
HOME: backend.tmpDir,
|
|
GIT_AUTHOR_NAME: "E2E Test",
|
|
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
|
GIT_COMMITTER_NAME: "E2E Test",
|
|
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
|
};
|
|
|
|
// Create a repo with the PR branch locally but NO remote.
|
|
// This causes fetchBranchToLocal to fail the fetch and fall back to local,
|
|
// which produces a warning that should be displayed in the UI.
|
|
const repoDir = `${backend.tmpDir}/repos/e2e-warning-repo`;
|
|
fs.mkdirSync(repoDir, { recursive: true });
|
|
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
|
|
execSync("git checkout -b feature/warn-branch", { cwd: repoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "feature commit"', { cwd: repoDir, env: gitEnv });
|
|
execSync("git checkout main", { cwd: repoDir, env: gitEnv });
|
|
|
|
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
|
|
name: "warn-owner/warn-repo",
|
|
provider: "github",
|
|
provider_owner: "warn-owner",
|
|
provider_name: "warn-repo",
|
|
});
|
|
|
|
await apiClient.mockGitHubAddBranches("warn-owner", "warn-repo", [
|
|
{ name: "main" },
|
|
{ name: "feature/warn-branch" },
|
|
]);
|
|
|
|
await apiClient.mockGitHubAddPRs([
|
|
{
|
|
number: 200,
|
|
title: "Warning PR",
|
|
state: "open",
|
|
head_branch: "feature/warn-branch",
|
|
base_branch: "main",
|
|
author_login: "test-user",
|
|
repo_owner: "warn-owner",
|
|
repo_name: "warn-repo",
|
|
},
|
|
]);
|
|
|
|
// Need worktree executor to trigger fetchBranchToLocal (local executor doesn't produce warnings)
|
|
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();
|
|
|
|
// Switch to Remote tab and paste the PR URL.
|
|
await openRemoteAndPasteURL(testPage, "https://github.com/warn-owner/warn-repo/pull/200");
|
|
|
|
// PR-info fetch resolves asynchronously; downstream the submit-button
|
|
// becomes enabled once branches + agent profile are ready.
|
|
|
|
await testPage.getByTestId("task-title-input").fill("Warning Test Task");
|
|
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
|
|
|
const startBtn = testPage.getByTestId("submit-start-agent");
|
|
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
|
|
|
|
// Select worktree executor
|
|
const executorSelector = testPage.getByTestId("executor-profile-selector");
|
|
await executorSelector.click();
|
|
await testPage.getByRole("option", { name: /Worktree/i }).click();
|
|
|
|
await startBtn.click();
|
|
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
|
|
|
const card = kanban.taskCardByTitle("Warning Test Task");
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
|
|
await card.click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
|
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
|
|
// Wait for agent to complete (preparation is done by this point)
|
|
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// The warning banner should persist after preparation completes.
|
|
// It shows because fetchBranchToLocal failed to reach origin (no remote)
|
|
// and fell back to the local branch.
|
|
const warningBanner = testPage.getByTestId("prepare-warning-banner");
|
|
await expect(warningBanner).toBeVisible({ timeout: 10_000 });
|
|
await expect(warningBanner).toContainText("Could not fetch latest from origin");
|
|
|
|
// The "Details" toggle should be visible and expand raw git output on click.
|
|
const detailsBtn = warningBanner.getByRole("button", { name: "Details" });
|
|
await expect(detailsBtn).toBeVisible();
|
|
await detailsBtn.click();
|
|
// After expanding, raw git output should appear (e.g. "fatal:" from the failed fetch)
|
|
await expect(warningBanner.locator("pre")).toBeVisible();
|
|
|
|
// Clicking again should collapse the details.
|
|
await detailsBtn.click();
|
|
await expect(warningBanner.locator("pre")).not.toBeVisible();
|
|
});
|
|
|
|
test("two tasks from the same PR URL create independent worktrees", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const { execSync } = await import("child_process");
|
|
const fs = await import("fs");
|
|
const gitEnv = {
|
|
...process.env,
|
|
HOME: backend.tmpDir,
|
|
GIT_AUTHOR_NAME: "E2E Test",
|
|
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
|
GIT_COMMITTER_NAME: "E2E Test",
|
|
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
|
};
|
|
|
|
// Create a repo with the PR branch locally.
|
|
const repoDir = `${backend.tmpDir}/repos/e2e-shared-pr`;
|
|
fs.mkdirSync(repoDir, { recursive: true });
|
|
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
|
|
execSync("git checkout -b feature/shared-pr", { cwd: repoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "shared pr commit"', { cwd: repoDir, env: gitEnv });
|
|
execSync("git checkout main", { cwd: repoDir, env: gitEnv });
|
|
|
|
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
|
|
name: "shared-owner/shared-repo",
|
|
provider: "github",
|
|
provider_owner: "shared-owner",
|
|
provider_name: "shared-repo",
|
|
});
|
|
|
|
await apiClient.mockGitHubAddBranches("shared-owner", "shared-repo", [
|
|
{ name: "main" },
|
|
{ name: "feature/shared-pr" },
|
|
]);
|
|
|
|
await apiClient.mockGitHubAddPRs([
|
|
{
|
|
number: 50,
|
|
title: "Shared PR",
|
|
state: "open",
|
|
head_branch: "feature/shared-pr",
|
|
base_branch: "main",
|
|
author_login: "test-user",
|
|
repo_owner: "shared-owner",
|
|
repo_name: "shared-repo",
|
|
},
|
|
]);
|
|
|
|
// 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();
|
|
|
|
// Helper: select worktree executor in the create dialog
|
|
const selectWorktreeExecutor = async () => {
|
|
const selector = testPage.getByTestId("executor-profile-selector");
|
|
// Only click if the selector doesn't already show "Worktree"
|
|
const selectorText = await selector.textContent();
|
|
if (!selectorText?.includes("Worktree")) {
|
|
await selector.click();
|
|
await testPage.getByRole("option", { name: /Worktree/i }).click();
|
|
}
|
|
};
|
|
|
|
// --- Task A: first task from PR URL ---
|
|
await kanban.createTaskButton.first().click();
|
|
const dialog = testPage.getByTestId("create-task-dialog");
|
|
await expect(dialog).toBeVisible();
|
|
|
|
await openRemoteAndPasteURL(testPage, "https://github.com/shared-owner/shared-repo/pull/50");
|
|
// PR-info fetch resolves asynchronously; the chip's branch pill renders
|
|
// the resolved PR head once `useBranchAutoSelectEffect` mirrors it into
|
|
// `remoteRepos[0].branch`, which is also the trigger that enables submit.
|
|
await expect(
|
|
testPage.locator('[data-testid="remote-branch-chip-trigger"]').first(),
|
|
).toContainText("feature/shared-pr", { timeout: 10_000 });
|
|
|
|
await testPage.getByTestId("task-title-input").fill("Shared PR 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 selectWorktreeExecutor();
|
|
|
|
await startBtn.click();
|
|
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
|
await expect(kanban.taskCardByTitle("Shared PR Task A")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Wait for Task A agent to complete before creating Task B
|
|
await kanban.taskCardByTitle("Shared PR Task A").click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
|
const sessionA = new SessionPage(testPage);
|
|
await sessionA.waitForLoad();
|
|
await expect(sessionA.chat.getByText("simple mock response", { exact: false })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await expect(sessionA.idleInput()).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Task A should have the direct PR branch
|
|
await expect(sessionA.terminal).toBeVisible({ timeout: 15_000 });
|
|
await sessionA.typeInTerminal("git branch --show-current");
|
|
await sessionA.expectTerminalHasText("feature/shared-pr");
|
|
|
|
// --- Task B: second task from the same PR URL ---
|
|
await testPage.goto("/");
|
|
await kanban.board.waitFor({ state: "visible" });
|
|
|
|
await kanban.createTaskButton.first().click();
|
|
await expect(dialog).toBeVisible();
|
|
|
|
await openRemoteAndPasteURL(testPage, "https://github.com/shared-owner/shared-repo/pull/50");
|
|
// PR-info fetch resolves asynchronously; the chip's branch pill renders
|
|
// the resolved PR head once `useBranchAutoSelectEffect` mirrors it into
|
|
// `remoteRepos[0].branch`, which is also the trigger that enables submit.
|
|
await expect(
|
|
testPage.locator('[data-testid="remote-branch-chip-trigger"]').first(),
|
|
).toContainText("feature/shared-pr", { timeout: 10_000 });
|
|
|
|
await testPage.getByTestId("task-title-input").fill("Shared PR Task B");
|
|
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
|
|
|
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
|
|
await selectWorktreeExecutor();
|
|
|
|
await startBtn.click();
|
|
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
|
|
|
// Task B should also succeed (this would have failed before the fix)
|
|
await expect(kanban.taskCardByTitle("Shared PR Task B")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await kanban.taskCardByTitle("Shared PR Task B").click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
|
const sessionB = new SessionPage(testPage);
|
|
await sessionB.waitForLoad();
|
|
await expect(sessionB.chat.getByText("simple mock response", { exact: false })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// Task B should have a suffixed branch (not the original PR branch)
|
|
await expect(sessionB.terminal).toBeVisible({ timeout: 15_000 });
|
|
await sessionB.typeInTerminal("git branch --show-current");
|
|
// Branch should start with the PR branch name but have a random suffix
|
|
await sessionB.expectTerminalHasText("feature/shared-pr-");
|
|
});
|
|
|
|
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();
|
|
|
|
// The source-mode segmented control: Repo (workspace), Remote (chip
|
|
// picker / paste), None (scratch).
|
|
const urlModeBtn = testPage.getByTestId("source-mode-remote");
|
|
const repoModeBtn = testPage.getByTestId("source-mode-workspace");
|
|
|
|
// Default state: workspace mode, remote chips row not rendered.
|
|
await expect(repoModeBtn).toHaveAttribute("aria-checked", "true");
|
|
await expect(testPage.getByTestId("remote-repo-chips-row")).not.toBeVisible();
|
|
|
|
// Switch to Remote mode — chip row appears, Remote button is selected.
|
|
await urlModeBtn.click();
|
|
await expect(testPage.getByTestId("remote-repo-chips-row")).toBeVisible();
|
|
await expect(urlModeBtn).toHaveAttribute("aria-checked", "true");
|
|
await expect(repoModeBtn).toHaveAttribute("aria-checked", "false");
|
|
|
|
// Switch back to workspace mode — chip row disappears.
|
|
await repoModeBtn.click();
|
|
await expect(testPage.getByTestId("remote-repo-chips-row")).not.toBeVisible();
|
|
await expect(repoModeBtn).toHaveAttribute("aria-checked", "true");
|
|
await expect(urlModeBtn).toHaveAttribute("aria-checked", "false");
|
|
});
|
|
});
|