mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
The agent's usage query used gcTime 0, so every close dropped its reports and every open started from the loading state. The drop existed because an agent resumed under another login keeps its ID. The daemon now projects an opaque usageScopeKey that follows the harness session and model, the query key includes it, and reports are retained for as long as the scope holds. Hover cards placed above their trigger now hold their bottom edge. Anchored by top, a card that grew painted past the trigger until onLayout corrected it on a later frame.
265 lines
9.7 KiB
TypeScript
265 lines
9.7 KiB
TypeScript
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<ReturnType<typeof seedMockAgentWorkspace>>;
|
|
|
|
/** An agent a quarter into its context window. */
|
|
export function seedAgentWithContextWindow(): Promise<MockAgentSession> {
|
|
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<void> {
|
|
await openAgentRoute(page, session);
|
|
await expectComposerVisible(page);
|
|
}
|
|
|
|
export async function reloadAgent(page: Page): Promise<void> {
|
|
// 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<Locator> {
|
|
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<void> {
|
|
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<ContextWindowFrame[]> }> {
|
|
await page.evaluate(() => {
|
|
const recorder = window as unknown as {
|
|
__contextWindowFrames: ContextWindowFrame[];
|
|
__contextWindowRecording: boolean;
|
|
};
|
|
recorder.__contextWindowFrames = [];
|
|
recorder.__contextWindowRecording = true;
|
|
const findCard = () =>
|
|
document.querySelector<HTMLElement>('[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<void> {
|
|
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<Locator> {
|
|
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<void> {
|
|
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<void> {
|
|
await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click();
|
|
}
|
|
|
|
export async function expectRefreshButtons(surface: Locator, count: number): Promise<void> {
|
|
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<void>; open: () => void } {
|
|
let open!: () => void;
|
|
const promise = new Promise<void>((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<void>;
|
|
}
|
|
|
|
/**
|
|
* 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<AgentUsageScript> {
|
|
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<void> {
|
|
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`) });
|
|
}
|