Revert the timeline optimistic and pagination rework (#2596)
* Add npm release track invariant and beta dist-tag procedure * Revert "Keep older chat history and image previews stable (#2490)" The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework. * Revert "Stop completed turns from appearing stuck (#2484)" See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface SendAgentMessageRequest {
|
||||
type: "send_agent_message_request";
|
||||
requestId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: Record<string, unknown>;
|
||||
};
|
||||
const request = envelope.type === "session" ? envelope.message : null;
|
||||
if (
|
||||
request?.type !== "send_agent_message_request" ||
|
||||
typeof request.requestId !== "string" ||
|
||||
typeof request.agentId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "send_agent_message_request",
|
||||
requestId: request.requestId,
|
||||
agentId: request.agentId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function gateNextAgentMessage(page: Page) {
|
||||
let serverSocket: WebSocketRoute | null = null;
|
||||
let browserSocket: WebSocketRoute | null = null;
|
||||
const heldMessages: Array<WebSocketMessage | null> = [];
|
||||
const requests: SendAgentMessageRequest[] = [];
|
||||
const requestWaiters = new Set<() => void>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
browserSocket = ws;
|
||||
const server = ws.connectToServer();
|
||||
serverSocket = server;
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const request = readSendRequest(message);
|
||||
if (request) {
|
||||
heldMessages.push(message);
|
||||
requests.push(request);
|
||||
for (const resolve of requestWaiters) resolve();
|
||||
requestWaiters.clear();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
const waitForRequest = async (count = 1): Promise<SendAgentMessageRequest> => {
|
||||
while (requests.length < count) {
|
||||
await new Promise<void>((resolve) => requestWaiters.add(resolve));
|
||||
}
|
||||
return requests[count - 1];
|
||||
};
|
||||
|
||||
return {
|
||||
waitForRequest,
|
||||
accept(index = 0) {
|
||||
const heldMessage = heldMessages[index];
|
||||
if (!serverSocket || !heldMessage) {
|
||||
throw new Error("No held send-agent-message request to accept");
|
||||
}
|
||||
serverSocket.send(heldMessage);
|
||||
heldMessages[index] = null;
|
||||
},
|
||||
async disconnect(): Promise<void> {
|
||||
if (!browserSocket) throw new Error("No browser daemon socket to disconnect");
|
||||
await browserSocket.close({ code: 1008, reason: "Dropped by submission test." });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,21 +15,6 @@ export interface AgentTimelineResponseGate {
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OlderTimelinePagesGate {
|
||||
getRequestCount(): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface BootstrapTimelineGate extends AgentTimelineResponseGate {
|
||||
releaseCatchUp(): void;
|
||||
waitForDelayedCatchUp(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
@@ -60,32 +45,6 @@ function getPayload(message: Record<string, unknown>): Record<string, unknown> |
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
|
||||
let released = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (released) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
released = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
@@ -168,142 +127,6 @@ export async function delayCreatedAgentInitialTailResponse(
|
||||
export async function delayAgentOlderTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
return delayAgentTimelineResponse(page, agentId, "before");
|
||||
}
|
||||
|
||||
export async function holdAgentOlderTimelinePages(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId &&
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === "before"
|
||||
) {
|
||||
responseCount += 1;
|
||||
const pageNumber = responseCount;
|
||||
if (releasedPages.has(pageNumber)) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
const forwards = delayedForwards.get(pageNumber) ?? [];
|
||||
forwards.push(() => ws.send(message));
|
||||
delayedForwards.set(pageNumber, forwards);
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
getRequestCount: () => requestCount,
|
||||
releasePage(pageNumber) {
|
||||
releasedPages.add(pageNumber);
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentBootstrapTailResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<BootstrapTimelineGate> {
|
||||
let tailReleased = false;
|
||||
let catchUpReleased = false;
|
||||
const delayedTailForwards: Array<() => void> = [];
|
||||
const delayedCatchUpForwards: Array<() => void> = [];
|
||||
let resolveDelayedTail: (() => void) | null = null;
|
||||
let resolveDelayedCatchUp: (() => void) | null = null;
|
||||
const delayedTail = new Promise<void>((resolve) => {
|
||||
resolveDelayedTail = resolve;
|
||||
});
|
||||
const delayedCatchUp = new Promise<void>((resolve) => {
|
||||
resolveDelayedCatchUp = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
const isTimelineResponse =
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
|
||||
if (isTimelineResponse && payload.direction === "tail") {
|
||||
resolveDelayedTail?.();
|
||||
if (tailReleased) ws.send(message);
|
||||
else delayedTailForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
if (isTimelineResponse && payload.direction === "after") {
|
||||
resolveDelayedCatchUp?.();
|
||||
if (catchUpReleased) ws.send(message);
|
||||
else delayedCatchUpForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
tailReleased = true;
|
||||
for (const forward of delayedTailForwards.splice(0)) forward();
|
||||
},
|
||||
releaseCatchUp() {
|
||||
catchUpReleased = true;
|
||||
for (const forward of delayedCatchUpForwards.splice(0)) forward();
|
||||
},
|
||||
waitForDelayedResponse: () => delayedTail,
|
||||
waitForDelayedCatchUp: () => delayedCatchUp,
|
||||
};
|
||||
}
|
||||
|
||||
async function delayAgentTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
direction: "before" | "tail",
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
@@ -325,7 +148,7 @@ async function delayAgentTimelineResponse(
|
||||
!delayedResponseSeen &&
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === direction
|
||||
payload.direction === "before"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { ArchiveTabAgent } from "./archive-tab";
|
||||
import { openWorkspaceWithAgents } from "./archive-tab";
|
||||
import { submitMessage } from "./composer";
|
||||
import type { SeedDaemonClient, SeededWorkspace } from "./seed-client";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { rememberTimelineViewport, userScrollsTimelineToHistoryStart } from "./timeline-pagination";
|
||||
|
||||
const IMAGE_PREVIEW_ERROR = "Unable to load image preview.";
|
||||
const SMALL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
const TEN_MEBIBYTES = 10 * 1024 * 1024;
|
||||
|
||||
export interface AssistantImageFixture {
|
||||
alt: string;
|
||||
height: number;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export async function createSmallAssistantPng(
|
||||
workspace: SeededWorkspace,
|
||||
input: { alt: string; fileName: string },
|
||||
): Promise<AssistantImageFixture> {
|
||||
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
|
||||
return {
|
||||
alt: input.alt,
|
||||
height: 1,
|
||||
relativePath: input.fileName,
|
||||
size: SMALL_PNG.byteLength,
|
||||
width: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createNearTenMegabyteAssistantPng(
|
||||
workspace: SeededWorkspace,
|
||||
input: { alt: string; fileName: string },
|
||||
): Promise<AssistantImageFixture> {
|
||||
const width = 2_048;
|
||||
const height = 1_280;
|
||||
const bytes = encodeDeterministicPng(width, height);
|
||||
if (bytes.byteLength < TEN_MEBIBYTES * 0.95 || bytes.byteLength > TEN_MEBIBYTES * 1.05) {
|
||||
throw new Error(`Expected a PNG near 10 MiB, encoded ${bytes.byteLength} bytes`);
|
||||
}
|
||||
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
|
||||
return {
|
||||
alt: input.alt,
|
||||
height,
|
||||
relativePath: input.fileName,
|
||||
size: bytes.byteLength,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSettledMockAgent(
|
||||
workspace: SeededWorkspace,
|
||||
title: string,
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
return {
|
||||
id: agent.id,
|
||||
title,
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function emitSettledAssistantImage(
|
||||
client: SeedDaemonClient,
|
||||
agent: ArchiveTabAgent,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
await client.sendAgentMessage(
|
||||
agent.id,
|
||||
`Emit settled assistant image Markdown: `,
|
||||
);
|
||||
const result = await client.waitForFinish(agent.id, 30_000);
|
||||
if (result.status !== "idle" || result.final?.lastError) {
|
||||
throw new Error(
|
||||
`Assistant image agent did not settle: ${result.final?.lastError ?? result.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendSettledTimelineTurns(
|
||||
client: SeedDaemonClient,
|
||||
agent: ArchiveTabAgent,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await client.sendAgentMessage(
|
||||
agent.id,
|
||||
`image-history-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
const result = await client.waitForFinish(agent.id, 30_000);
|
||||
if (result.status !== "idle" || result.final?.lastError) {
|
||||
throw new Error(
|
||||
`Assistant image history turn did not settle: ${result.final?.lastError ?? result.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectAssistantImageRendered(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
const rendered = page.getByRole("img", { name: image.alt }).first();
|
||||
await expect(rendered).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () =>
|
||||
rendered.evaluate((element) => {
|
||||
const imageElement =
|
||||
element instanceof HTMLImageElement ? element : element.querySelector("img");
|
||||
return imageElement?.complete
|
||||
? { height: imageElement.naturalHeight, width: imageElement.naturalWidth }
|
||||
: null;
|
||||
}),
|
||||
)
|
||||
.toEqual({ height: image.height, width: image.width });
|
||||
}
|
||||
|
||||
export async function switchAwayAndBackWithoutImageInstability(
|
||||
page: Page,
|
||||
input: {
|
||||
image: AssistantImageFixture;
|
||||
imageAgent: ArchiveTabAgent;
|
||||
otherAgent: ArchiveTabAgent;
|
||||
},
|
||||
): Promise<void> {
|
||||
await beginVisibleImageStabilityObservation(page, input.image.alt, input.imageAgent.id);
|
||||
await selectSettledAgentTab(page, input.otherAgent);
|
||||
await selectSettledAgentTab(page, input.imageAgent);
|
||||
await expectAssistantImageRendered(page, input.image);
|
||||
await expectNoVisibleImageInstability(page);
|
||||
}
|
||||
|
||||
export async function openExistingImageAgentTabs(
|
||||
page: Page,
|
||||
input: { imageAgent: ArchiveTabAgent; otherAgent: ArchiveTabAgent },
|
||||
): Promise<void> {
|
||||
await openWorkspaceWithAgents(page, [input.otherAgent, input.imageAgent]);
|
||||
}
|
||||
|
||||
export async function openAssistantImageTimeline(
|
||||
page: Page,
|
||||
agent: ArchiveTabAgent,
|
||||
): Promise<void> {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
|
||||
}
|
||||
|
||||
export async function expectAssistantImageNotMounted(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function userPagesUntilAssistantImageRenders(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
const rendered = page.getByRole("img", { name: image.alt });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if ((await rendered.count()) > 0) {
|
||||
await expectAssistantImageRendered(page, image);
|
||||
return;
|
||||
}
|
||||
const previous = await rememberTimelineViewport(page);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expect
|
||||
.poll(async () => (await rememberTimelineViewport(page)).scrollHeight)
|
||||
.toBeGreaterThan(previous.scrollHeight);
|
||||
}
|
||||
await expectAssistantImageRendered(page, image);
|
||||
}
|
||||
|
||||
export async function remountAndRecoverAssistantImageFromHistory(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
await page.reload();
|
||||
await userPagesUntilAssistantImageRenders(page, image);
|
||||
}
|
||||
|
||||
export async function sendFollowUpAndExpectVisibleResponse(
|
||||
page: Page,
|
||||
input: { prompt: string; response: string },
|
||||
): Promise<void> {
|
||||
await submitMessage(page, input.prompt);
|
||||
await expect(page.getByText(input.prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(input.response, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function selectSettledAgentTab(page: Page, agent: ArchiveTabAgent): Promise<void> {
|
||||
const tab = page.getByRole("button", { name: agent.title, exact: true });
|
||||
await tab.click();
|
||||
await expect(page).toHaveTitle(agent.title);
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator('[data-testid="agent-chat-scroll"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function beginVisibleImageStabilityObservation(
|
||||
page: Page,
|
||||
alt: string,
|
||||
imageAgentId: string,
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ accessibleName, errorText, tabTestId }) => {
|
||||
const record = {
|
||||
errorSeen: false,
|
||||
missingSeen: false,
|
||||
inspect: () => undefined,
|
||||
observer: null as MutationObserver | null,
|
||||
};
|
||||
const isVisible = (element: Element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
record.inspect = () => {
|
||||
record.errorSeen ||= Array.from(document.querySelectorAll("*")).some(
|
||||
(element) =>
|
||||
element.children.length === 0 &&
|
||||
element.textContent?.trim() === errorText &&
|
||||
isVisible(element),
|
||||
);
|
||||
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||
if (imageTab?.getAttribute("aria-selected") !== "true") return;
|
||||
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
|
||||
);
|
||||
record.missingSeen ||= !imageVisible;
|
||||
};
|
||||
record.observer = new MutationObserver(record.inspect);
|
||||
record.observer.observe(document.body, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
record.inspect();
|
||||
(
|
||||
window as unknown as {
|
||||
__paseoAssistantImageObservation?: typeof record;
|
||||
}
|
||||
).__paseoAssistantImageObservation = record;
|
||||
},
|
||||
{
|
||||
accessibleName: alt,
|
||||
errorText: IMAGE_PREVIEW_ERROR,
|
||||
tabTestId: `workspace-tab-agent_${imageAgentId}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function expectNoVisibleImageInstability(page: Page): Promise<void> {
|
||||
const observation = await page.evaluate(() => {
|
||||
const owner = window as unknown as {
|
||||
__paseoAssistantImageObservation?: {
|
||||
errorSeen: boolean;
|
||||
missingSeen: boolean;
|
||||
inspect(): void;
|
||||
observer: MutationObserver | null;
|
||||
};
|
||||
};
|
||||
const record = owner.__paseoAssistantImageObservation;
|
||||
if (!record) throw new Error("Assistant image stability observation was not started");
|
||||
record.inspect();
|
||||
record.observer?.disconnect();
|
||||
delete owner.__paseoAssistantImageObservation;
|
||||
return { errorSeen: record.errorSeen, missingSeen: record.missingSeen };
|
||||
});
|
||||
expect(observation).toEqual({ errorSeen: false, missingSeen: false });
|
||||
}
|
||||
|
||||
function encodeDeterministicPng(width: number, height: number): Buffer {
|
||||
const stride = width * 4 + 1;
|
||||
const scanlines = Buffer.allocUnsafe(stride * height);
|
||||
let state = 0x9e3779b9;
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const rowOffset = row * stride;
|
||||
scanlines[rowOffset] = 0;
|
||||
for (let offset = 1; offset < stride; offset += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
scanlines[rowOffset + offset] = state & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
pngChunk("IHDR", header),
|
||||
pngChunk("IDAT", deflateSync(scanlines, { level: 0 })),
|
||||
pngChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBytes = Buffer.from(type, "ascii");
|
||||
const chunk = Buffer.allocUnsafe(12 + data.byteLength);
|
||||
chunk.writeUInt32BE(data.byteLength, 0);
|
||||
typeBytes.copy(chunk, 4);
|
||||
data.copy(chunk, 8);
|
||||
chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.byteLength);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function crc32(bytes: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
@@ -16,20 +16,6 @@ interface ClientRequest {
|
||||
type?: unknown;
|
||||
subscribe?: unknown;
|
||||
page?: { cursor?: unknown };
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: ClientRequest;
|
||||
};
|
||||
return envelope.message ?? envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||
@@ -52,111 +38,15 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripAssistantMessageId(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
|
||||
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
|
||||
};
|
||||
const event = (envelope.message?.payload ?? envelope.payload)?.event;
|
||||
if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message;
|
||||
delete event.item.messageId;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function stripMessageSubmissionDisposition(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") {
|
||||
return message;
|
||||
}
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: Record<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (!payload) return message;
|
||||
delete payload.outOfBand;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer {
|
||||
if (!enabled || typeof message !== "string") return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: Record<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (!payload) return message;
|
||||
payload.epoch = `playwright-reset-${Date.now()}`;
|
||||
payload.reset = true;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function readAgentStreamEventType(message: ClientRequest | null): string | null {
|
||||
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const event = (message.payload as { event?: { type?: unknown } }).event;
|
||||
return typeof event?.type === "string" ? event.type : null;
|
||||
}
|
||||
|
||||
function readAgentStreamItemType(message: ClientRequest | null): string | null {
|
||||
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } })
|
||||
.event;
|
||||
return event?.type === "timeline" && typeof event.item?.type === "string"
|
||||
? event.item.type
|
||||
: null;
|
||||
}
|
||||
|
||||
function shouldSuppressServerMessage(input: {
|
||||
message: ClientRequest | null;
|
||||
messageTypes: ReadonlySet<string>;
|
||||
agentStreamEventTypes: ReadonlySet<string>;
|
||||
suppressAgentStream: boolean;
|
||||
}): boolean {
|
||||
const messageType = typeof input.message?.type === "string" ? input.message.type : null;
|
||||
if (messageType && input.messageTypes.has(messageType)) return true;
|
||||
if (input.suppressAgentStream && messageType === "agent_stream") return true;
|
||||
const eventType = readAgentStreamEventType(input.message);
|
||||
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
|
||||
}
|
||||
|
||||
export async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
let reconnectWithFreshClient = false;
|
||||
let suppressAgentStream = false;
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripSubmissionDisposition = false;
|
||||
let heldClientRequestType: string | null = null;
|
||||
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldClientRequest: (() => void) | null = null;
|
||||
let heldServerMessageType: string | null = null;
|
||||
let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldServerMessage: (() => void) | null = null;
|
||||
const suppressedServerMessageTypes = new Set<string>();
|
||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
let latestServer: WebSocketRoute | null = null;
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
unsubscribed: { agents: 0, workspaces: 0 },
|
||||
total: { agents: 0, workspaces: 0 },
|
||||
};
|
||||
const clientRequestCounts = new Map<string, number>();
|
||||
const serverMessageCounts = new Map<string, number>();
|
||||
const agentStreamItemCounts = new Map<string, number>();
|
||||
const serverMessageWaiters = new Set<() => void>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
@@ -166,20 +56,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
latestServer = server;
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
if (reconnectWithFreshClient && typeof message === "string") {
|
||||
const hello = readClientRequest(message);
|
||||
if (hello?.type === "hello") {
|
||||
const parsed = JSON.parse(message) as { clientId?: string };
|
||||
parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`;
|
||||
reconnectWithFreshClient = false;
|
||||
server.send(JSON.stringify(parsed));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const request = readClientRequest(message);
|
||||
if (typeof request?.type === "string") {
|
||||
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
|
||||
@@ -190,12 +69,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
directoryStarts.total[directory] += 1;
|
||||
}
|
||||
}
|
||||
if (request?.type === heldClientRequestType) {
|
||||
heldClientRequest = { server, message };
|
||||
resolveHeldClientRequest?.();
|
||||
resolveHeldClientRequest = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
@@ -205,55 +78,8 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
const serverMessage = readSessionMessage(message);
|
||||
let outboundMessage = stripAssistantMessageId(
|
||||
message,
|
||||
stripAssistantMessageIds,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = stripMessageSubmissionDisposition(
|
||||
outboundMessage,
|
||||
stripSubmissionDisposition,
|
||||
serverMessage?.type,
|
||||
);
|
||||
const shouldForceTimelineReset =
|
||||
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
|
||||
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
|
||||
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
|
||||
if (typeof serverMessage?.type === "string") {
|
||||
serverMessageCounts.set(
|
||||
serverMessage.type,
|
||||
(serverMessageCounts.get(serverMessage.type) ?? 0) + 1,
|
||||
);
|
||||
for (const resolve of serverMessageWaiters) resolve();
|
||||
serverMessageWaiters.clear();
|
||||
}
|
||||
const agentStreamItemType = readAgentStreamItemType(serverMessage);
|
||||
if (agentStreamItemType) {
|
||||
agentStreamItemCounts.set(
|
||||
agentStreamItemType,
|
||||
(agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1,
|
||||
);
|
||||
for (const resolve of serverMessageWaiters) resolve();
|
||||
serverMessageWaiters.clear();
|
||||
}
|
||||
if (serverMessage?.type === heldServerMessageType) {
|
||||
heldServerMessage = { browser: ws, message: outboundMessage };
|
||||
resolveHeldServerMessage?.();
|
||||
resolveHeldServerMessage = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
shouldSuppressServerMessage({
|
||||
message: serverMessage,
|
||||
messageTypes: suppressedServerMessageTypes,
|
||||
agentStreamEventTypes: suppressedAgentStreamEventTypes,
|
||||
suppressAgentStream,
|
||||
})
|
||||
)
|
||||
return;
|
||||
try {
|
||||
ws.send(outboundMessage);
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
@@ -274,125 +100,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
restoreFresh(): void {
|
||||
reconnectWithFreshClient = true;
|
||||
acceptingConnections = true;
|
||||
},
|
||||
holdNextClientRequest(type: string): void {
|
||||
heldClientRequestType = type;
|
||||
heldClientRequest = null;
|
||||
},
|
||||
waitForHeldClientRequest(): Promise<void> {
|
||||
if (heldClientRequest) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveHeldClientRequest = resolve;
|
||||
});
|
||||
},
|
||||
releaseHeldClientRequest(): void {
|
||||
if (!heldClientRequest) throw new Error("No held client request to release");
|
||||
heldClientRequest.server.send(heldClientRequest.message);
|
||||
heldClientRequest = null;
|
||||
heldClientRequestType = null;
|
||||
},
|
||||
holdNextServerMessage(type: string): void {
|
||||
heldServerMessageType = type;
|
||||
heldServerMessage = null;
|
||||
},
|
||||
waitForHeldServerMessage(): Promise<void> {
|
||||
if (heldServerMessage) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveHeldServerMessage = resolve;
|
||||
});
|
||||
},
|
||||
releaseHeldServerMessage(): void {
|
||||
if (!heldServerMessage) throw new Error("No held server message to release");
|
||||
heldServerMessage.browser.send(heldServerMessage.message);
|
||||
heldServerMessage = null;
|
||||
heldServerMessageType = null;
|
||||
},
|
||||
requestTimelineTail(agentId: string): void {
|
||||
if (!latestServer) throw new Error("No daemon WebSocket is connected");
|
||||
latestServer.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "fetch_agent_timeline_request",
|
||||
agentId,
|
||||
requestId: `playwright-timeline-${Date.now()}`,
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
getHeldTimelineLastItemType(): string | null {
|
||||
if (!heldServerMessage) throw new Error("No held server message to inspect");
|
||||
const response = readSessionMessage(heldServerMessage.message);
|
||||
const payload = response?.payload;
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const entries = (payload as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(entries)) return null;
|
||||
const last = entries.at(-1) as { item?: { type?: unknown } } | undefined;
|
||||
return typeof last?.item?.type === "string" ? last.item.type : null;
|
||||
},
|
||||
truncateHeldTimelineAfterLast(itemType: string): void {
|
||||
if (!heldServerMessage || typeof heldServerMessage.message !== "string") {
|
||||
throw new Error("No held text server message to truncate");
|
||||
}
|
||||
const envelope = JSON.parse(heldServerMessage.message) as {
|
||||
message?: { payload?: Record<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (!payload) throw new Error("Held message has no payload");
|
||||
const entries = payload.entries;
|
||||
if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response");
|
||||
const index = entries.findLastIndex(
|
||||
(entry) =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as { item?: { type?: unknown } }).item?.type === itemType,
|
||||
);
|
||||
if (index < 0) throw new Error(`Timeline response has no ${itemType} item`);
|
||||
const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>;
|
||||
const lastSeq = retained.at(-1)?.seqEnd;
|
||||
if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end");
|
||||
payload.entries = retained;
|
||||
payload.endCursor = { epoch: payload.epoch, seq: lastSeq };
|
||||
payload.hasNewer = false;
|
||||
if (payload.window && typeof payload.window === "object") {
|
||||
(payload.window as Record<string, unknown>).maxSeq = lastSeq;
|
||||
(payload.window as Record<string, unknown>).nextSeq = lastSeq + 1;
|
||||
}
|
||||
heldServerMessage.message = JSON.stringify(envelope);
|
||||
},
|
||||
setServerMessageSuppressed(type: string, suppressed: boolean): void {
|
||||
if (suppressed) {
|
||||
suppressedServerMessageTypes.add(type);
|
||||
} else {
|
||||
suppressedServerMessageTypes.delete(type);
|
||||
}
|
||||
},
|
||||
setAgentStreamEventSuppressed(type: string, suppressed: boolean): void {
|
||||
if (suppressed) {
|
||||
suppressedAgentStreamEventTypes.add(type);
|
||||
} else {
|
||||
suppressedAgentStreamEventTypes.delete(type);
|
||||
}
|
||||
},
|
||||
setAssistantMessageIdsStripped(stripped: boolean): void {
|
||||
stripAssistantMessageIds = stripped;
|
||||
},
|
||||
setMessageSubmissionDispositionStripped(stripped: boolean): void {
|
||||
stripSubmissionDisposition = stripped;
|
||||
},
|
||||
setAgentStreamSuppressed(suppressed: boolean): void {
|
||||
suppressAgentStream = suppressed;
|
||||
},
|
||||
forceNextTimelineEpochReset(): void {
|
||||
forceTimelineEpochReset = true;
|
||||
},
|
||||
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
|
||||
return {
|
||||
subscribed: { ...directoryStarts.subscribed },
|
||||
@@ -403,18 +110,5 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
getClientRequestCount(type: string): number {
|
||||
return clientRequestCounts.get(type) ?? 0;
|
||||
},
|
||||
getAgentStreamItemCount(type: string): number {
|
||||
return agentStreamItemCounts.get(type) ?? 0;
|
||||
},
|
||||
async waitForServerMessage(type: string, count = 1): Promise<void> {
|
||||
while ((serverMessageCounts.get(type) ?? 0) < count) {
|
||||
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
||||
}
|
||||
},
|
||||
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
|
||||
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
|
||||
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,51 +1,20 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import {
|
||||
delayAgentBootstrapTailResponse,
|
||||
delayAgentOlderTimelineResponse,
|
||||
holdDaemonHydration,
|
||||
holdAgentOlderTimelinePages,
|
||||
type AgentTimelineResponseGate,
|
||||
type BootstrapTimelineGate,
|
||||
type OlderTimelinePagesGate,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
export { holdDaemonHydration };
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
}
|
||||
|
||||
interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
initialTailOldestPrompt: string;
|
||||
oldestPrompt: string;
|
||||
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;
|
||||
}
|
||||
|
||||
interface TimelinePromptPositionSnapshot {
|
||||
prompt: string;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface OlderHistoryLoadingOperation {
|
||||
animationStartTime: number | null;
|
||||
marker: string;
|
||||
}
|
||||
|
||||
interface OlderHistoryPages {
|
||||
expectRequestedPages(count: number): Promise<void>;
|
||||
expectSettledWithRequestedPages(count: number): Promise<void>;
|
||||
releasePage(pageNumber: number): void;
|
||||
}
|
||||
|
||||
function promptForTurn(index: number): string {
|
||||
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
|
||||
@@ -67,41 +36,11 @@ export async function seedLongMockAgentTimeline(
|
||||
|
||||
return {
|
||||
...agent,
|
||||
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 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 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 openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
||||
await page.waitForURL(
|
||||
@@ -111,17 +50,15 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P
|
||||
}
|
||||
|
||||
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 });
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
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);
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 20_000 });
|
||||
await page.setViewportSize({ width: 1280, height: 8_000 });
|
||||
}
|
||||
|
||||
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
|
||||
@@ -138,33 +75,6 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<voi
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function reloadAgentTimelineFromPersistedReplica(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((agentId) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: unknown[];
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
|
||||
return timeline?.items?.length === 50;
|
||||
}, agent.agentId),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
@@ -180,106 +90,6 @@ export async function holdNextOlderTimelinePage(
|
||||
};
|
||||
}
|
||||
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
releasePage(pageNumber) {
|
||||
gate.releasePage(pageNumber);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
|
||||
return readTimelineViewport(page);
|
||||
}
|
||||
|
||||
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 rememberOlderHistoryLoadingOperation(
|
||||
page: Page,
|
||||
): Promise<OlderHistoryLoadingOperation> {
|
||||
const slot = page.getByTestId("load-older-history-spinner");
|
||||
await expect(slot).toBeVisible();
|
||||
const marker = `older-history-loading-${Date.now()}`;
|
||||
await slot.evaluate((element, operationMarker) => {
|
||||
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
|
||||
const animated = candidates.find(
|
||||
(candidate) => getComputedStyle(candidate).animationName !== "none",
|
||||
);
|
||||
if (!(animated instanceof HTMLElement)) {
|
||||
throw new Error("Expected the older-history loader to contain an animated element");
|
||||
}
|
||||
animated.dataset.olderHistoryLoadingOperation = operationMarker;
|
||||
}, marker);
|
||||
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
})
|
||||
.not.toBeNull();
|
||||
const animationStartTime = await animated.evaluate((element) => {
|
||||
const startTime = element.getAnimations()[0]?.startTime;
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
});
|
||||
return { animationStartTime, marker };
|
||||
}
|
||||
|
||||
export async function expectSameOlderHistoryLoadingOperation(
|
||||
page: Page,
|
||||
operation: OlderHistoryLoadingOperation,
|
||||
): Promise<void> {
|
||||
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
|
||||
await expect(animated).toBeVisible();
|
||||
const animationStartTime = await animated.evaluate((element) => {
|
||||
const startTime = element.getAnimations()[0]?.startTime;
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
});
|
||||
expect(animationStartTime).toBe(operation.animationStartTime);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -299,95 +109,31 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
|
||||
});
|
||||
}
|
||||
|
||||
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
|
||||
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
for (let step = 0; step < 60; step += 1) {
|
||||
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||
break;
|
||||
}
|
||||
await page.mouse.wheel(0, -1_000);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
}),
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.evaluate((element) => {
|
||||
const previousHeight = 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 }));
|
||||
return element.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
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;
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(previousHeight);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user