diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e3f65f336..7afb95a852 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -405,9 +405,9 @@ jobs: run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - # Weight file groups by observed duration; raw test counts overload - # early agent/Changes specs. Required check names remain unchanged. - PWTEST_SHARD_WEIGHTS: "111:172:137:160" + # Balance contiguous file groups using CI durations and the trimmed + # inventory. Required check names and worker counts stay unchanged. + PWTEST_SHARD_WEIGHTS: "183:185:181:202" - name: Upload test artifacts uses: actions/upload-artifact@v4 diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 38598f4e65..988e1c28e5 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -460,7 +460,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( const target = await seedMockAgentWorkspace({ repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`, title: "Hidden streaming submission", - model: "ten-second-stream", + model: "e2e-fast-stream", }); const evictionAgents = await Promise.all( Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) => @@ -549,7 +549,7 @@ async function expectCompletedSubmissionClearsAfterMissedRunningTransition( const agent = await seedMockAgentWorkspace({ repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`, title: "Submission missed running transition", - model: "ten-second-stream", + model: "e2e-fast-stream", }); try { await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); @@ -700,7 +700,7 @@ async function expectCanonicalOrderWinsAcrossOverlappingClients( const agent = await seedMockAgentWorkspace({ repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`, title: "Cross-client submission order", - model: "ten-second-stream", + model: "e2e-fast-stream", }); const localPrompt = "Send this after the other client turn."; const remotePrompt = "Commit this other client turn first."; diff --git a/packages/app/e2e/browser/agent-skills-settings.spec.ts b/packages/app/e2e/browser/agent-skills-settings.spec.ts index e9f5d5a04d..0ade06fb47 100644 --- a/packages/app/e2e/browser/agent-skills-settings.spec.ts +++ b/packages/app/e2e/browser/agent-skills-settings.spec.ts @@ -36,15 +36,25 @@ test.describe("Host agent skills", () => { page, skills, }) => { + await page.setViewportSize({ width: 1280, height: 900 }); const custom = skills.available.slice(0, 2); const cancelledAddition = skills.available[2]!; await openAgentSkillsSettings(page, skills); await openSkillSelection(page); + await test.step("the desktop sheet gives the bundled label its intended inset", async () => { + await expectBundledSkillsInset(page); + }); await chooseCustomSkills(page, custom); await saveSkillSelection(page); await expectInstalledSkills(skills, custom); await expectSavedSelection(skills, { mode: "custom", skills: custom }); + await test.step("installation ignores agent config environment overrides", async () => { + await expect(access(`${skills.home}/../ignored-claude-config/skills`)).rejects.toThrow( + "ENOENT", + ); + await expect(access(`${skills.home}/../ignored-codex-home/skills`)).rejects.toThrow("ENOENT"); + }); await openSkillSelection(page); await toggleSkill(page, cancelledAddition); @@ -150,23 +160,6 @@ test.describe("Host agent skills", () => { await expectInstalledSkills(skills, secondSelection.skills); await expectSavedSelection(skills, secondSelection); }); - - test("ignores agent config environment overrides", async ({ skills }) => { - await skills.install({ mode: "custom", skills: [skills.available[0]!] }); - - await expect( - access(`${skills.home}/../ignored-claude-config/skills`).then( - () => true, - () => false, - ), - ).resolves.toBe(false); - await expect( - access(`${skills.home}/../ignored-codex-home/skills`).then( - () => true, - () => false, - ), - ).resolves.toBe(false); - }); }); function allSkillsCard(page: Parameters[0]) { @@ -192,24 +185,14 @@ test.describe("Agent skills sheet inset", () => { await openSkillSelection(page); await waitForSettledPosition(page.getByTestId("skill-selection-sheet")); - expect({ - aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), - belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), - }).toEqual({ aboveLabel: 16, belowLabel: 12 }); - }); - }); - - test.describe("desktop", () => { - test.use({ viewport: { width: 1280, height: 900 } }); - - test("keeps more space above the bundled skills label than below", async ({ page, skills }) => { - await openAgentSkillsSettings(page, skills); - await openSkillSelection(page); - - expect({ - aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), - belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), - }).toEqual({ aboveLabel: 16, belowLabel: 12 }); + await expectBundledSkillsInset(page); }); }); }); + +async function expectBundledSkillsInset(page: Parameters[0]) { + expect({ + aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), + belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), + }).toEqual({ aboveLabel: 16, belowLabel: 12 }); +} diff --git a/packages/app/e2e/browser/archive-tab.spec.ts b/packages/app/e2e/browser/archive-tab.spec.ts index 6ee80ddf1e..d98036aaea 100644 --- a/packages/app/e2e/browser/archive-tab.spec.ts +++ b/packages/app/e2e/browser/archive-tab.spec.ts @@ -51,7 +51,9 @@ test.describe("Archive tab reconciliation", () => { await tempRepo?.cleanup(); }); - test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => { + test("non-UI archive prunes open tabs and keeps the session archived when reopened", async ({ + page, + }) => { const archived = await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, @@ -65,28 +67,51 @@ test.describe("Archive tab reconciliation", () => { const passivePage = await page.context().newPage(); try { - await primeAdditionalPage(passivePage); - await resetSeededPageState(page); - await resetSeededPageState(passivePage); - await openSessions(page); - await expectSessionRowVisible(page, archived.title); - await expectSessionRowVisible(page, surviving.title); - await openSessions(passivePage); - await expectSessionRowVisible(passivePage, archived.title); - await expectSessionRowVisible(passivePage, surviving.title); - await openWorkspaceWithAgents(page, [archived, surviving]); - await openWorkspaceWithAgents(passivePage, [archived, surviving]); + await test.step("open both sessions in two pages", async () => { + await primeAdditionalPage(passivePage); + await resetSeededPageState(page); + await resetSeededPageState(passivePage); + await openSessions(page); + await expectSessionRowVisible(page, archived.title); + await expectSessionRowVisible(page, surviving.title); + await openSessions(passivePage); + await expectSessionRowVisible(passivePage, archived.title); + await expectSessionRowVisible(passivePage, surviving.title); + await openWorkspaceWithAgents(page, [archived, surviving]); + await openWorkspaceWithAgents(passivePage, [archived, surviving]); + }); + await archiveAgentFromDaemon(client, archived.id); - await expectWorkspaceArchiveOutcome(page, { - archivedAgentId: archived.id, - survivingAgentId: surviving.id, + const archivedAt = await fetchAgentArchivedAt(client, archived.id); + expect(archivedAt).not.toBeNull(); + + await test.step("prune the archived tab across both pages and reload", async () => { + await expectWorkspaceArchiveOutcome(page, { + archivedAgentId: archived.id, + survivingAgentId: surviving.id, + }); + await expectWorkspaceArchiveOutcome(passivePage, { + archivedAgentId: archived.id, + survivingAgentId: surviving.id, + }); + await reloadWorkspace(passivePage, surviving.workspaceId); + await expectWorkspaceTabHidden(passivePage, archived.id); }); - await expectWorkspaceArchiveOutcome(passivePage, { - archivedAgentId: archived.id, - survivingAgentId: surviving.id, + + await test.step("reopen the archived session without unarchiving it", async () => { + await openSessions(page); + await expectSessionRowArchived(page, archived.title); + + await clickSessionRow(page, archived.title); + + await expectArchivedAgentFocused(page, archived.id); + await expectAgentTabActive(page, archived.id); + expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt); + + await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), { + timeout: 30_000, + }); }); - await reloadWorkspace(passivePage, surviving.workspaceId); - await expectWorkspaceTabHidden(passivePage, archived.id); } finally { await passivePage.close(); } @@ -123,35 +148,4 @@ test.describe("Archive tab reconciliation", () => { await passivePage.close(); } }); - - test("clicking an archived session navigates without unarchiving it", async ({ page }) => { - const archived = await createIdleAgent(client, { - cwd: tempRepo.path, - workspaceId, - title: `unarchive-archived-${randomUUID().slice(0, 8)}`, - }); - const surviving = await createIdleAgent(client, { - cwd: tempRepo.path, - workspaceId, - title: `unarchive-control-${randomUUID().slice(0, 8)}`, - }); - - await resetSeededPageState(page); - await openWorkspaceWithAgents(page, [archived, surviving]); - await archiveAgentFromDaemon(client, archived.id); - const archivedAt = await fetchAgentArchivedAt(client, archived.id); - expect(archivedAt).not.toBeNull(); - await openSessions(page); - await expectSessionRowArchived(page, archived.title); - - await clickSessionRow(page, archived.title); - - await expectArchivedAgentFocused(page, archived.id); - await expectAgentTabActive(page, archived.id); - expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt); - - await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), { - timeout: 30_000, - }); - }); }); diff --git a/packages/app/e2e/browser/assistant-fork-menu.spec.ts b/packages/app/e2e/browser/assistant-fork-menu.spec.ts index b5a0b0c27a..1aacbb3846 100644 --- a/packages/app/e2e/browser/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/browser/assistant-fork-menu.spec.ts @@ -90,7 +90,7 @@ test.describe("Assistant fork menu", () => { await expectLiveAssistantText(page, visibleAfterFork); }); - test("focuses a forked assistant turn in a new workspace draft tab", async ({ + test("focuses an existing-workspace fork draft and keeps its attachment after submission", async ({ page, seedForkWorkspace, }) => { @@ -101,55 +101,44 @@ test.describe("Assistant fork menu", () => { model: "ten-second-stream", }); - await openAgentRoute(page, session); - await expectComposerVisible(page); - await awaitAssistantMessage(page); - await session.client.waitForFinish(session.agentId, 45_000); + await test.step("complete a second assistant turn in the visible source tab", async () => { + await openAgentRoute(page, session); + await expectComposerVisible(page); + await awaitAssistantMessage(page); + await session.client.waitForFinish(session.agentId, 45_000); - await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible."); - await session.client.waitForFinish(session.agentId, 45_000); - await awaitAssistantMessage(page); - - const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`); - await expect(agentTab).toHaveAttribute("aria-selected", "true"); - - await forkMostRecentAssistantTurnToNewTab(page); - - const selectedTab = page - .getByTestId("workspace-tabs-row") - .getByRole("button") - .and(page.locator('[aria-selected="true"]')); - await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, { - timeout: 30_000, - }); - await expect(agentTab).toHaveAttribute("aria-selected", "false"); - await expectChatHistoryAttachment(page); - }); - - test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({ - page, - seedForkWorkspace, - }) => { - const session = await seedForkWorkspace({ - repoPrefix: "assistant-fork-tab-submit-", - title: "Assistant fork tab submit", - initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.", - model: "ten-second-stream", + await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible."); + await session.client.waitForFinish(session.agentId, 45_000); + await awaitAssistantMessage(page); }); - await openAgentRoute(page, session); - await expectComposerVisible(page); - await awaitAssistantMessage(page); - await session.client.waitForFinish(session.agentId, 45_000); + await test.step("fork the latest turn into the selected draft tab", async () => { + const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`); + await expect(agentTab).toHaveAttribute("aria-selected", "true"); - await forkMostRecentAssistantTurnToNewTab(page); - await expectChatHistoryAttachment(page); + await forkMostRecentAssistantTurnToNewTab(page); - await submitMessage(page, ""); + const selectedTab = page + .getByTestId("workspace-tabs-row") + .getByRole("button") + .and(page.locator('[aria-selected="true"]')); + await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, { + timeout: 30_000, + }); + await expect(agentTab).toHaveAttribute("aria-selected", "false"); + await expectChatHistoryAttachment(page); + }); - const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last(); - await expect(userMessage).toBeVisible({ timeout: 30_000 }); - await expect(userMessage).not.toContainText("Source agent:"); + await test.step("submit that draft with its chat-history attachment", async () => { + await submitMessage(page, ""); + + const userMessage = page + .getByTestId("user-message") + .filter({ hasText: "Chat history" }) + .last(); + await expect(userMessage).toBeVisible({ timeout: 30_000 }); + await expect(userMessage).not.toContainText("Source agent:"); + }); }); test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({ diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index 640b141f53..ec336efdce 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../support/fixtures"; +import { expect, test, type Page } from "../support/fixtures"; import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate"; import { clickChatOutlineRowEdge, @@ -50,6 +50,43 @@ import { const WIDE_VIEWPORT = { width: 1440, height: 900 }; const LOADED_TURNS = 16; +async function navigateLoadedOutline(page: Page, agent: LongTimelineAgent): Promise { + await test.step("preview across the row", async () => { + await expectOneActiveChatOutlinePrompt(page); + await hoverChatOutlinePrompt(page, 2); + await expectChatOutlinePreview(page, agent.prompts[1]); + + await pointAtChatOutlineRowEdge(page, 7); + await expectChatOutlinePreview(page, agent.prompts[6]); + + await movePointerOffChatOutline(page); + await expectNoChatOutlinePreview(page); + }); + await test.step("jump, dismiss preview, and retain bare selection", async () => { + await clickChatOutlineRowEdge(page, 4); + await expectChatOutlinePromptToRemainBare(page, 4); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]); + await expectActiveChatOutlinePrompt(page, 4); + await movePointerOffChatOutline(page); + await expectNoChatOutlinePreview(page); + }); + await test.step("track the active prompt while jumping and scrolling", async () => { + await expectOneActiveChatOutlinePrompt(page); + await scrollTimelineToNewestLoadedEdge(page); + await expectTimelinePromptVisible(page, agent.newestPrompt); + await expectActiveChatOutlinePromptMovedFrom(page, 4); + }); + await test.step("clamp the newest prompt at maximum scroll", async () => { + await clickChatOutlineRowEdge(page, LOADED_TURNS); + await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt); + }); +} + +async function jumpToNewerLoadedPrompt(page: Page, agent: LongTimelineAgent): Promise { + await clickChatOutlineRowEdge(page, 60); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]); +} + test.describe("desktop chat outline", () => { test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({ page, @@ -183,28 +220,14 @@ test.describe("desktop chat outline", () => { await expectChatOutlinePrompts(page, LOADED_TURNS); }); - test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => { - await hoverChatOutlinePrompt(page, 2); - await expectChatOutlinePreview(page, agent.prompts[1]); - - await pointAtChatOutlineRowEdge(page, 7); - await expectChatOutlinePreview(page, agent.prompts[6]); - - await movePointerOffChatOutline(page); - await expectNoChatOutlinePreview(page); + test("previews, jumps, and tracks the reader through loaded prompts", async ({ page }) => { + await navigateLoadedOutline(page, agent); }); test("does not preview while crossing the rail toward the sidebar", async ({ page }) => { await expectNoChatOutlinePreviewWhileCrossingToSidebar(page); }); - test("closes the preview when the pointer leaves after a jump", async ({ page }) => { - await clickChatOutlineRowEdge(page, 4); - await movePointerOffChatOutline(page); - - await expectNoChatOutlinePreview(page); - }); - test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => { await focusChatOutlinePrompt(page, 1); await expectChatOutlinePreview(page, agent.prompts[0]); @@ -215,32 +238,6 @@ test.describe("desktop chat outline", () => { await expectActiveChatOutlinePrompt(page, 1); }); - test("moves the active mark as the reader jumps and scrolls", async ({ page }) => { - await expectOneActiveChatOutlinePrompt(page); - - await clickChatOutlineRowEdge(page, 4); - await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]); - await expectActiveChatOutlinePrompt(page, 4); - - await scrollTimelineToNewestLoadedEdge(page); - await expectTimelinePromptVisible(page, agent.newestPrompt); - await expectActiveChatOutlinePromptMovedFrom(page, 4); - }); - - test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => { - await clickChatOutlineRowEdge(page, 4); - - await expectChatOutlinePromptToRemainBare(page, 4); - }); - - test("clamps the newest prompt at maximum scroll while keeping it visible", async ({ - page, - }) => { - await clickChatOutlineRowEdge(page, LOADED_TURNS); - - await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt); - }); - test("keeps the current prompt active throughout a long live assistant turn", async ({ page, }) => { @@ -282,21 +279,11 @@ test.describe("desktop chat outline", () => { await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]); expect(history.requestCount()).toBe(requestsBeforeJump); - }); - test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => { - const history = await holdOlderHistoryPages(page, agent); - await openAgentTimeline(page, agent); - await expectChatOutlinePrompts(page, 80); - await scrollThroughOlderHistoryPages(page, 2, history); - await scrollTimelineToNewestLoadedEdge(page); - - await clickChatOutlineRowEdge(page, 30); - await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]); - - await clickChatOutlineRowEdge(page, 60); - - await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]); + await test.step( + "jump downward from old history to a newer loaded prompt", + jumpToNewerLoadedPrompt.bind(null, page, agent), + ); }); }); diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 5d024de761..b3c5f4baab 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -173,6 +173,14 @@ for (const layout of [ }); } +async function captureContextThemes(page: Page, surface: string, state: string) { + for (const theme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme: theme }); + await expect(contextWindowDetails(page)).toBeVisible(); + await qaScreenshot(page, `${surface}-${theme}-${state}`); + } +} + type OpenDetails = (page: Page) => Promise; type Shot = (state: string) => Promise; @@ -241,140 +249,138 @@ async function expectOnlyContextWindowWithoutUsage( await shot("unsupported"); } -for (const theme of ["light", "dark"] as const) { - test(`the context window hover card shows the agent's usage (${theme})`, async ({ - page, - agent, - }) => { - test.setTimeout(240_000); - const [claude] = claudeAndCodexReports(); - const usage = await scriptAgentUsage(page); - await page.emulateMedia({ colorScheme: theme }); - await page.setViewportSize(DESKTOP); - await openAgent(page, agent); - const shot: Shot = (state) => qaScreenshot(page, `hover-card-desktop-${theme}-${state}`); +test("the context window hover card shows the agent's usage in both themes", async ({ + page, + agent, +}) => { + test.setTimeout(240_000); + const [claude] = claudeAndCodexReports(); + const usage = await scriptAgentUsage(page); + await page.emulateMedia({ colorScheme: "light" }); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + const shot: Shot = (state) => captureContextThemes(page, "hover-card-desktop", state); - await test.step("reports stream in one card at a time, each with Refresh", async () => { - const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); - await expectRefreshButtons(card, 2); - await shot("ready"); - expect(usage.agentRequests()).toEqual([agent.agentId]); - }); - - await test.step("the card stays open while the pointer moves onto it", async () => { - const card = contextWindowDetails(page); - await moveOntoContextWindowDetails(page, card); - await page.waitForTimeout(300); - await expect(card).toBeVisible(); - }); - - await test.step("Refresh keeps the card open and replaces it with the new report", async () => { - const refreshed = gate(); - usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] }); - const card = contextWindowDetails(page); - await refreshUsageCard(card, "Claude"); - await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled(); - // Past the card's close grace, with the pointer still on the card. - await page.waitForTimeout(500); - await expect(card).toBeVisible(); - refreshed.open(); - await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedAgents()).toEqual([agent.agentId]); - }); - - await test.step("after a resume under another login, only that login shows", async () => { - // Refresh can retain focus; return to the composer before testing pointer dismissal. - await composerLocator(page).focus(); - // Closed while the old login's report is still on its way. - await leaveContextWindowMeter(page); - const slowSource = gate(); - usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] }); - const closedEarly = await hoverContextWindowMeter(page); - await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible(); - await leaveContextWindowMeter(page); - - const resumed = gate(); - const finished = gate(); - usage.answerNext({ - stream: [resumed.promise, onPersonalLogin(claude!), finished.promise], - }); - const card = await hoverContextWindowMeter(page); - await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible(); - await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); - - // The old login's report lands during the new request, before the new login's. - slowSource.open(); - resumed.open(); - await expect( - usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }), - ).toBeVisible(); - await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); - finished.open(); - }); - - await test.step("a report with a problem shows it on the card", async () => { - usage.answerNext([expiredLogin(onWorkLogin(claude!))]); - await reloadAgent(page); - const card = await hoverContextWindowMeter(page); - await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); - await shot("problem"); - }); - - await test.step("a failed request says so in a sentence", async () => { - await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot); - }); - - await test.step("a host without usage reports shows only the context window", async () => { - await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); - // One request per open that had usage; this one sends none. - expect(usage.agentRequests()).toHaveLength(6); - }); + await test.step("reports stream in one card at a time, each with Refresh", async () => { + const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); + await expectRefreshButtons(card, 2); + await shot("ready"); + expect(usage.agentRequests()).toEqual([agent.agentId]); }); - test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({ - page, - agent, - }) => { - test.setTimeout(240_000); - const [claude] = claudeAndCodexReports(); - const usage = await scriptAgentUsage(page); - await page.emulateMedia({ colorScheme: theme }); - await page.setViewportSize(COMPACT); - await openAgent(page, agent); - const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`); - - await test.step("reports stream in one card at a time, each with Refresh", async () => { - const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot); - await expectRefreshButtons(sheet, 2); - // Without hover, each card prints its freshness instead of a Refresh tooltip. - await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2); - await shot("ready"); - expect(usage.agentRequests()).toEqual([agent.agentId]); - }); - - await test.step("Refresh replaces the card with the source's new report", async () => { - usage.answerNext([expiredLogin(onWorkLogin(claude!))]); - const sheet = contextWindowDetails(page); - await refreshUsageCard(sheet, "Claude"); - await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedAgents()).toEqual([agent.agentId]); - await shot("problem"); - }); - - await test.step("Close dismisses the sheet", async () => { - await closeContextWindowSheet(page); - }); - - await test.step("a failed request says so in a sentence", async () => { - await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot); - }); - - await test.step("a host without usage reports shows only the context window", async () => { - await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); - expect(usage.agentRequests()).toHaveLength(3); - }); + await test.step("the card stays open while the pointer moves onto it", async () => { + const card = contextWindowDetails(page); + await moveOntoContextWindowDetails(page, card); + await page.waitForTimeout(300); + await expect(card).toBeVisible(); }); -} + + await test.step("Refresh keeps the card open and replaces it with the new report", async () => { + const refreshed = gate(); + usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] }); + const card = contextWindowDetails(page); + await refreshUsageCard(card, "Claude"); + await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled(); + // Past the card's close grace, with the pointer still on the card. + await page.waitForTimeout(500); + await expect(card).toBeVisible(); + refreshed.open(); + await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); + }); + + await test.step("after a resume under another login, only that login shows", async () => { + // Refresh can retain focus; return to the composer before testing pointer dismissal. + await composerLocator(page).focus(); + // Closed while the old login's report is still on its way. + await leaveContextWindowMeter(page); + const slowSource = gate(); + usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] }); + const closedEarly = await hoverContextWindowMeter(page); + await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible(); + await leaveContextWindowMeter(page); + + const resumed = gate(); + const finished = gate(); + usage.answerNext({ + stream: [resumed.promise, onPersonalLogin(claude!), finished.promise], + }); + const card = await hoverContextWindowMeter(page); + await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible(); + await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); + + // The old login's report lands during the new request, before the new login's. + slowSource.open(); + resumed.open(); + await expect( + usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }), + ).toBeVisible(); + await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); + finished.open(); + }); + + await test.step("a report with a problem shows it on the card", async () => { + usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + await reloadAgent(page); + const card = await hoverContextWindowMeter(page); + await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); + await shot("problem"); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot); + }); + + await test.step("a host without usage reports shows only the context window", async () => { + await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); + // One request per open that had usage; this one sends none. + expect(usage.agentRequests()).toHaveLength(6); + }); +}); + +test("the context window sheet shows the agent's usage and refreshes it in both themes", async ({ + page, + agent, +}) => { + test.setTimeout(240_000); + const [claude] = claudeAndCodexReports(); + const usage = await scriptAgentUsage(page); + await page.emulateMedia({ colorScheme: "light" }); + await page.setViewportSize(COMPACT); + await openAgent(page, agent); + const shot: Shot = (state) => captureContextThemes(page, "sheet-compact", state); + + await test.step("reports stream in one card at a time, each with Refresh", async () => { + const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot); + await expectRefreshButtons(sheet, 2); + // Without hover, each card prints its freshness instead of a Refresh tooltip. + await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2); + await shot("ready"); + expect(usage.agentRequests()).toEqual([agent.agentId]); + }); + + await test.step("Refresh replaces the card with the source's new report", async () => { + usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + const sheet = contextWindowDetails(page); + await refreshUsageCard(sheet, "Claude"); + await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); + await shot("problem"); + }); + + await test.step("Close dismisses the sheet", async () => { + await closeContextWindowSheet(page); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot); + }); + + await test.step("a host without usage reports shows only the context window", async () => { + await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); + expect(usage.agentRequests()).toHaveLength(3); + }); +}); test("context details support keyboard entry and dismiss when the viewport moves", async ({ page, @@ -420,36 +426,38 @@ test("context details support keyboard entry and dismiss when the viewport moves await expect(details).toHaveCount(0); }); -test("pointer hovering context details preserves composer focus", async ({ page, agent }) => { +test("context details preserve composer focus on hover and leave Find available after keyboard dismissal", async ({ + page, + agent, +}) => { const usage = await scriptAgentUsage(page); await page.setViewportSize(DESKTOP); await openAgent(page, agent); const [claude] = claudeAndCodexReports(); - usage.answerNext([onPersonalLogin(claude!)]); - const input = composerLocator(page); - await input.focus(); - await hoverContextWindowMeter(page); - await expect(input).toBeFocused(); - await leaveContextWindowMeter(page); - await expect(input).toBeFocused(); -}); -test("Find remains available after dismissing context details", async ({ page, agent }) => { - const usage = await scriptAgentUsage(page); - await page.setViewportSize(DESKTOP); - await openAgent(page, agent); - const [claude] = claudeAndCodexReports(); - usage.answerNext([onPersonalLogin(claude!)]); - const meter = page.getByTestId("context-window-meter"); - const trigger = page.getByRole("button", { - name: (await meter.getAttribute("aria-label"))!, - exact: true, + await test.step("pointer entry and dismissal preserve composer focus", async () => { + usage.answerNext([onPersonalLogin(claude!)]); + const input = composerLocator(page); + await input.focus(); + await hoverContextWindowMeter(page); + await expect(input).toBeFocused(); + await leaveContextWindowMeter(page); + await expect(input).toBeFocused(); + }); + + await test.step("keyboard dismissal restores trigger focus and Find remains available", async () => { + usage.answerNext([onPersonalLogin(claude!)]); + const meter = page.getByTestId("context-window-meter"); + const trigger = page.getByRole("button", { + name: (await meter.getAttribute("aria-label"))!, + exact: true, + }); + await trigger.focus(); + await expect(contextWindowDetails(page)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(contextWindowDetails(page)).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.keyboard.press("ControlOrMeta+f"); + await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused(); }); - await trigger.focus(); - await expect(contextWindowDetails(page)).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(contextWindowDetails(page)).toHaveCount(0); - await expect(trigger).toBeFocused(); - await page.keyboard.press("ControlOrMeta+f"); - await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused(); }); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 0d63336d59..d0f2277395 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -105,25 +105,41 @@ async function seedAgentWithFileLink(input: LinkedFile) { } test.describe("CodeMirror workspace file editing", () => { - test("opens explicit screenshot links and explains missing files", async ({ page }) => { + test("opens screenshot and non-ASCII Markdown links and explains missing files", async ({ + page, + }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "screenshot-markdown-link-", title: "Screenshot file links", initialPrompt: "Show screenshots", featureValues: { mockAssistantResponse: - "[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)", + "[Capture](.dev/tablet-review/capture.png) [报告](docs/reports/开户赠金.md) [Missing capture](.dev/tablet-review/missing.png)", }, }); try { await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true }); await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL); + await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true }); + await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n"); await openAgentRoute(page, session); await test.step("Open the linked screenshot", async () => { await openAssistantMarkdownLink(page, "Capture"); await expectFileTabOpen(page, ".dev/tablet-review/capture.png"); await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible(); }); + await test.step("Open the non-ASCII report named by its Markdown link", async () => { + await returnToAssistant(page, session.agentId); + await openAssistantMarkdownLink(page, "报告"); + await expectFileTabOpen(page, "docs/reports/开户赠金.md"); + await expect( + page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByText("Report opened", { exact: true }), + ).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") }); + }); await test.step("Explain why the missing screenshot cannot open", async () => { await returnToAssistant(page, session.agentId); await openAssistantMarkdownLink(page, "Missing capture"); @@ -136,31 +152,6 @@ test.describe("CodeMirror workspace file editing", () => { } }); - test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => { - const session = await seedMockAgentWorkspace({ - repoPrefix: "encoded-markdown-link-", - title: "Non-ASCII file link", - initialPrompt: "Show the report link", - featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" }, - }); - try { - await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true }); - await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n"); - await openAgentRoute(page, session); - await openAssistantMarkdownLink(page, "报告"); - await expectFileTabOpen(page, "docs/reports/开户赠金.md"); - await expect( - page - .getByTestId("workspace-file-pane") - .filter({ visible: true }) - .getByText("Report opened", { exact: true }), - ).toBeVisible(); - await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") }); - } finally { - await session.cleanup(); - } - }); - for (const order of ["raw-first", "href-first"] as const) { test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ @@ -690,7 +681,37 @@ test.describe("CodeMirror workspace file editing", () => { const imagePath = path.join(workspace.repoPath, "pixel.png"); await writeFile(markdownPath, "# First heading\n", "utf8"); await writeFile(imagePath, RED_PIXEL); + await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true }); + await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true }); + await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL); + await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL); + await writeFile( + path.join(workspace.repoPath, "docs", "guide.md"), + [ + "# Guide", + "", + "![sibling](./sibling.png)", + "", + "![hero](../data/cache/hero.png?v=2#preview)", + "", + ].join("\n"), + "utf8", + ); await workspace.navigateTo(); + await test.step("render sibling and parent-relative images in Markdown preview", async () => { + await openFileExplorer(page); + await expandFolder(page, "docs"); + await openFileFromExplorer(page, "guide.md"); + await expectFileTabOpen(page, "docs/guide.md"); + + const guidePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); + await expect(guidePane.getByText("Guide", { exact: true })).toBeVisible(); + await expectMarkdownImageLoaded(page, "sibling"); + await expectMarkdownImageLoaded(page, "hero"); + await guidePane.screenshot({ + path: test.info().outputPath("markdown-relative-images.png"), + }); + }); await openWorkspaceFile(page, "notes.md"); const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); @@ -724,84 +745,30 @@ test.describe("CodeMirror workspace file editing", () => { await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource); }); - test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => { - test.setTimeout(90_000); - const workspace = await withWorkspace({ prefix: "file-md-relative-image-" }); - await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true }); - await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true }); - await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL); - await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL); - await writeFile( - path.join(workspace.repoPath, "docs", "guide.md"), - [ - "# Guide", - "", - "![sibling](./sibling.png)", - "", - "![hero](../data/cache/hero.png?v=2#preview)", - "", - ].join("\n"), - "utf8", - ); - await workspace.navigateTo(); - await openFileExplorer(page); - await expandFolder(page, "docs"); - await openFileFromExplorer(page, "guide.md"); - await expectFileTabOpen(page, "docs/guide.md"); - - const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); - await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible(); - await expectMarkdownImageLoaded(page, "sibling"); - await expectMarkdownImageLoaded(page, "hero"); - await visibleFilePane.screenshot({ - path: test.info().outputPath("markdown-relative-images.png"), - }); - }); - - test("previews and refreshes an HTML plan while preserving source access", async ({ + test("isolates an HTML preview, blocks fetch egress, and refreshes while retaining source access", async ({ page, withWorkspace, }) => { - test.setTimeout(90_000); - const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" }); - const htmlPath = path.join(workspace.repoPath, "plan.html"); - await writeFile( - htmlPath, - "

