import path from "node:path"; import { expect, type Locator, type Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expectComposerVisible } from "./composer"; import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent"; import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports"; import { claudeAndCodexReports } from "./usage-sidebar-item"; // 32,000 of the mock's 128,000-token window. const METER_NAME = "Context window 25% used"; export type MockAgentSession = Awaited>; /** An agent a quarter into its context window. */ export function seedAgentWithContextWindow(): Promise { return seedMockAgentWorkspace({ repoPrefix: "context-window-usage-", title: "Context window usage e2e", initialPrompt: "emit 32000 byte file agent stream payload", }); } export async function openAgent(page: Page, session: MockAgentSession): Promise { await openAgentRoute(page, session); await expectComposerVisible(page); } export async function reloadAgent(page: Page): Promise { // Reload must not reopen the card under a stationary pointer. await page.mouse.move(0, 0); await page.reload({ waitUntil: "domcontentloaded" }); await expectComposerVisible(page); } /** Wide screens show the meter's details in a hover card while the pointer is on it. */ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise { await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 }); const card = contextWindowDetails(page); await expect(card).toBeVisible(); await expect(card).toBeInViewport({ ratio: 1 }); return card; } /** Walks the pointer from the meter up onto its hover card, across the gap between them. */ export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise { const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox(); const box = await card.boundingBox(); if (!meter || !box) throw new Error("The meter and its hover card must be on screen."); await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 }); } /** The hover card as one painted frame showed it, or unmounted. */ export interface ContextWindowFrame { mounted: boolean; bottom: number | null; loading: boolean; usageCards: number; } /** * Reads the hover card as it was painted, every frame, until stopped, so a state that lasted one * frame is seen. Stop it only after everything has settled. */ export async function recordContextWindowFrames( page: Page, ): Promise<{ stop: () => Promise }> { await page.evaluate(() => { const recorder = window as unknown as { __contextWindowFrames: ContextWindowFrame[]; __contextWindowRecording: boolean; }; recorder.__contextWindowFrames = []; recorder.__contextWindowRecording = true; const findCard = () => document.querySelector('[data-testid="context-window-details"]'); const read = (card: HTMLElement | null): ContextWindowFrame => ({ mounted: card !== null, bottom: card?.getBoundingClientRect().bottom ?? null, loading: card?.innerText.includes("Loading usage...") ?? false, usageCards: card?.querySelectorAll('[data-testid^="usage-report-"]').length ?? 0, }); // A card that just resized has not been painted yet: its frame is read from an observer, // which runs before that paint. let watched: HTMLElement | null = null; let paintedHeight: number | null = null; let observer: ResizeObserver | null = null; const watch = (card: HTMLElement) => { observer = new ResizeObserver(() => { paintedHeight = card.offsetHeight; recorder.__contextWindowFrames.push(read(card)); }); observer.observe(card); }; const channel = new MessageChannel(); // A message posted from an animation frame runs after that frame's paint. channel.port1.addEventListener("message", () => { const card = findCard(); if (card !== watched) { observer?.disconnect(); watched = card; paintedHeight = null; if (card) watch(card); } if (!card || card.offsetHeight === paintedHeight) { recorder.__contextWindowFrames.push(read(card)); } }); channel.port1.start(); const sample = () => { if (!recorder.__contextWindowRecording) return; channel.port2.postMessage(null); requestAnimationFrame(sample); }; requestAnimationFrame(sample); }); return { stop: () => page.evaluate(() => { const recorder = window as unknown as { __contextWindowFrames: ContextWindowFrame[]; __contextWindowRecording: boolean; }; recorder.__contextWindowRecording = false; return recorder.__contextWindowFrames; }), }; } export async function leaveContextWindowMeter(page: Page): Promise { await page.mouse.move(0, 0); await expect(contextWindowDetails(page)).toHaveCount(0); } /** Clicking the meter opens its details in the active layout. */ export async function pressContextWindowMeter( page: Page, name: string | RegExp = METER_NAME, ): Promise { await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 }); const details = contextWindowDetails(page); await expect(details).toBeVisible(); return details; } export async function closeContextWindowSheet(page: Page): Promise { const sheet = contextWindowDetails(page); await sheet.getByRole("button", { name: "Close", exact: true }).click(); await expect(sheet).toHaveCount(0); } /** The hover card on wide screens and the sheet on compact ones are both this dialog. */ export function contextWindowDetails(page: Page): Locator { return page.getByRole("dialog", { name: "Context window" }); } /** One report card; cards have no role of their own. */ export function usageCard(surface: Locator, reportId: string): Locator { return surface.getByTestId(`usage-report-${reportId}`); } export async function refreshUsageCard(surface: Locator, source: string): Promise { await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click(); } export async function expectRefreshButtons(surface: Locator, count: number): Promise { await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count); } /** A report on the agent's own login, which is not the host's default one. */ export function onWorkLogin(report: UsageReportEntry): UsageReportEntry { return onLogin(report, "work"); } /** A report on the login an agent resumed under. */ export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry { return onLogin(report, "personal"); } function onLogin(report: UsageReportEntry, login: string): UsageReportEntry { return { ...report, id: `${report.sourceId}:${login}`, account: { label: `${login}@example.com` }, }; } export function expiredLogin(report: UsageReportEntry): UsageReportEntry { return { ...report, report: { status: "unavailable", problem: { kind: "expired", expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), refreshedBy: "claude", }, }, }; } /** Holds a stream at a point until the test opens it, like a slow source. */ export function gate(): { promise: Promise; open: () => void } { let open!: () => void; const promise = new Promise((resolve) => { open = resolve; }); return { promise, open }; } export interface AgentUsageScript { /** The answer to the next agent usage request or report Refresh. */ answerNext(response: UsageListResponse): void; /** From the next reload the host no longer reports usage. */ stopReportingUsage(): void; /** The agent of each agent usage request, in order. */ agentRequests(): string[]; /** Each scoped agent Refresh, by agent ID. */ refreshedAgents(): string[]; /** Reloads the agent's session, which starts it under a new usage scope, as a resume does. */ resumeAgent(agent: MockAgentSession): Promise; } /** * Scripts the host's usage: the host's own list is Claude and Codex on its default login; each * agent request and each Refresh takes the next scripted answer. */ export async function scriptAgentUsage(page: Page): Promise { let supported = true; const answers: UsageListResponse[] = []; const usage = await installUsageReportsFixture(page, { usageSupported: () => supported, lists: [ (request) => { if (!request.agentId && !request.reportIds) return claudeAndCodexReports(); const next = answers.shift(); if (!next) throw new Error("The test scripts every agent usage request."); return next; }, ], }); return { answerNext: (response) => answers.push(response), stopReportingUsage: () => { supported = false; }, agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), refreshedAgents: () => usage .listRequests() .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), resumeAgent: (agent) => usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)), }; } /** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */ export async function qaScreenshot(page: Page, name: string): Promise { const directory = process.env.PASEO_QA_SCREENSHOT_DIR; if (!directory) return; // Let the sheet and hover card animations settle. await page.waitForTimeout(600); await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); await page.screenshot({ path: path.join(directory, `${name}.png`) }); }