mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
import type { Locator, Page } from "@playwright/test";
|
|
import { expect, test as base } from "../support/fixtures";
|
|
import {
|
|
composerLocator,
|
|
expectComposerVisible,
|
|
submitMessageWithButton,
|
|
} from "../support/helpers/composer";
|
|
import {
|
|
type AgentUsageScript,
|
|
closeContextWindowSheet,
|
|
contextWindowDetails,
|
|
expectRefreshButtons,
|
|
expiredLogin,
|
|
gate,
|
|
hoverContextWindowMeter,
|
|
leaveContextWindowMeter,
|
|
type MockAgentSession,
|
|
moveOntoContextWindowDetails,
|
|
onPersonalLogin,
|
|
onWorkLogin,
|
|
openAgent,
|
|
pressContextWindowMeter,
|
|
qaScreenshot,
|
|
refreshUsageCard,
|
|
reloadAgent,
|
|
scriptAgentUsage,
|
|
seedAgentWithContextWindow,
|
|
usageCard,
|
|
} from "../support/helpers/context-window";
|
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
|
|
import { installLoginUsage } from "../support/helpers/usage-login";
|
|
|
|
const test = base.extend<{ agent: MockAgentSession }>({
|
|
agent: async ({ page: _page }, provide) => {
|
|
const agent = await seedAgentWithContextWindow();
|
|
try {
|
|
await provide(agent);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
},
|
|
});
|
|
|
|
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
|
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
|
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
|
|
const ring = meter.locator("svg");
|
|
const progressColor = await ring
|
|
.locator("circle")
|
|
.last()
|
|
.evaluate((circle) => getComputedStyle(circle).stroke);
|
|
const screenshot = await ring.screenshot();
|
|
return ring.evaluate(
|
|
async (_svg, { png, color }) => {
|
|
const image = await createImageBitmap(
|
|
await (await fetch(`data:image/png;base64,${png}`)).blob(),
|
|
);
|
|
const canvas = new OffscreenCanvas(image.width, image.height);
|
|
const context = canvas.getContext("2d")!;
|
|
context.drawImage(image, 0, 0);
|
|
const { data } = context.getImageData(0, 0, image.width, image.height);
|
|
const [r, g, b] = color.match(/\d+/g)!.map(Number);
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
let count = 0;
|
|
for (let y = 0; y < image.height; y += 1) {
|
|
for (let x = 0; x < image.width; x += 1) {
|
|
const i = (y * image.width + x) * 4;
|
|
const distance =
|
|
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
|
|
if (distance < 40) {
|
|
sumX += x;
|
|
sumY += y;
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
if (count === 0) {
|
|
throw new Error(`No pixels painted in the progress colour ${color}`);
|
|
}
|
|
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
|
|
},
|
|
{ png: screenshot.toString("base64"), color: progressColor },
|
|
);
|
|
}
|
|
|
|
test.describe("context window meter", () => {
|
|
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "context-window-meter-",
|
|
title: "Context window meter e2e",
|
|
initialPrompt: "emit 32000 byte file agent stream payload",
|
|
});
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
await expectComposerVisible(page);
|
|
const meter = page.getByTestId("context-window-meter");
|
|
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
|
|
|
|
const centroid = await progressArcCentroid(meter);
|
|
expect(centroid.x).toBeGreaterThan(1);
|
|
expect(centroid.y).toBeLessThan(-1);
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
const DESKTOP = { width: 1440, height: 900 };
|
|
const COMPACT = { width: 390, height: 844 };
|
|
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
|
|
|
|
for (const layout of [
|
|
{
|
|
name: "desktop",
|
|
viewport: DESKTOP,
|
|
open: hoverContextWindowMeter,
|
|
close: leaveContextWindowMeter,
|
|
},
|
|
{
|
|
name: "compact",
|
|
viewport: COMPACT,
|
|
open: pressContextWindowMeter,
|
|
close: closeContextWindowSheet,
|
|
},
|
|
]) {
|
|
test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const source = await installLoginUsage({
|
|
status: "available",
|
|
windows: [{ id: "session", label: "Session", usedPct: 31 }],
|
|
});
|
|
try {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "context-window-empty-",
|
|
title: "Agent before its first turn",
|
|
});
|
|
try {
|
|
await page.setViewportSize(layout.viewport);
|
|
await openAgent(page, agent);
|
|
|
|
await test.step("the empty meter opens context details and account usage", async () => {
|
|
const details = await layout.open(page, "Context window: No context data");
|
|
await expect(details.getByText("No context data", { exact: true })).toBeVisible();
|
|
await expect(details.getByText("0% used", { exact: true })).toHaveCount(0);
|
|
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
|
const account = usageCard(details, "login-journey:account");
|
|
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
|
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
|
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
|
await layout.close(page);
|
|
});
|
|
|
|
await test.step("the first context reading replaces the empty state", async () => {
|
|
await submitMessageWithButton(page, "emit 32000 byte file agent stream payload");
|
|
const details = await layout.open(page);
|
|
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
|
|
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
|
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
|
await expect(
|
|
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
} finally {
|
|
await source.cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
type OpenDetails = (page: Page) => Promise<Locator>;
|
|
type Shot = (state: string) => Promise<void>;
|
|
|
|
/** Two reports on the agent's login stream in; the details show each card as it arrives. */
|
|
async function expectCardsToStreamIn(
|
|
page: Page,
|
|
usage: AgentUsageScript,
|
|
openDetails: OpenDetails,
|
|
shot: Shot,
|
|
): Promise<Locator> {
|
|
const [claude, codex] = claudeAndCodexReports();
|
|
const first = gate();
|
|
const second = gate();
|
|
usage.answerNext({
|
|
stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)],
|
|
});
|
|
const details = await openDetails(page);
|
|
await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible();
|
|
await shot("loading");
|
|
|
|
first.open();
|
|
await expect(
|
|
usageCard(details, "claude:work").getByText("work@example.com", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
|
|
await expect(usageCard(details, "codex:work")).toHaveCount(0);
|
|
await shot("streaming");
|
|
|
|
second.open();
|
|
await expect(
|
|
usageCard(details, "codex:work").getByText("Session", { exact: true }),
|
|
).toBeVisible();
|
|
// Only the agent's login, never the host's default one, and no pins.
|
|
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
|
|
await expectUnpinnableRows(details);
|
|
return details;
|
|
}
|
|
|
|
async function expectFailedRequestSentence(
|
|
page: Page,
|
|
usage: AgentUsageScript,
|
|
openDetails: OpenDetails,
|
|
shot: Shot,
|
|
): Promise<void> {
|
|
usage.answerNext({ error: "Unknown agent" });
|
|
await reloadAgent(page);
|
|
const details = await openDetails(page);
|
|
await expect(
|
|
details.getByText("Unable to load usage: Unknown agent", { exact: true }),
|
|
).toBeVisible();
|
|
await shot("error");
|
|
}
|
|
|
|
async function expectOnlyContextWindowWithoutUsage(
|
|
page: Page,
|
|
usage: AgentUsageScript,
|
|
openDetails: OpenDetails,
|
|
shot: Shot,
|
|
): Promise<void> {
|
|
usage.stopReportingUsage();
|
|
await reloadAgent(page);
|
|
const details = await openDetails(page);
|
|
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
|
|
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
|
|
await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
|
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}`);
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
test("context details support keyboard entry and dismiss when the viewport moves", async ({
|
|
page,
|
|
agent,
|
|
}) => {
|
|
const usage = await scriptAgentUsage(page);
|
|
await page.setViewportSize(DESKTOP);
|
|
await openAgent(page, agent);
|
|
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
|
|
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
|
await expect(stop).toBeVisible();
|
|
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 expect(trigger).toHaveAttribute("tabindex", "0");
|
|
await trigger.focus();
|
|
const details = contextWindowDetails(page);
|
|
await expect(details).toBeVisible();
|
|
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible();
|
|
await trigger.press("ArrowDown");
|
|
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused();
|
|
await page.keyboard.press("Escape");
|
|
await expect(details).toHaveCount(0);
|
|
await expect(stop).toBeVisible();
|
|
await expect(trigger).toBeFocused();
|
|
for (const key of ["Enter", " "]) {
|
|
usage.answerNext([onPersonalLogin(claude!)]);
|
|
await trigger.press(key);
|
|
await expect(details).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(details).toHaveCount(0);
|
|
await expect(stop).toBeVisible();
|
|
await expect(trigger).toBeFocused();
|
|
}
|
|
usage.answerNext([onPersonalLogin(claude!)]);
|
|
await trigger.press("ArrowDown");
|
|
await expect(details).toBeVisible();
|
|
await page.setViewportSize({ width: 1280, height: 850 });
|
|
await expect(details).toHaveCount(0);
|
|
});
|
|
|
|
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();
|
|
|
|
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();
|
|
});
|
|
});
|