Visual plan

", - "utf8", - ); - await workspace.navigateTo(); - await openWorkspaceFile(page, "plan.html"); - - const preview = htmlPreview(page); - await expect(preview.host).toBeVisible(); - await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/); - await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/); - await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible(); - - await writeFile( - htmlPath, - "

Updated plan

", - "utf8", - ); - await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible(); - - await selectFileView(page, "Source"); - await expect(page.getByTestId("file-source-editor")).toBeVisible(); - await expect(preview.host).toHaveCount(0); - await selectFileView(page, "Preview"); - await expect(preview.host).toBeVisible(); - }); - - test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => { test.setTimeout(90_000); const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" }); await writeFile( path.join(workspace.repoPath, "probe.html"), `probe +

Visual plan

Inline script did not run

Network request not attempted

Standards mode not detected

+

?

?

+ + -`, - "utf8", - ); - await workspace.navigateTo(); - await openWorkspaceFile(page, "origin.html"); - - const preview = htmlPreview(page); - await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible(); - await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible(); - await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible(); + await test.step("isolate the preview from app origin and storage", async () => { + await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/); + await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/); + await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible(); + await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible(); + await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible(); + }); + await test.step("refresh a changed HTML plan and switch to source and back", async () => { + await writeFile( + path.join(workspace.repoPath, "probe.html"), + "

Updated plan

", + "utf8", + ); + await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible(); + await selectFileView(page, "Source"); + await expect(page.getByTestId("file-source-editor")).toBeVisible(); + await expect(preview.host).toHaveCount(0); + await selectFileView(page, "Preview"); + await expect(preview.host).toBeVisible(); + }); }); test("persists Vim keybindings and reports Vim mode with cursor position", async ({ diff --git a/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts b/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts index f3f1742c69..ba181ba4e0 100644 --- a/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts +++ b/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts @@ -88,11 +88,23 @@ async function openNewWorkspaceFromContextProject( await expectNewWorkspaceProjectSelected(page, "Context project"); } -const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({ - crossHostProject: async ({ page: _page }, provide) => { - const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon( - "new-workspace-project-preservation-secondary", - ); +const test = base.extend< + { crossHostProject: CrossHostProjectScenario }, + { secondaryDaemon: IsolatedHostDaemon } +>({ + secondaryDaemon: [ + async ({ e2eWorker }, provide) => { + void e2eWorker; + const daemon = await startIsolatedHostDaemon("new-workspace-project-preservation-secondary"); + try { + await provide(daemon); + } finally { + await daemon.close(); + } + }, + { scope: "worker" }, + ], + crossHostProject: async ({ page: _page, secondaryDaemon }, provide) => { const contextRepo = await createTempGitRepo("new-workspace-context-"); const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", { originUrl: SHARED_REMOTE_URL, @@ -177,7 +189,6 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({ await primaryDuplicateRepo.cleanup().catch(() => undefined); await secondaryFallbackRepo.cleanup().catch(() => undefined); await secondarySharedRepo.cleanup().catch(() => undefined); - await secondaryDaemon.close().catch(() => undefined); } }, }); diff --git a/packages/app/e2e/browser/pane-find.spec.ts b/packages/app/e2e/browser/pane-find.spec.ts index 113a582e0e..c349a85d6e 100644 --- a/packages/app/e2e/browser/pane-find.spec.ts +++ b/packages/app/e2e/browser/pane-find.spec.ts @@ -226,10 +226,19 @@ test.describe("narrow touch browser", () => { await source(page).press("ControlOrMeta+f"); await expect(query(page)).toBeFocused(); await query(page).fill("needle"); - await page.getByRole("button", { name: "Next match" }).tap(); - await expect(status(page)).toHaveText("2 of 2"); - await expectFindInsidePane(page); - await page.screenshot({ path: testInfo.outputPath("touch-find.png") }); + await test.step("reveal the first match below the floating Find widget", async () => { + await expect(status(page)).toHaveText("1 of 2"); + await expectActiveMatchUncovered(page); + }); + await test.step("tap the next uncovered match while controls stay inside the pane", async () => { + await page.getByRole("button", { name: "Next match" }).tap(); + await expect(status(page)).toHaveText("2 of 2"); + await expectActiveMatchUncovered(page); + await expectFindInsidePane(page); + await page.screenshot({ path: testInfo.outputPath("touch-find.png") }); + await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") }); + await expect(query(page)).toHaveValue("needle"); + }); await page.getByRole("button", { name: "Close Find" }).tap(); await expect(source(page)).toBeFocused(); }); @@ -332,33 +341,6 @@ for (const startingField of ["Find in pane", "Replace with"] as const) { } test.describe("the active match stays visible under the floating widget", () => { - test.describe("narrow touch browser", () => { - test.use({ hasTouch: true }); - test("reveals a short file's match that the widget would cover", async ({ - page, - withWorkspace, - }, testInfo) => { - const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" }); - await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n"); - await workspace.navigateTo(); - await openSource(page, "touch.txt"); - await page.setViewportSize({ width: 390, height: 844 }); - await source(page).focus(); - await source(page).press("ControlOrMeta+f"); - await expect(query(page)).toBeFocused(); - await query(page).fill("needle"); - await expect(status(page)).toHaveText("1 of 2"); - await expectActiveMatchUncovered(page); - await page.getByRole("button", { name: "Next match" }).tap(); - await expect(status(page)).toHaveText("2 of 2"); - await expectActiveMatchUncovered(page); - await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") }); - await expect(query(page)).toHaveValue("needle"); - await page.getByRole("button", { name: "Close Find" }).tap(); - await expect(source(page)).toBeFocused(); - }); - }); - test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({ page, withWorkspace, diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 85de01a376..a553c99c7e 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -316,7 +316,9 @@ test.describe("Sidebar workspace list", () => { } }); - test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => { + test("opens a GitHub-backed workspace with folder metadata and pointer and keyboard hover cards", async ({ + page, + }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-remote-", repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, @@ -335,6 +337,46 @@ test.describe("Sidebar workspace list", () => { await expect(projectRow).toBeVisible({ timeout: 30_000 }); await expect(projectRow).not.toContainText("test-owner/test-repo"); + expect(workspace.workspaceName).toBe("main"); + const row = await waitForSidebarWorkspace(page, workspace.workspaceId); + await expect(row).toContainText("main"); + + await test.step("inspect host metadata before opening the workspace", async () => { + const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId); + await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost"); + await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/); + }); + + await test.step("open the workspace and inspect its selected folder header", async () => { + await openWorkspaceFromSidebar(page, workspace.workspaceId); + await expectWorkspaceHeader(page, { + title: workspace.workspaceName, + subtitle: projectName, + }); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + }); + + await test.step("dismiss a clicked row's hover card when the pointer leaves", async () => { + await row.click(); + await expect(row).toBeFocused(); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + }); + + await test.step("retain keyboard preview until Escape or focus leaves the row", async () => { + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardStaysOpen(page); + await page.keyboard.press("Escape"); + await expectWorkspaceHoverCardClosed(page); + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await page.keyboard.press("Tab"); + await expectWorkspaceHoverCardClosed(page); + }); } finally { await workspace.cleanup(); } @@ -354,107 +396,6 @@ test.describe("Sidebar workspace list", () => { } }); - test("workspace header uses the selected folder name instead of its GitHub remote", async ({ - page, - }) => { - const workspace = await seedWorkspace({ - repoPrefix: "sidebar-header-", - repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, - }); - - try { - const projectName = path.basename(workspace.repoPath); - await gotoAppShell(page); - await waitForSidebarProject(page, projectName); - await waitForSidebarWorkspace(page, workspace.workspaceId); - await openWorkspaceFromSidebar(page, workspace.workspaceId); - - await expectWorkspaceHeader(page, { - title: workspace.workspaceName, - subtitle: projectName, - }); - } finally { - await workspace.cleanup(); - } - }); - - test("git project shows branch name in workspace row", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" }); - - try { - await gotoAppShell(page); - await waitForSidebarProject(page, path.basename(workspace.repoPath)); - - expect(workspace.workspaceName).toBe("main"); - await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText( - "main", - ); - } finally { - await workspace.cleanup(); - } - }); - - test("workspace hover card shows host as metadata", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" }); - - try { - await gotoAppShell(page); - await waitForSidebarProject(page, path.basename(workspace.repoPath)); - - const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId); - await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost"); - await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/); - } finally { - await workspace.cleanup(); - } - }); - - test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" }); - - try { - await gotoAppShell(page); - await waitForSidebarProject(page, path.basename(workspace.repoPath)); - const row = await openWorkspaceFromSidebar(page, workspace.workspaceId); - await movePointerAwayFromWorkspaceHoverCard(page); - await expectWorkspaceHoverCardClosed(page); - - await row.click(); - await expect(row).toBeFocused(); - await expectWorkspaceHoverCardOpen(page); - await movePointerAwayFromWorkspaceHoverCard(page); - await expectWorkspaceHoverCardClosed(page); - } finally { - await workspace.cleanup(); - } - }); - - test("workspace hover card follows keyboard focus on a row", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); - - try { - await gotoAppShell(page); - await waitForSidebarProject(page, path.basename(workspace.repoPath)); - await openWorkspaceFromSidebar(page, workspace.workspaceId); - await movePointerAwayFromWorkspaceHoverCard(page); - await expectWorkspaceHoverCardClosed(page); - - await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); - await expectWorkspaceHoverCardOpen(page); - await movePointerAwayFromWorkspaceHoverCard(page); - await expectWorkspaceHoverCardStaysOpen(page); - await page.keyboard.press("Escape"); - await expectWorkspaceHoverCardClosed(page); - - await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); - await expectWorkspaceHoverCardOpen(page); - await page.keyboard.press("Tab"); - await expectWorkspaceHoverCardClosed(page); - } finally { - await workspace.cleanup(); - } - }); - test("marks a finished workspace unread until it is opened again", async ({ page }) => { const workspace = await seedMockAgentWorkspace({ repoPrefix: "sidebar-mark-unread-", @@ -495,26 +436,21 @@ test.describe("Sidebar workspace list", () => { test.describe("Mobile sidebar panelState transition", () => { test.use({ viewport: { width: 390, height: 844 } }); - test("showMobileAgent open and close transition", async ({ page }) => { + test("opens mobile navigation beside the close button and closes it again", async ({ page }) => { await gotoAppShell(page); await expectMobileAgentSidebarHidden(page); await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); + await test.step("place the close button beside the first navigation row", async () => { + const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox(); + const closeButton = await page.getByTestId("sidebar-close").boundingBox(); + if (!firstRow || !closeButton) throw new Error("first nav row and close button must render"); + expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width); + }); await closeMobileAgentSidebar(page); await expectMobileAgentSidebarHidden(page); }); - test("places the close button beside the first nav row instead of over it", async ({ page }) => { - await gotoAppShell(page); - await openMobileAgentSidebar(page); - await expectMobileAgentSidebarVisible(page); - - const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox(); - const closeButton = await page.getByTestId("sidebar-close").boundingBox(); - if (!firstRow || !closeButton) throw new Error("first nav row and close button must render"); - expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width); - }); - test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({ page, }) => { @@ -587,15 +523,14 @@ test.describe("Half-screen desktop layout", () => { } }); - test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => { - await gotoAppShell(page); - await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); - await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); - }); - test("keeps the left toggle center-owned without left window controls", async ({ page }) => { await gotoAppShell(page); + await test.step("start with pinned navigation at half of a 14-inch Mac display", async () => { + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); + }); + const openToggle = page.getByTestId("menu-button"); const openIcon = openToggle.locator("svg").first(); await expect(openIcon).toBeVisible(); diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index a923971d09..723cb827b7 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -477,49 +477,59 @@ for (const shape of ["seven-day-only", "Spark"] as const) { }); } -for (const theme of ["light", "dark"] as const) { - for (const [size, viewport] of Object.entries({ - desktop: { width: 1440, height: 900 }, - compact: { width: 390, height: 844 }, - })) { - test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => { - test.setTimeout(120_000); - await page.addInitScript((value) => { - const key = "@paseo:app-settings"; - const current = JSON.parse(localStorage.getItem(key) ?? "{}"); - localStorage.setItem(key, JSON.stringify({ ...current, theme: value })); - }, theme); - const usage = await installUsageReportsFixture(page, { - lists: [() => claudeAndCodexReports()], - }); - await page.setViewportSize(viewport); - await gotoAppShell(page); - if (size === "compact") await openCompactSidebar(page); - await openUsageFromIcon(page); - const screen = page.getByTestId(`usage-host-${getServerId()}`); - await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 }); - const settings = page.getByTestId("usage-options-menu"); - await expect(settings).toHaveAccessibleName("Settings"); - await expect(page.getByTestId("usage-display-used")).toHaveCount(0); - await captureSettingsState(page, testInfo, `${size}-${theme}-cog`); +for (const [size, viewport] of Object.entries({ + desktop: { width: 1440, height: 900 }, + compact: { width: 390, height: 844 }, +})) { + test(`Usage Settings cog ${size} in both themes`, async ({ page }, testInfo) => { + test.setTimeout(120_000); + await page.addInitScript(() => { + const key = "@paseo:app-settings"; + const current = JSON.parse(localStorage.getItem(key) ?? "{}"); + localStorage.setItem(key, JSON.stringify({ ...current, theme: "auto" })); + }); + await page.emulateMedia({ colorScheme: "light" }); + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize(viewport); + await gotoAppShell(page); + if (size === "compact") await openCompactSidebar(page); + await openUsageFromIcon(page); + const screen = page.getByTestId(`usage-host-${getServerId()}`); + await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 }); - await openUsageOptions(page); - await expectUsageOptionsFitContent(page); - await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible(); - await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true"); - await captureSettingsState(page, testInfo, `${size}-${theme}-settings`); - await showUsageAs(page, "remaining"); - await expect(screen.getByText("69% left")).toBeVisible(); - await showUsageAs(page, "used"); - await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName( - /^Pin Claude Session, 31% · resets /, - ); - await openUsageOptions(page); - await closeUsageOptions(page); + for (const theme of ["light", "dark"] as const) { + await test.step(`the cog and its settings work in ${theme}`, async () => { + await page.emulateMedia({ colorScheme: theme }); + const settings = page.getByTestId("usage-options-menu"); + await expect(settings).toHaveAccessibleName("Settings"); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); + await captureSettingsState(page, testInfo, `${size}-${theme}-cog`); + + await openUsageOptions(page); + await expectUsageOptionsFitContent(page); + await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible(); + await expect(page.getByTestId("usage-display-used")).toHaveAttribute( + "aria-selected", + "true", + ); + await captureSettingsState(page, testInfo, `${size}-${theme}-settings`); + await showUsageAs(page, "remaining"); + await expect(screen.getByText("69% left")).toBeVisible(); + await showUsageAs(page, "used"); + await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName( + /^Pin Claude Session, 31% · resets /, + ); + await openUsageOptions(page); + await closeUsageOptions(page); + }); + } + await test.step("refresh still works after changing the display settings", async () => { await refreshAllUsage(page); await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]); }); - } + }); } async function captureSettingsState( diff --git a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts index be7b67e2fa..16cde88058 100644 --- a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -42,7 +42,7 @@ async function seedViewedTimelineScenario( config: ViewedAgentConfig = { provider: "mock", modeId: "load-test", - model: "ten-second-stream", + model: "e2e-fast-stream", }, ) => workspace.client.createAgent({