From 4bfb682cd9c15ad63e2ecaa367438b0a90a39cb3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 25 Aug 2026 20:55:05 +0200 Subject: [PATCH] Cut Playwright feedback time without losing behavioral coverage (#3852) * test(app): consolidate Playwright journeys * test(app): stabilize consolidated Playwright flows * test(app): make streaming dependencies explicit * test(app): await profile feature persistence * test(app): sample only actual workspace tabs --- .github/workflows/ci.yml | 6 +- docs/testing.md | 1 + .../app/e2e/browser/add-project-flow.spec.ts | 51 +------ .../e2e/browser/agent-message-rewind.spec.ts | 46 +----- ...agent-profile-modeless-preferences.spec.ts | 15 ++ .../e2e/browser/bottom-sheet-reopen.spec.ts | 66 ++++----- .../app/e2e/browser/branch-switcher.spec.ts | 32 ---- packages/app/e2e/browser/chat-outline.spec.ts | 9 +- .../e2e/browser/command-center-host.spec.ts | 39 ----- .../e2e/browser/composer-whitespace.spec.ts | 1 + .../new-workspace-launch-memory.spec.ts | 2 + .../browser/rewind-menu.ui-contract.spec.ts | 1 + .../app/e2e/browser/sessions-search.spec.ts | 98 +++++-------- packages/app/e2e/browser/sidebar-help.spec.ts | 61 ++++---- .../e2e/browser/sidebar-resize-handle.spec.ts | 39 ----- .../sidebar-workspace-pin-shortcut.spec.ts | 55 +++---- .../app/e2e/browser/sidebar-workspace.spec.ts | 13 -- .../e2e/browser/workspace-lifecycle.spec.ts | 38 ++--- .../browser/workspace-pane-remount.spec.ts | 138 +++++++----------- .../browser/workspace-setup-streaming.spec.ts | 8 +- .../app/e2e/browser/worktree-archive.spec.ts | 22 --- packages/app/e2e/support/fixtures.ts | 24 +-- .../app/e2e/support/helpers/chat-outline.ts | 9 ++ packages/app/e2e/support/helpers/launcher.ts | 7 +- .../app/e2e/support/helpers/mock-agent.ts | 2 +- packages/app/playwright.config.ts | 10 +- .../agent/providers/mock-load-test-agent.ts | 11 ++ 27 files changed, 277 insertions(+), 527 deletions(-) delete mode 100644 packages/app/e2e/browser/sidebar-resize-handle.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fa5c814c..833eafa67a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,12 +348,12 @@ jobs: timeout-minutes: 10 run: npx playwright install chromium - - name: Build app dependencies - run: npm run build:app-deps - - name: Build server stack run: npm run build:server + - name: Build native app dependency + run: npm run build --workspace=@getpaseo/expo-two-way-audio + - name: Install agent CLIs for provider tests run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai diff --git a/docs/testing.md b/docs/testing.md index e34191dcae..9b94de5038 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -186,6 +186,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es - For full-suite confidence, push to CI and check GitHub Actions. - Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow. - App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated. +- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page. - Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`. - Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics. - Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts index 77ab53f960..027d6156e7 100644 --- a/packages/app/e2e/browser/add-project-flow.spec.ts +++ b/packages/app/e2e/browser/add-project-flow.spec.ts @@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise { test.describe("Add Project command-center flow", () => { test.describe.configure({ timeout: 180_000 }); - test("method selection has no search field", async ({ page }) => { + test("method selection shows the daemon's available project sources without search", async ({ + page, + }) => { await gotoAppShell(page); await openAddProjectFlow(page); await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible(); + await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub"); + await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory"); await expect(addProjectFlowInput(page)).toHaveCount(0); await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0); await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0); }); - test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => { - await gotoAppShell(page); - await openAddProjectFlow(page); - - await page.keyboard.press("Enter"); - await expectAddProjectPage(page, "directory-search"); - await addProjectFlowInput(page).fill("/tmp"); - - const backGlyph = addProjectFlowBack(page).locator("svg"); - const resultGlyph = addProjectFlow(page) - .locator('[data-testid^="add-project-flow-path-"]') - .first() - .locator("svg"); - await expect(resultGlyph).toBeVisible(); - - const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([ - backGlyph.boundingBox(), - addProjectFlowInput(page).boundingBox(), - resultGlyph.boundingBox(), - page.getByTestId("add-project-flow-title").boundingBox(), - page.getByTestId("add-project-flow-results").boundingBox(), - page.getByTestId("add-project-flow-footer").boundingBox(), - ]); - expect(backBox).not.toBeNull(); - expect(inputBox).not.toBeNull(); - expect(resultBox).not.toBeNull(); - expect(titleBox).not.toBeNull(); - expect(resultsBox).not.toBeNull(); - expect(footerBox).not.toBeNull(); - if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return; - - expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2); - expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2); - expect(titleBox.height).toBeLessThanOrEqual(24); - expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1); - }); - test("an offline extra host neither appears nor forces host selection", async ({ page }) => { await gotoAppShell(page); await addOfflineHostAndReload(page, { @@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => { await expectProjectHasNoWorkspaces(projectId); }); - test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => { - await gotoAppShell(page); - await openAddProjectFlow(page); - - await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub"); - await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory"); - }); - test("a complete repository URL remains selectable without a GitHub search result", async ({ page, }) => { diff --git a/packages/app/e2e/browser/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts index 2cb01f89e1..dea6614370 100644 --- a/packages/app/e2e/browser/agent-message-rewind.spec.ts +++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts @@ -1,30 +1,9 @@ import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; -import { - cancelAgent, - expectComposerDraft, - expectComposerVisible, - submitMessage, -} from "../support/helpers/composer"; +import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -async function completeSubmittedTurn( - page: Page, - agent: Awaited>, - prompt: string, -): Promise { - await submitMessage(page, prompt); - const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); - await expect(userMessage).toBeVisible(); - - const finish = await agent.client.waitForFinish(agent.agentId, 30_000); - expect(finish.status).toBe("idle"); - await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); - await expect(userMessage).toHaveAttribute("aria-busy", "false"); - return userMessage; -} - async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise { await userMessage.getByText(prompt, { exact: true }).hover(); await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click(); @@ -42,29 +21,6 @@ async function expectTurnCompletesNormally( } test.describe("Agent message rewind", () => { - test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => { - test.setTimeout(90_000); - const prompt = "Restore this submitted prompt after rewind."; - const agent = await seedMockAgentWorkspace({ - repoPrefix: "message-rewind-roundtrip-", - title: "Message rewind roundtrip", - model: "ten-second-stream", - }); - - try { - await openAgentRoute(page, agent); - await expectComposerVisible(page); - const userMessage = await completeSubmittedTurn(page, agent, prompt); - - await rewindConversation(page, userMessage, prompt); - - await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0); - await expectComposerDraft(page, prompt); - } finally { - await agent.cleanup(); - } - }); - test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => { test.setTimeout(90_000); const prompt = "Delay synthetic user message by 2000ms."; diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts index 980eaea5d0..2558d3b427 100644 --- a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -103,6 +103,18 @@ async function readProviderModePreference(page: Page, provider: string): Promise ); } +async function readProviderFeaturePreferences(page: Page, provider: string): Promise { + return page.evaluate( + ({ preferencesKey, providerId }) => { + const raw = localStorage.getItem(preferencesKey); + if (!raw) return null; + const preferences = JSON.parse(raw) as FormPreferences; + return preferences.providerPreferences?.[providerId]?.featureValues ?? null; + }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider }, + ); +} + test.describe("Agent profiles repair modeless provider preferences", () => { test.describe.configure({ timeout: 240_000 }); @@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => { await openModelPicker(page); await applyProfileFromPicker(page, "Fast profile"); + await expect + .poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 }) + .toEqual({ fast_mode: true }); await submitNewWorkspacePrompt(page, "Create an agent with profile features."); const createAgentRequest = await createAgentRecorder.waitForRequest(); diff --git a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts index 2d017b42ad..967462f05b 100644 --- a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts @@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) { repoPrefix: "bottom-sheet-reopen-", title: "Bottom sheet reopen e2e", initialPrompt: "Prepare a bottom sheet reopen test agent.", + model: "ten-second-stream", }); await openAgentRoute(page, session); await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({ @@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) { return session; } -async function withMobileMockAgent(page: Page, run: () => Promise) { - const session = await openMockAgentAtMobileBreakpoint(page); - - try { - await run(); - } finally { - await session.cleanup(); - } -} - function bottomSheetBackdrop(page: Page) { return page.getByRole("button", { name: "Bottom sheet backdrop" }).first(); } @@ -108,35 +99,36 @@ async function openAndCloseModelSelectorTwice(page: Page) { } test.describe("mobile bottom sheet reopen", () => { - test("tab switcher can open, close, reopen, and close again", async ({ page }) => { - await withMobileMockAgent(page, async () => { - await openAndCloseTabSwitcherTwice(page); - }); - }); - - test("model selector can open, close, reopen, and close again", async ({ page }) => { - await withMobileMockAgent(page, async () => { - await openAndCloseModelSelectorTwice(page); - }); - }); - - test("search selection returns to configuration options", async ({ page }) => { - await withMobileMockAgent(page, async () => { - await openModelSelector(page); - const sheet = page.getByTestId("agent-controls-model-sheet"); - - await page.getByTestId("model-search-all-input").click(); - const model = page.getByRole("button", { name: /^Ten second stream/ }); - await expect(model).toBeVisible({ - timeout: 10_000, + test("sheets reopen and model search returns to configuration", async ({ page }) => { + const session = await openMockAgentAtMobileBreakpoint(page); + try { + await test.step("tab switcher opens, closes, and reopens", async () => { + await openAndCloseTabSwitcherTwice(page); }); - await model.click(); + await test.step("model selector opens, closes, and reopens", async () => { + await openAndCloseModelSelectorTwice(page); + }); - await expect(sheet).toBeVisible(); - await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible(); - await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream"); - await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible(); - }); + await test.step("model search returns to configuration", async () => { + await openModelSelector(page); + const sheet = page.getByTestId("agent-controls-model-sheet"); + + await page.getByTestId("model-search-all-input").click(); + const model = page.getByRole("button", { name: /^Ten second stream/ }); + await expect(model).toBeVisible({ + timeout: 10_000, + }); + + await model.click(); + + await expect(sheet).toBeVisible(); + await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible(); + await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream"); + await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible(); + }); + } finally { + await session.cleanup(); + } }); }); diff --git a/packages/app/e2e/browser/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts index 6e81e93029..c413b6413e 100644 --- a/packages/app/e2e/browser/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -46,38 +46,6 @@ test.describe("Branch switcher", () => { // releases stale sessions from the previous spec; one retry stabilizes it. test.describe.configure({ retries: 1 }); - test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({ - page, - }) => { - test.setTimeout(90_000); - const serverId = getServerId(); - const workspace = await seedWorkspace({ - repoPrefix: "branch-switch-", - repo: { branches: ["main", "dev"] }, - }); - - try { - await gotoAppShell(page); - await waitForSidebarHydration(page); - await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); - - await openChangesPanel(page); - await expectWorkspaceBranch(page, "main"); - await switchBranchFromChangesPanel(page, { from: "main", to: "dev" }); - await expectWorkspaceBranch(page, "dev"); - - await expect - .poll( - async () => - (await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch, - { timeout: 30_000 }, - ) - .toBe("dev"); - } finally { - await workspace.cleanup(); - } - }); - test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({ page, }) => { diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index efece690bb..8e4532ee4f 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -4,6 +4,7 @@ import { clickChatOutlineRowEdge, disableChatOutlineFromAppearance, expectActiveChatOutlinePrompt, + expectActiveChatOutlinePromptMovedFrom, expectChatOutlinePreview, expectChatOutlinePrompts, expectChatOutlinePromptToRemainBare, @@ -29,7 +30,6 @@ import { openAgentTimeline, scrollThroughOlderHistoryPages, scrollTimelineToNewestLoadedEdge, - scrollTimelineToOldestLoadedEdge, seedLongMockAgentTimeline, type LongTimelineAgent, } from "../support/helpers/timeline-pagination"; @@ -130,11 +130,12 @@ test.describe("desktop chat outline", () => { await expectOneActiveChatOutlinePrompt(page); await clickChatOutlineRowEdge(page, 4); - await expectTimelinePromptVisible(page, agent.prompts[3]); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]); await expectActiveChatOutlinePrompt(page, 4); - await scrollTimelineToOldestLoadedEdge(page); - await expectActiveChatOutlinePrompt(page, 1); + await scrollTimelineToNewestLoadedEdge(page); + await expectTimelinePromptVisible(page, agent.newestPrompt); + await expectActiveChatOutlinePromptMovedFrom(page, 4); }); test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => { diff --git a/packages/app/e2e/browser/command-center-host.spec.ts b/packages/app/e2e/browser/command-center-host.spec.ts index f430969a87..be4dd9e830 100644 --- a/packages/app/e2e/browser/command-center-host.spec.ts +++ b/packages/app/e2e/browser/command-center-host.spec.ts @@ -1,53 +1,14 @@ import { randomUUID } from "node:crypto"; -import { expect } from "@playwright/test"; import { test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { createIdleAgent } from "../support/helpers/archive-tab"; import { openCommandCenter } from "../support/helpers/command-center"; -import { addOfflineHostAndReload } from "../support/helpers/hosts"; import { expectAgentTabActive } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; - -const PRIMARY_HOST_LABEL = "Primary Host"; -const SECONDARY_HOST_ID = "host-command-center-secondary"; test.describe("Command center host labels", () => { test.describe.configure({ timeout: 180_000 }); - test("agent results show the host they live on when more than one host exists", async ({ - page, - }) => { - const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" }); - const title = `cc-host-${randomUUID().slice(0, 8)}`; - - try { - const agent = await createIdleAgent(seeded.client, { - cwd: seeded.repoPath, - workspaceId: seeded.workspaceId, - title, - }); - - // A second (offline) host makes the view multi-host, which is when the host label earns its space. - await gotoAppShell(page); - await addOfflineHostAndReload(page, { - serverId: SECONDARY_HOST_ID, - label: "Secondary Host", - primaryLabel: PRIMARY_HOST_LABEL, - }); - - const panel = await openCommandCenter(page); - - // The shared daemon may carry agents from other specs, so target this agent by its id. - const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`); - await expect(row).toBeVisible({ timeout: 30_000 }); - await expect(row).toContainText(title); - await expect(row).toContainText(PRIMARY_HOST_LABEL); - } finally { - await seeded.cleanup(); - } - }); - test("selecting an agent result opens its workspace tab", async ({ page }) => { const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" }); const title = `cc-navigation-${randomUUID().slice(0, 8)}`; diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 81c87d2563..f935676c3b 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -99,6 +99,7 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } repoPrefix: "composer-bottom-anchor-", title: "Composer bottom anchor", initialPrompt: "Produce enough content to make the chat scrollable.", + model: "ten-second-stream", }); try { diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts index dc15ed981d..39b734d4f9 100644 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -64,6 +64,7 @@ test.describe("New workspace: launch target memory", () => { await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => { await openNewWorkspace(); await selectLaunchOption(page, PROMPT_PROFILE.id); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); await fillTerminalPrompt(page, "remember me"); await submitTerminalLaunch(page); await expectWorkspaceOpensWithTerminalTab(page); @@ -88,6 +89,7 @@ test.describe("New workspace: launch target memory", () => { await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => { await selectLaunchOption(page, PROMPT_PROFILE.id); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); await fillTerminalPrompt(page, "second run"); await submitTerminalLaunch(page); await expectWorkspaceOpensWithTerminalTab(page); diff --git a/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts index ab1ce4e199..f04a0626ed 100644 --- a/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts @@ -153,6 +153,7 @@ test.describe("Rewind sheet", () => { repoPrefix: "rewind-e2e-", title: "Rewind e2e", initialPrompt: firstPrompt, + model: "ten-second-stream", }); try { diff --git a/packages/app/e2e/browser/sessions-search.spec.ts b/packages/app/e2e/browser/sessions-search.spec.ts index f9fa0c596d..eac218b264 100644 --- a/packages/app/e2e/browser/sessions-search.spec.ts +++ b/packages/app/e2e/browser/sessions-search.spec.ts @@ -70,73 +70,55 @@ test.describe("History search", () => { await tempRepo?.cleanup(); }); - test("typing narrows history to matching sessions and clearing restores them", async ({ - page, - }) => { + test("searches, ranks, highlights, and clears session history", async ({ page }) => { await resetSeededPageState(page); await openSessions(page); - // Seeded newest-first, and at rest history is chronological. - await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); - await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 }); + await test.step("narrows history and restores chronological grouping", async () => { + await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); + await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { + timeout: 30_000, + }); - await search(page, `${NONCE} billing`); - await expectVisibleTitles(page, [TITLES.billing]); + await search(page, `${NONCE} billing`); + await expectVisibleTitles(page, [TITLES.billing]); + await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); - // Ranked results are one flat list — a day heading would claim an order - // the list no longer has. - await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 }); + await page.getByTestId("sessions-search-clear").click(); + await expect(page.getByTestId("sessions-search-input")).toHaveValue(""); + await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); + await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { + timeout: 30_000, + }); + }); - await page.getByTestId("sessions-search-clear").click(); - await expect(page.getByTestId("sessions-search-input")).toHaveValue(""); - await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); - await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 }); - }); + await test.step("ranks whole-word matches above substrings", async () => { + await search(page, `${NONCE} bill`); + await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]); + }); - test("ranks a whole-word hit above one buried inside a word", async ({ page }) => { - await resetSeededPageState(page); - await openSessions(page); + await test.step("highlights exact and typo-resolved matches", async () => { + await search(page, `${NONCE} billing`); + const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first(); + await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 }); - // "bill" starts a word in "billing" and hides inside "unbilled", so the - // stronger match leads even though both sessions are equally recent. - await search(page, `${NONCE} bill`); - await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]); - }); + await search(page, `${NONCE} bulling`); + await expectVisibleTitles(page, [TITLES.billing]); + const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first(); + await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + }); - test("marks the characters each result matched on", async ({ page }) => { - await resetSeededPageState(page); - await openSessions(page); + await test.step("distinguishes no matches from empty history", async () => { + await search(page, `${NONCE} kubernetes`); + await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 }); - // The mark is a nested Text run, so the matched slice is its own element. - await search(page, `${NONCE} billing`); - const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first(); - await expect(row).toBeVisible({ timeout: 30_000 }); - await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 }); - - // A typo has no characters in the text to point at, so the whole word it - // resolved to is marked. - await search(page, `${NONCE} bulling`); - const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first(); - await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 }); - }); - - test("finds a session through a typo", async ({ page }) => { - await resetSeededPageState(page); - await openSessions(page); - - await search(page, `${NONCE} bulling`); - await expectVisibleTitles(page, [TITLES.billing]); - }); - - test("says the query found nothing, not that history is empty", async ({ page }) => { - await resetSeededPageState(page); - await openSessions(page); - - await search(page, `${NONCE} kubernetes`); - await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 }); - - await page.getByText("Clear search").click(); - await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); + await page.getByText("Clear search").click(); + await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); + }); }); }); diff --git a/packages/app/e2e/browser/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts index 2c9359271e..2d0c6ef25f 100644 --- a/packages/app/e2e/browser/sidebar-help.spec.ts +++ b/packages/app/e2e/browser/sidebar-help.spec.ts @@ -41,46 +41,37 @@ async function expectExternalPage( await popup.close(); } -test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => { +test("opens troubleshooting, support, and release destinations", async ({ page }) => { await gotoAppShell(page); await expect(page.getByTestId("sidebar-help")).toBeVisible(); - await openHelpMenu(page); - const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => { - const { y, height } = element.getBoundingClientRect(); - return { y, height }; + await test.step("opens diagnostics and keyboard shortcuts", async () => { + await openHelpMenu(page); + await expect(page.getByText("Help", { exact: true })).toBeVisible(); + await expect(page.getByText("Report an issue", { exact: true })).toBeVisible(); + await expect(page.getByText("What's new", { exact: true })).toBeVisible(); + await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION); + + await page.getByTestId("sidebar-help-diagnostics").click(); + await expectDiagnosticReport(page); + await closeSheet(page, "app-diagnostic-sheet"); + + await openHelpMenu(page); + await page.getByTestId("sidebar-help-shortcuts").click(); + await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible(); + await closeSheet(page, "keyboard-shortcuts-dialog"); }); - const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => { - const { y, height } = element.getBoundingClientRect(); - return { y, height }; + + await test.step("opens support and release pages", async () => { + await openHelpMenu(page); + await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION); + + await openHelpMenu(page); + await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION); + + await openHelpMenu(page); + await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION); }); - expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y); - await expect(page.getByText("Help", { exact: true })).toBeVisible(); - await expect(page.getByText("Report an issue", { exact: true })).toBeVisible(); - await expect(page.getByText("What's new", { exact: true })).toBeVisible(); - await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION); - - await page.getByTestId("sidebar-help-diagnostics").click(); - await expectDiagnosticReport(page); - await closeSheet(page, "app-diagnostic-sheet"); - - await openHelpMenu(page); - await page.getByTestId("sidebar-help-shortcuts").click(); - await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible(); - await closeSheet(page, "keyboard-shortcuts-dialog"); -}); - -test("opens support and release destinations", async ({ page }) => { - await gotoAppShell(page); - - await openHelpMenu(page); - await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION); - - await openHelpMenu(page); - await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION); - - await openHelpMenu(page); - await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION); }); test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => { diff --git a/packages/app/e2e/browser/sidebar-resize-handle.spec.ts b/packages/app/e2e/browser/sidebar-resize-handle.spec.ts deleted file mode 100644 index fdbfb5842c..0000000000 --- a/packages/app/e2e/browser/sidebar-resize-handle.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Locator } from "@playwright/test"; -import { expect, test, type Page } from "../support/fixtures"; - -test.use({ viewport: { width: 1600, height: 900 } }); - -async function expectBorderHighlight( - page: Page, - testID: string, - hoverTarget: Locator, - expectedWidth: string, -) { - const handle = page.getByTestId(testID); - await expect(handle).toBeVisible(); - await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); - - await hoverTarget.hover(); - - const highlight = page.getByTestId(`${testID}-highlight`); - await expect(highlight).toBeVisible(); - await expect(highlight).toHaveCSS("width", expectedWidth); - await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); -} - -test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => { - const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" }); - await workspace.navigateTo(); - - const sidebarHandle = page.getByTestId("left-sidebar-resize-handle"); - await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px"); - - await page.getByTestId("workspace-explorer-toggle").first().click(); - const splitHandle = page.getByTestId("workspace-explorer-sidebar-resize-handle"); - await expectBorderHighlight( - page, - "workspace-explorer-sidebar-resize-handle", - splitHandle.getByRole("separator"), - "3px", - ); -}); diff --git a/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts index a471980e09..7f9a7b980d 100644 --- a/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts @@ -146,20 +146,37 @@ async function installPinRpcGate( } test.describe("Pin workspace shortcut", () => { - test("keeps project creation reachable after pinning its only workspace", async ({ page }) => { + test("dispatches pin once and keeps project creation reachable", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "pin-project-creation-", title: "Pinned workspace", }); try { + const gate = await installPinRpcGate(page); + await gotoAppShell(page); await openWorkspace(page, workspace.workspaceId); - await page.keyboard.press(PIN_SHORTCUT); - await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); - await openNewWorkspaceComposer(page, workspace); - await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName); + await test.step("sends one RPC for each pin transition", async () => { + await page.keyboard.press(PIN_SHORTCUT); + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + expect(gate.sentCount()).toBe(1); + + await page.keyboard.press(PIN_SHORTCUT); + await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 }); + expect(gate.sentCount()).toBe(2); + await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1); + }); + + await test.step("keeps the pinned project available to workspace creation", async () => { + await page.keyboard.press(PIN_SHORTCUT); + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + expect(gate.sentCount()).toBe(3); + + await openNewWorkspaceComposer(page, workspace); + await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName); + }); } finally { await workspace.cleanup(); } @@ -207,34 +224,6 @@ test.describe("Pin workspace shortcut", () => { } }); - test("sends exactly one pin RPC per press when the row is rendered and selected", async ({ - page, - }) => { - const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" }); - - try { - const gate = await installPinRpcGate(page); - - await gotoAppShell(page); - await openWorkspace(page, workspace.workspaceId); - - await page.keyboard.press(PIN_SHORTCUT); - await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); - // Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight - // guard, or a second dispatch path. It cannot detect a duplicate handler registration: - // `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a - // shadowed second handler is unobservable from outside by design. - expect(gate.sentCount()).toBe(1); - - await page.keyboard.press(PIN_SHORTCUT); - await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 }); - expect(gate.sentCount()).toBe(2); - await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1); - } finally { - await workspace.cleanup(); - } - }); - test("pins the active workspace while its status group is collapsed", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" }); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index e7a95106da..14421f84ca 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -118,19 +118,6 @@ test.describe("Sidebar workspace list", () => { } }); - test("project shows workspace under it", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" }); - - try { - await gotoAppShell(page); - - await waitForSidebarProject(page, path.basename(workspace.repoPath)); - await waitForSidebarWorkspace(page, workspace.workspaceId); - } finally { - await workspace.cleanup(); - } - }); - test("non-git project shows directory name", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false }); diff --git a/packages/app/e2e/browser/workspace-lifecycle.spec.ts b/packages/app/e2e/browser/workspace-lifecycle.spec.ts index ca1accab8e..7c7a20658f 100644 --- a/packages/app/e2e/browser/workspace-lifecycle.spec.ts +++ b/packages/app/e2e/browser/workspace-lifecycle.spec.ts @@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => { // One retry is enough for the daemon to stabilize. test.describe.configure({ retries: 1 }); - test.describe("Main checkout", () => { - test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => { - test.setTimeout(60_000); - const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" }); - await workspace.navigateTo(); + test("main checkout creates an agent chat and terminal in the project root", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(60_000); + const workspace = await withWorkspace({ prefix: "lifecycle-main-" }); + await workspace.navigateTo(); + + await test.step("creates an agent chat", async () => { await createAgentChatFromLauncher(page); }); - test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => { - test.setTimeout(60_000); - const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" }); - await workspace.navigateTo(); + await test.step("creates a terminal in the project root", async () => { await createStandaloneTerminalFromLauncher(page); await expectTerminalCwd(page, workspace.repoPath); }); }); - test.describe("Worktree workspace", () => { - test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => { - test.setTimeout(90_000); - const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" }); - await workspace.navigateTo(); + test("worktree creates an agent chat and terminal in its directory", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(90_000); + const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" }); + await workspace.navigateTo(); + + await test.step("creates an agent chat", async () => { await createAgentChatFromLauncher(page); }); - test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => { - test.setTimeout(90_000); - const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" }); - await workspace.navigateTo(); + await test.step("creates a terminal in the worktree", async () => { await createStandaloneTerminalFromLauncher(page); await expectTerminalCwd(page, workspace.repoPath); }); diff --git a/packages/app/e2e/browser/workspace-pane-remount.spec.ts b/packages/app/e2e/browser/workspace-pane-remount.spec.ts index 9d7f286307..201b6e3ccf 100644 --- a/packages/app/e2e/browser/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/browser/workspace-pane-remount.spec.ts @@ -1,5 +1,5 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; -import type { Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { createIdleAgent } from "../support/helpers/archive-tab"; import { expectComposerVisible } from "../support/helpers/composer"; @@ -35,10 +35,22 @@ async function expectSameRenderedNode( ); } +async function expectNodeConnected(node: Awaited>) { + expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true); +} + +async function getSettingsShortcut(page: Page) { + return page.evaluate(() => + navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,", + ); +} + +async function waitForWorkspaceRoute(page: Page, route: string) { + await page.waitForURL((url) => url.pathname === route); +} + test.describe("Workspace pane mounting", () => { - test("opening the first split pane keeps the existing agent composer mounted", async ({ - page, - }) => { + test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => { test.setTimeout(90_000); const serverId = getServerId(); @@ -65,55 +77,35 @@ test.describe("Workspace pane mounting", () => { .first() .elementHandle(); expect(originalComposer).not.toBeNull(); - const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count(); + await test.step("opening the first split pane preserves the composer", async () => { + const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count(); - await runWorkspaceActionFromCommandCenter(page, "Split pane right"); - await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount( - emptyPanesBeforeSplit + 1, - { timeout: 30_000 }, - ); - await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1); - - const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected); - expect(originalStillConnected).toBe(true); - } finally { - await workspace.cleanup(); - } - }); - - test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({ - page, - }) => { - test.setTimeout(90_000); - const serverId = getServerId(); - const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" }); - - try { - const agent = await createIdleAgent(workspace.client, { - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, - title: `settings-pane-retention-${Date.now()}`, + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount( + emptyPanesBeforeSplit + 1, + { timeout: 30_000 }, + ); + await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount( + 1, + ); + await expectNodeConnected(originalComposer!); }); - await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); - await waitForWorkspaceTabsVisible(page); const composer = page.getByTestId("message-input-root").filter({ visible: true }).first(); - const originalComposer = await captureRenderedNode(composer); + await test.step("desktop Settings closes overlays and preserves the composer", async () => { + const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); + await tab.click({ button: "right" }); + await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click(); + const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`); + await expect(renameInput).toBeVisible(); - const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); - await tab.click({ button: "right" }); - await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click(); - const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`); - await expect(renameInput).toBeVisible(); - - const settingsShortcut = await page.evaluate(() => - navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,", - ); - await page.keyboard.press(settingsShortcut); - await expect(page).toHaveURL(/\/settings\/general$/); - await expect(renameInput).not.toBeVisible(); - await clickSettingsBackToWorkspace(page); - await expectSameRenderedNode(originalComposer, composer); + const settingsShortcut = await getSettingsShortcut(page); + await page.keyboard.press(settingsShortcut); + await expect(page).toHaveURL(/\/settings\/general$/); + await expect(renameInput).not.toBeVisible(); + await clickSettingsBackToWorkspace(page); + await expectSameRenderedNode(originalComposer!, composer); + }); } finally { await workspace.cleanup(); } @@ -140,18 +132,17 @@ test.describe("Workspace pane mounting", () => { const composer = page.getByTestId("message-input-root").filter({ visible: true }).first(); const originalComposer = await captureRenderedNode(composer); - await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId)); + const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId); + await openCompactSettings(page, workspaceRoute); await page.goBack(); - await page.waitForURL( - (url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId), - ); + await waitForWorkspaceRoute(page, workspaceRoute); await expectSameRenderedNode(originalComposer, composer); } finally { await workspace.cleanup(); } }); - test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => { + test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => { test.setTimeout(90_000); const serverId = getServerId(); const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" }); @@ -170,39 +161,20 @@ test.describe("Workspace pane mounting", () => { const terminalSurface = terminalSurfaceLocator(page); const originalTerminal = await captureRenderedNode(terminalSurface); - await page.getByTestId(`workspace-tab-agent_${agent.id}`).click(); - await expectComposerVisible(page); - expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true); + await test.step("switching tabs preserves the terminal", async () => { + await page.getByTestId(`workspace-tab-agent_${agent.id}`).click(); + await expectComposerVisible(page); + await expectNodeConnected(originalTerminal); - await clickFirstTerminalTab(page); - await expectSameRenderedNode(originalTerminal, terminalSurface); - } finally { - await workspace.cleanup(); - } - }); - - test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => { - test.setTimeout(90_000); - const serverId = getServerId(); - const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" }); - - try { - const agent = await createIdleAgent(workspace.client, { - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, - title: `settings-terminal-retention-${Date.now()}`, + await clickFirstTerminalTab(page); + await expectSameRenderedNode(originalTerminal, terminalSurface); }); - await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); - await waitForWorkspaceTabsVisible(page); - await clickNewTerminal(page); - await expectTerminalSurfaceVisible(page); - const terminalSurface = terminalSurfaceLocator(page); - const originalTerminal = await captureRenderedNode(terminalSurface); - - await openSettings(page); - await clickSettingsBackToWorkspace(page); - await expectSameRenderedNode(originalTerminal, terminalSurface); + await test.step("opening Settings preserves the terminal", async () => { + await openSettings(page); + await clickSettingsBackToWorkspace(page); + await expectSameRenderedNode(originalTerminal, terminalSurface); + }); } finally { await workspace.cleanup(); } diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 281bad13b9..28654291cc 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "../support/fixtures"; +import { daemonTest, test, expect } from "../support/fixtures"; import { createTempGitRepo } from "../support/helpers/workspace"; import { closeSetupTab, @@ -117,7 +117,7 @@ test.describe("Workspace setup streaming", () => { } }); - test("streams running and completed setup snapshots for a successful setup", async () => { + daemonTest("streams running and completed setup snapshots for a successful setup", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-success-", { paseoConfig: { @@ -204,7 +204,7 @@ test.describe("Workspace setup streaming", () => { } }); - test("emits a completed empty snapshot when no setup commands exist", async () => { + daemonTest("emits a completed empty snapshot when no setup commands exist", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-none-"); @@ -283,7 +283,7 @@ test.describe("Workspace setup streaming", () => { } }); - test("launches workspace scripts through an explicit daemon request", async () => { + daemonTest("launches workspace scripts through an explicit daemon request", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-scripts-", { paseoConfig: { diff --git a/packages/app/e2e/browser/worktree-archive.spec.ts b/packages/app/e2e/browser/worktree-archive.spec.ts index e14b357e89..0db4655bea 100644 --- a/packages/app/e2e/browser/worktree-archive.spec.ts +++ b/packages/app/e2e/browser/worktree-archive.spec.ts @@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => { await tempRepo?.cleanup().catch(() => undefined); }); - test("archiving the final workspace removes its managed worktree directory", async ({ page }) => { - const serverId = getServerId(); - await openProjectViaDaemon(client, tempRepo.path); - const worktree = await createWorktreeViaDaemon(client, { - cwd: tempRepo.path, - slug: `archive-${Date.now()}`, - }); - createdWorktreeDirectories.add(worktree.workspaceDirectory); - expect(existsSync(worktree.workspaceDirectory)).toBe(true); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); - - await archiveWorkspaceFromSidebar(page, worktree.workspaceId); - - await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId); - await expect - .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) - .toBe(false); - }); - test("a managed worktree remains until its last workspace is archived", async ({ page }) => { const serverId = getServerId(); await openProjectViaDaemon(client, tempRepo.path); diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index 723f048081..d032a0f1a3 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -32,16 +32,8 @@ const metroTest = base.extend({ }, }); -const test = metroTest.extend< - { - paseoE2ESetup: void; - projectOwnership: void; - outdatedDaemon: OutdatedDaemon; - desktopManagedOutdatedDaemon: OutdatedDaemon; - relayConfigOutdatedDaemon: OutdatedDaemon; - projectPickerFixture: TrackedProjectPickerFixture; - withWorkspace: WithWorkspace; - }, +const daemonTest = metroTest.extend< + { projectOwnership: void }, { e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient } >({ e2eForkProviders: [[], { scope: "worker", option: true }], @@ -94,6 +86,16 @@ const test = metroTest.extend< }, { auto: true }, ], +}); + +const test = daemonTest.extend<{ + paseoE2ESetup: void; + outdatedDaemon: OutdatedDaemon; + desktopManagedOutdatedDaemon: OutdatedDaemon; + relayConfigOutdatedDaemon: OutdatedDaemon; + projectPickerFixture: TrackedProjectPickerFixture; + withWorkspace: WithWorkspace; +}>({ paseoE2ESetup: [ async ({ page }, provide, testInfo) => { const daemonPort = getE2EDaemonPort(); @@ -227,4 +229,4 @@ const test = metroTest.extend< }, }); -export { test, metroTest, expect, type Page }; +export { daemonTest, test, metroTest, expect, type Page }; diff --git a/packages/app/e2e/support/helpers/chat-outline.ts b/packages/app/e2e/support/helpers/chat-outline.ts index a324992916..b89f4e6e2a 100644 --- a/packages/app/e2e/support/helpers/chat-outline.ts +++ b/packages/app/e2e/support/helpers/chat-outline.ts @@ -140,6 +140,15 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number ); } +export async function expectActiveChatOutlinePromptMovedFrom( + page: Page, + position: number, +): Promise { + const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true }); + await expect(activePrompt).toHaveCount(1); + await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `)); +} + export async function expectLiveTurnPromptAboveFoldAndActive( page: Page, prompt: string, diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index cf0c42c3fb..cf4a6b14a0 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -175,7 +175,7 @@ export async function sampleTabsDuringTransition( function sample() { const tabs = Array.from( document.querySelectorAll( - '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])', + '[data-testid^="workspace-tab-"][role="button"][aria-selected]', ), ).filter((element) => element.getClientRects().length > 0); scope.__paseoTabTrackFrames?.push( @@ -188,7 +188,10 @@ export async function sampleTabsDuringTransition( requestAnimationFrame(sample); } } - requestAnimationFrame(sample); + // Establish the known-good pre-action state synchronously. Starting on the + // next animation frame lets the action race the first sample, which can + // misclassify a not-yet-painted test harness frame as a transition blank. + sample(); }, durationMs); await action(); await page.waitForTimeout(durationMs + 100); diff --git a/packages/app/e2e/support/helpers/mock-agent.ts b/packages/app/e2e/support/helpers/mock-agent.ts index b6448c4b81..d1447700a3 100644 --- a/packages/app/e2e/support/helpers/mock-agent.ts +++ b/packages/app/e2e/support/helpers/mock-agent.ts @@ -42,7 +42,7 @@ export async function seedMockAgentWorkspace( workspaceId: workspace.workspaceId, title: options.title, modeId: options.modeId ?? "load-test", - model: options.model ?? "ten-second-stream", + model: options.model ?? "e2e-fast-stream", initialPrompt: options.initialPrompt, featureValues: options.featureValues, }); diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index da67eb0092..2592697b4c 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,6 +6,12 @@ const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts"; +function videoMode(): "on" | "on-first-retry" | "retain-on-failure" { + if (process.env.E2E_RECORD_VIDEO === "1") return "on"; + if (process.env.CI) return "on-first-retry"; + return "retain-on-failure"; +} + export default defineConfig({ testDir: "./e2e/browser", globalSetup: "./e2e/support/global-setup.ts", @@ -21,9 +27,9 @@ export default defineConfig({ reporter: [["list"]], use: { baseURL, - trace: "retain-on-failure", + trace: process.env.CI ? "on-first-retry" : "retain-on-failure", screenshot: "only-on-failure", - video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure", + video: videoMode(), }, projects: [ { diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index e40795aad1..9fbd6d8b62 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -131,6 +131,17 @@ const MODELS: AgentModelDefinition[] = [ intervalMs: 5, }, }, + { + provider: MOCK_LOAD_TEST_PROVIDER_ID, + id: "e2e-fast-stream", + label: "E2E fast stream", + description: "Short deterministic stream for browser tests.", + isSelectable: false, + metadata: { + durationMs: 2_000, + intervalMs: 20, + }, + }, ]; interface ActiveTurn {