test(app): consolidate browser setup and rebalance CI shards

This commit is contained in:
Mohamed Boudra
2026-10-17 21:12:13 +02:00
parent 941c890744
commit 213c78e819
13 changed files with 528 additions and 668 deletions
+3 -3
View File
@@ -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
@@ -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.";
@@ -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<typeof openSkillSelection>[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<typeof openSkillSelection>[0]) {
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
}
+45 -51
View File
@@ -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,
});
});
});
@@ -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 ({
+44 -57
View File
@@ -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<void> {
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<void> {
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),
);
});
});
@@ -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<Locator>;
type Shot = (state: string) => Promise<void>;
@@ -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();
});
+83 -122
View File
@@ -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,
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
"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,
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
"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"),
`<!doctype html><html><head><title>probe</title></head><body>
<h1>Visual plan</h1>
<h1 id="script-result">Inline script did not run</h1>
<p id="network-result">Network request not attempted</p>
<p id="document-mode">Standards mode not detected</p>
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
<script>
function report(id, label, probe) {
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
}
report("parent", "Parent DOM", function () { return parent.document.body; });
report("storage", "Storage", function () { return localStorage.length; });
report("cookie", "Cookies", function () { return document.cookie; });
</script>
<script>
document.getElementById("script-result").textContent = "Inline script ran";
if (document.compatMode === "CSS1Compat") {
@@ -822,6 +789,8 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "probe.html");
const preview = htmlPreview(page);
await expect(preview.host).toBeVisible();
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
await expect(
preview.document.getByRole("heading", { name: "Inline script ran" }),
).toBeVisible();
@@ -832,34 +801,26 @@ test.describe("CodeMirror workspace file editing", () => {
preview.document.getByText("Network request blocked", { exact: true }),
).toBeVisible();
expect(blockedRequests).toEqual([]);
});
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
await writeFile(
path.join(workspace.repoPath, "origin.html"),
`<!doctype html><html><body>
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
<script>
function report(id, label, probe) {
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
}
report("parent", "Parent DOM", function () { return parent.document.body; });
report("storage", "Storage", function () { return localStorage.length; });
report("cookie", "Cookies", function () { return document.cookie; });
</script>
</body></html>`,
"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"),
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
"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 ({
@@ -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);
}
},
});
+13 -31
View File
@@ -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,
@@ -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();
+49 -39
View File
@@ -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(
@@ -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({