mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
* fix(app): focus chat feed when clicking message content * test(app): wait for new chat output before checking scroll position Co-authored-by: Ivan Pantic <panta@talentkit.io> * test(app): capture the keyboard paging regression * test(app): verify paged chat text stays in place --------- Co-authored-by: Ivan Pantic <panta@talentkit.io> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
632 lines
22 KiB
TypeScript
632 lines
22 KiB
TypeScript
import { expect, type TestInfo, type Page } from "@playwright/test";
|
|
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
|
import { readReplicaCache } from "./replica-cache-storage";
|
|
import {
|
|
delayAgentBootstrapTailResponse,
|
|
delayAgentOlderTimelineResponse,
|
|
holdDaemonHydration,
|
|
holdAgentOlderTimelinePages,
|
|
type AgentTimelineResponseGate,
|
|
type BootstrapTimelineGate,
|
|
type OlderTimelinePagesGate,
|
|
} from "./agent-timeline-gate";
|
|
|
|
export { holdDaemonHydration };
|
|
|
|
interface LongTimelineAgentOptions {
|
|
turns: number;
|
|
/** Live turns stream for ten seconds unless a test needs one that outlasts it. */
|
|
liveTurns?: "ten-second-stream" | "thirty-minute-stream";
|
|
}
|
|
|
|
export interface LongTimelineAgent extends MockAgentWorkspace {
|
|
/** Every seeded prompt, oldest first, so a test can name the nth turn. */
|
|
prompts: string[];
|
|
firstOlderPagePrompt: string;
|
|
initialTailAnchorPrompt: string;
|
|
initialTailOldestPrompt: string;
|
|
/** The newest prompt on the first older page, adjacent to the initial tail. */
|
|
newestOlderPagePrompt: string;
|
|
oldestPrompt: string;
|
|
newestPrompt: string;
|
|
}
|
|
|
|
interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
|
|
newestPrompt: string;
|
|
}
|
|
|
|
const PROMPT_PREFIX = "timeline-pagination-turn";
|
|
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
|
const HISTORY_START_THRESHOLD_PX = 96;
|
|
|
|
interface TimelineViewportSnapshot {
|
|
scrollHeight: number;
|
|
scrollTop: number;
|
|
}
|
|
|
|
export interface TimelinePresentationSnapshot {
|
|
marker: string;
|
|
position: TimelinePromptPositionSnapshot;
|
|
viewport: TimelineViewportSnapshot;
|
|
}
|
|
|
|
interface TimelinePromptPositionSnapshot {
|
|
prompt: string;
|
|
top: number;
|
|
}
|
|
|
|
interface OlderHistoryPages {
|
|
expectRequestedPages(count: number): Promise<void>;
|
|
expectSettledWithRequestedPages(count: number): Promise<void>;
|
|
expectNoRepeatedEntries(): void;
|
|
expectRepeatedEntries(): void;
|
|
expectOwnedTextRendered(text: string): Promise<void>;
|
|
releasePage(pageNumber: number): void;
|
|
requestCount(): number;
|
|
}
|
|
|
|
function promptForTurn(index: number): string {
|
|
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
|
|
}
|
|
|
|
export async function seedLongMockAgentTimeline(
|
|
options: LongTimelineAgentOptions,
|
|
): Promise<LongTimelineAgent> {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "timeline-pagination-",
|
|
title: "Timeline pagination regression",
|
|
model: options.liveTurns ?? "ten-second-stream",
|
|
});
|
|
|
|
for (let index = 0; index < options.turns; index += 1) {
|
|
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
}
|
|
|
|
return {
|
|
...agent,
|
|
prompts: Array.from({ length: options.turns }, (_unused, index) => promptForTurn(index)),
|
|
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
|
|
initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)),
|
|
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
|
|
newestOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 21)),
|
|
oldestPrompt: promptForTurn(0),
|
|
newestPrompt: promptForTurn(options.turns - 1),
|
|
};
|
|
}
|
|
|
|
export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimelineAgent> {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "timeline-realistic-older-history-",
|
|
title: "Realistic older timeline pagination regression",
|
|
model: "ten-second-stream",
|
|
});
|
|
|
|
const olderTurnCount = 40;
|
|
for (let index = 0; index < olderTurnCount; index += 1) {
|
|
await agent.client.sendAgentMessage(
|
|
agent.agentId,
|
|
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
|
|
);
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
}
|
|
|
|
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
|
|
await agent.client.waitForFinish(agent.agentId, 20_000);
|
|
|
|
const newerTurnCount = 20;
|
|
for (let index = 0; index < newerTurnCount; index += 1) {
|
|
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
}
|
|
|
|
return {
|
|
...agent,
|
|
newestPrompt: promptForTurn(newerTurnCount - 1),
|
|
};
|
|
}
|
|
|
|
export async function scrollThroughOlderHistoryPages(
|
|
page: Page,
|
|
pageCount: number,
|
|
history: OlderHistoryPages,
|
|
): Promise<void> {
|
|
for (let pageNumber = 0; pageNumber < pageCount; pageNumber += 1) {
|
|
await userScrollsTimelineToHistoryStart(page);
|
|
await history.expectRequestedPages(pageNumber + 1);
|
|
history.releasePage(pageNumber + 1);
|
|
await history.expectSettledWithRequestedPages(pageNumber + 1);
|
|
}
|
|
}
|
|
|
|
export async function rememberTimelinePromptPosition(
|
|
page: Page,
|
|
prompt: string,
|
|
): Promise<TimelinePromptPositionSnapshot> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const item = timeline.getByText(prompt, { exact: true });
|
|
await expect(item).toBeVisible();
|
|
const box = await item.boundingBox();
|
|
if (!box) {
|
|
throw new Error(`Expected a rendered timeline item for ${prompt}`);
|
|
}
|
|
return { prompt, top: box.y };
|
|
}
|
|
|
|
export async function expectTimelinePromptLandedBelowTop(
|
|
page: Page,
|
|
prompt: string,
|
|
): Promise<void> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
|
await expect(promptRow).toBeVisible();
|
|
await expect
|
|
.poll(async () => {
|
|
const [timelineBox, promptBox] = await Promise.all([
|
|
timeline.boundingBox(),
|
|
promptRow.boundingBox(),
|
|
]);
|
|
if (!timelineBox || !promptBox) return Number.POSITIVE_INFINITY;
|
|
return Math.abs(promptBox.y - timelineBox.y - 8);
|
|
})
|
|
.toBeLessThanOrEqual(2);
|
|
}
|
|
|
|
export async function expectTimelinePromptPositionPreserved(
|
|
page: Page,
|
|
before: TimelinePromptPositionSnapshot,
|
|
): Promise<void> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const item = timeline.getByText(before.prompt, { exact: true });
|
|
await expect(item).toBeVisible();
|
|
await expect
|
|
.poll(async () => {
|
|
const box = await item.boundingBox();
|
|
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
|
})
|
|
.toBeLessThanOrEqual(2);
|
|
}
|
|
|
|
export async function expectTimelineAtMaximumScrollWithPromptVisible(
|
|
page: Page,
|
|
prompt: string,
|
|
): Promise<void> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const item = timeline.getByText(prompt, { exact: true });
|
|
await expect
|
|
.poll(async () => {
|
|
const [metrics, timelineBox, promptBox] = await Promise.all([
|
|
timeline.evaluate((element) => {
|
|
if (!(element instanceof HTMLElement)) {
|
|
throw new Error("Agent chat scroll element is not an HTMLElement");
|
|
}
|
|
return {
|
|
clientHeight: element.clientHeight,
|
|
scrollHeight: element.scrollHeight,
|
|
scrollTop: element.scrollTop,
|
|
};
|
|
}),
|
|
timeline.boundingBox(),
|
|
item.boundingBox(),
|
|
]);
|
|
if (!timelineBox || !promptBox) return false;
|
|
const isAtMaximumScroll =
|
|
Math.abs(metrics.scrollTop + metrics.clientHeight - metrics.scrollHeight) <= 2;
|
|
const isPromptFullyVisible =
|
|
promptBox.y >= timelineBox.y &&
|
|
promptBox.y + promptBox.height <= timelineBox.y + timelineBox.height;
|
|
return isAtMaximumScroll && isPromptFullyVisible;
|
|
})
|
|
.toBe(true);
|
|
}
|
|
|
|
export async function openAgentTimeline(
|
|
page: Page,
|
|
agent: MockAgentWorkspace,
|
|
serverId?: string,
|
|
): Promise<void> {
|
|
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId, serverId));
|
|
await page.waitForURL(
|
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
{ timeout: 60_000 },
|
|
);
|
|
}
|
|
|
|
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
export async function expectTimelinePromptCentered(page: Page, prompt: string): Promise<void> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const message = timeline.getByTestId("user-message").filter({ hasText: prompt });
|
|
await expect(message).toBeVisible();
|
|
await expect
|
|
.poll(async () => {
|
|
const [timelineBox, messageBox] = await Promise.all([
|
|
timeline.boundingBox(),
|
|
message.boundingBox(),
|
|
]);
|
|
if (!timelineBox || !messageBox) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
const timelineCenter = timelineBox.x + timelineBox.width / 2;
|
|
const messageCenter = messageBox.x + messageBox.width / 2;
|
|
return Math.abs(timelineCenter - messageCenter);
|
|
})
|
|
.toBeLessThanOrEqual(2);
|
|
}
|
|
|
|
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);
|
|
}
|
|
|
|
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
|
|
await page.setViewportSize({ width: 1280, height: 20_000 });
|
|
}
|
|
|
|
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await expect
|
|
.poll(async () =>
|
|
scroll.evaluate((element) => {
|
|
if (!(element instanceof HTMLElement)) {
|
|
throw new Error("Agent chat scroll element is not an HTMLElement");
|
|
}
|
|
return element.scrollHeight <= element.clientHeight;
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
export async function reloadAgentTimelineFromPersistedReplica(
|
|
page: Page,
|
|
agent: LongTimelineAgent,
|
|
): Promise<void> {
|
|
await expect
|
|
.poll(async () => {
|
|
const cache = await readReplicaCache(page);
|
|
const timeline = cache?.hosts
|
|
?.flatMap((host) => host.timelines)
|
|
.find((candidate) => candidate.agentId === agent.agentId);
|
|
return timeline?.items?.length === 50;
|
|
})
|
|
.toBe(true);
|
|
|
|
await page.reload();
|
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
|
}
|
|
|
|
export async function holdNextOlderTimelinePage(
|
|
page: Page,
|
|
agent: LongTimelineAgent,
|
|
): Promise<AgentTimelineResponseGate & { expectLoading(): Promise<void> }> {
|
|
const gate = await delayAgentOlderTimelineResponse(page, agent.agentId);
|
|
return {
|
|
...gate,
|
|
async expectLoading() {
|
|
await gate.waitForDelayedResponse();
|
|
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
|
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function holdOlderHistoryPages(
|
|
page: Page,
|
|
agent: MockAgentWorkspace,
|
|
daemonPort?: number,
|
|
): Promise<OlderHistoryPages> {
|
|
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
|
page,
|
|
agent.agentId,
|
|
daemonPort,
|
|
);
|
|
return {
|
|
async expectRequestedPages(count) {
|
|
await gate.waitForRequestCount(count);
|
|
expect(gate.getRequestCount()).toBe(count);
|
|
},
|
|
async expectSettledWithRequestedPages(count) {
|
|
await waitForTimelineGeometryToSettle(page);
|
|
expect(gate.getRequestCount()).toBe(count);
|
|
},
|
|
expectNoRepeatedEntries() {
|
|
expect(gate.getRepeatedEntryCount()).toBe(0);
|
|
},
|
|
expectRepeatedEntries() {
|
|
expect(gate.getRepeatedEntryCount()).toBeGreaterThan(0);
|
|
},
|
|
async expectOwnedTextRendered(text) {
|
|
const expectedCount = gate.getOwnedEntryCountContaining(text);
|
|
expect(expectedCount).toBeGreaterThan(0);
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await expect(timeline.getByText(text, { exact: false })).toHaveCount(expectedCount);
|
|
},
|
|
releasePage(pageNumber) {
|
|
gate.releasePage(pageNumber);
|
|
},
|
|
requestCount() {
|
|
return gate.getRequestCount();
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
|
|
return readTimelineViewport(page);
|
|
}
|
|
|
|
export async function expectStableHistoryStartGutter(page: Page): Promise<void> {
|
|
const gutter = page.getByTestId("older-history-slot");
|
|
await expect(gutter).toBeAttached();
|
|
await expect
|
|
.poll(() => gutter.evaluate((element) => element.getBoundingClientRect().height))
|
|
.toBe(32);
|
|
}
|
|
|
|
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
|
|
const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]);
|
|
if (!viewport || !target) {
|
|
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
|
|
}
|
|
// Programmatic scrolling does not express the user's intent to stop following output.
|
|
await timeline.hover();
|
|
await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2);
|
|
await expect(row).toBeInViewport({ ratio: 1 });
|
|
await waitForTimelineGeometryToSettle(page);
|
|
await expect
|
|
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
|
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
|
|
}
|
|
|
|
export async function rememberTimelinePresentation(
|
|
page: Page,
|
|
prompt: string,
|
|
): Promise<TimelinePresentationSnapshot> {
|
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
|
|
await expect(row).toBeVisible();
|
|
const marker = `timeline-presentation-${Date.now()}`;
|
|
await row.evaluate((element, value) => {
|
|
element.dataset.timelinePresentation = value;
|
|
}, marker);
|
|
return {
|
|
marker,
|
|
position: await rememberTimelinePromptPosition(page, prompt),
|
|
viewport: await rememberTimelineViewport(page),
|
|
};
|
|
}
|
|
|
|
export async function expectTimelinePresentationUnchanged(
|
|
page: Page,
|
|
before: TimelinePresentationSnapshot,
|
|
): Promise<void> {
|
|
await expect(page.locator(`[data-timeline-presentation="${before.marker}"]`)).toBeVisible();
|
|
await expectTimelinePromptPositionPreserved(page, before.position);
|
|
await expect.poll(async () => rememberTimelineViewport(page)).toEqual(before.viewport);
|
|
}
|
|
|
|
export async function expectTimelineViewportAnchoredAfterPrepend(
|
|
page: Page,
|
|
before: TimelineViewportSnapshot,
|
|
): Promise<void> {
|
|
await expect
|
|
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
|
|
.toBeGreaterThan(before.scrollHeight);
|
|
await waitForTimelineGeometryToSettle(page);
|
|
const after = await readTimelineViewport(page);
|
|
const contentGrowth = after.scrollHeight - before.scrollHeight;
|
|
const scrollAdjustment = after.scrollTop - before.scrollTop;
|
|
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
|
}
|
|
|
|
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
|
await expect
|
|
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
|
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
|
}
|
|
|
|
export async function holdBootstrapTimelinePage(
|
|
page: Page,
|
|
agent: LongTimelineAgent,
|
|
): Promise<BootstrapTimelineGate> {
|
|
return delayAgentBootstrapTailResponse(page, agent.agentId);
|
|
}
|
|
|
|
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
|
|
await agent.client.sendAgentMessage(agent.agentId, LIVE_BEFORE_HYDRATION_PROMPT);
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
return LIVE_BEFORE_HYDRATION_PROMPT;
|
|
}
|
|
|
|
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await scroll.hover();
|
|
await page.mouse.wheel(0, -20_000);
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
}),
|
|
);
|
|
await scroll.evaluate((element) => {
|
|
if (!(element instanceof HTMLElement)) {
|
|
throw new Error("Agent chat scroll element is not an HTMLElement");
|
|
}
|
|
element.scrollTop = 0;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await scroll.hover();
|
|
await returnTimelineToSettledHistoryStart(page, () => page.mouse.wheel(0, -1_000));
|
|
}
|
|
|
|
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await scroll.focus();
|
|
await returnTimelineToSettledHistoryStart(page, () => page.keyboard.press("Home"));
|
|
}
|
|
|
|
async function returnTimelineToSettledHistoryStart(
|
|
page: Page,
|
|
moveUp: () => Promise<unknown>,
|
|
): Promise<void> {
|
|
const loadingSpinner = page.getByTestId("load-older-history-spinner");
|
|
// A caller may only start a new history traversal after its prior page has settled.
|
|
await expect(loadingSpinner).toBeHidden();
|
|
let previous = await readTimelineViewport(page);
|
|
while (true) {
|
|
await moveUp();
|
|
await waitForTimelineGeometryToSettle(page);
|
|
let viewport = await readTimelineViewport(page);
|
|
// Tests that hold the response need to observe the requested page before releasing it.
|
|
if (await loadingSpinner.isVisible()) {
|
|
return;
|
|
}
|
|
if (viewport.scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
|
return;
|
|
}
|
|
await expect(loadingSpinner).toBeHidden();
|
|
await waitForTimelineGeometryToSettle(page);
|
|
viewport = await readTimelineViewport(page);
|
|
expect(
|
|
viewport.scrollTop < previous.scrollTop || viewport.scrollHeight > previous.scrollHeight,
|
|
).toBe(true);
|
|
previous = viewport;
|
|
}
|
|
}
|
|
|
|
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await scroll.evaluate((element) => {
|
|
if (!(element instanceof HTMLElement)) {
|
|
throw new Error("Agent chat scroll element is not an HTMLElement");
|
|
}
|
|
element.scrollTop = element.scrollHeight;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
export async function scrollTimelineUntilOlderHistoryIsReachable(
|
|
page: Page,
|
|
oldestPrompt: string,
|
|
): Promise<void> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
const prompt = scroll.getByText(oldestPrompt, { exact: true });
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
if ((await prompt.count()) > 0) {
|
|
await expect(prompt).toBeVisible();
|
|
return;
|
|
}
|
|
const previousHeight = await readTimelineViewport(page);
|
|
await userScrollsTimelineToHistoryStart(page);
|
|
await expect
|
|
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
|
|
.toBeGreaterThan(previousHeight.scrollHeight);
|
|
await waitForTimelineGeometryToSettle(page);
|
|
}
|
|
await expect(prompt).toBeVisible();
|
|
}
|
|
|
|
async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
return scroll.evaluate((element) => {
|
|
if (!(element instanceof HTMLElement)) {
|
|
throw new Error("Agent chat scroll element is not an HTMLElement");
|
|
}
|
|
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
|
|
});
|
|
}
|
|
|
|
export async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
|
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
|
await scroll.evaluate(
|
|
(element) =>
|
|
new Promise<void>((resolve, reject) => {
|
|
if (!(element instanceof HTMLElement)) {
|
|
reject(new Error("Agent chat scroll element is not an HTMLElement"));
|
|
return;
|
|
}
|
|
const startedAt = performance.now();
|
|
let stableFrames = 0;
|
|
let previous = `${element.scrollTop}:${element.scrollHeight}`;
|
|
const sample = () => {
|
|
const current = `${element.scrollTop}:${element.scrollHeight}`;
|
|
stableFrames = current === previous ? stableFrames + 1 : 0;
|
|
previous = current;
|
|
if (stableFrames >= 4) {
|
|
resolve();
|
|
return;
|
|
}
|
|
if (performance.now() - startedAt > 5_000) {
|
|
reject(new Error("Timeline geometry did not settle"));
|
|
return;
|
|
}
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
}),
|
|
);
|
|
}
|
|
|
|
function spokenTimelinePrompt(text: string): string {
|
|
return `<spoken-input>\n${text}\n</spoken-input>\n<instruction>This message was spoken by the user. Respond using the speak tool only, not normal messages, because the user may not be looking at the chat.</instruction>`;
|
|
}
|
|
|
|
export async function expectSpokenTimelinePrompt(page: Page, text: string): Promise<void> {
|
|
await expectTimelinePromptVisible(page, text);
|
|
const row = page.getByTestId("user-message").filter({ hasText: text });
|
|
await expect(row).not.toContainText("<spoken-input>");
|
|
await expect(row).not.toContainText("<instruction>");
|
|
await expect(row).not.toContainText("This message was spoken by the user.");
|
|
}
|
|
|
|
export async function sendSpokenTimelinePrompt(
|
|
agent: MockAgentWorkspace,
|
|
text: string,
|
|
): Promise<void> {
|
|
await agent.client.sendAgentMessage(agent.agentId, spokenTimelinePrompt(text));
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
}
|
|
|
|
export async function withSpokenTimeline(
|
|
historyText: string,
|
|
run: (agent: MockAgentWorkspace) => Promise<void>,
|
|
): Promise<void> {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "spoken-timeline-",
|
|
title: "Spoken timeline presentation",
|
|
initialPrompt: spokenTimelinePrompt(historyText),
|
|
});
|
|
try {
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
await run(agent);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
}
|
|
|
|
export async function reloadSpokenTimeline(page: Page): Promise<void> {
|
|
await page.reload();
|
|
}
|
|
|
|
export async function captureSpokenTimeline(
|
|
page: Page,
|
|
testInfo: TestInfo,
|
|
size: "desktop" | "compact",
|
|
): Promise<void> {
|
|
await page.setViewportSize(
|
|
size === "compact" ? { width: 390, height: 844 } : { width: 1280, height: 900 },
|
|
);
|
|
await testInfo.attach(`spoken-${size}-web`, {
|
|
body: await page.screenshot({ path: testInfo.outputPath(`spoken-${size}-web.png`) }),
|
|
contentType: "image/png",
|
|
});
|
|
}
|