test: prove virtualized chat workflows

This commit is contained in:
Shakker
2026-07-15 02:27:06 +01:00
committed by Shakker
parent 18cdf6b09b
commit 59dd62d1af
2 changed files with 258 additions and 39 deletions
+56 -9
View File
@@ -285,6 +285,13 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
splitEntry.evaluate((node) => node.closest(".agent-chat__composer-shell") == null),
)
.toBe(true);
await page.locator("openclaw-chat-pane").evaluate((pane) => {
(
globalThis as typeof globalThis & {
__classicChatPane?: Element;
}
).__classicChatPane = pane;
});
await splitEntry.click();
// Each pane owns an in-flow header (title + workspace/split/close
@@ -292,6 +299,18 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
const panes = page.locator("openclaw-chat-pane.chat-split-view__pane");
const headers = page.locator(".chat-pane__header");
await expect.poll(() => panes.count()).toBe(2);
await expect
.poll(() =>
panes.first().evaluate(
(pane) =>
(
globalThis as typeof globalThis & {
__classicChatPane?: Element;
}
).__classicChatPane === pane,
),
)
.toBe(true);
await expect.poll(() => headers.count()).toBe(2);
await expect
.poll(async () => {
@@ -413,7 +432,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await gateway.emitChatFinal({ runId, text: "Harness verified." });
await page.getByText("Harness verified.").waitFor({ timeout: 10_000 });
await page
.locator(".chat-thread-inner")
.getByText("Harness verified.")
.waitFor({ timeout: 10_000 });
const spacedPairCommand = "/ pair qr";
await page.locator(".agent-chat__composer-combobox textarea").fill(spacedPairCommand);
@@ -495,7 +517,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await expect
.poll(async () => (await gateway.getRequests("chat.history")).length)
.toBeGreaterThan(historyRequestsBefore);
await page.getByText(finalText, { exact: true }).waitFor();
await page.locator(".chat-thread-inner").getByText(finalText, { exact: true }).waitFor();
await expect
.poll(() =>
page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(),
@@ -603,7 +625,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
runId: requireString(defaultParams.idempotencyKey, "default send idempotency key"),
text: "Default shortcut received.",
});
await page.getByText("Default shortcut received.").waitFor({ timeout: 10_000 });
await page
.locator(".chat-thread-inner")
.getByText("Default shortcut received.")
.waitFor({ timeout: 10_000 });
// The send shortcut moved to the Settings appearance page; picking it
// there must apply to the chat composer after navigating back.
@@ -1049,11 +1074,15 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
text: "Visible progress from the targetless message tool.",
});
await page
.locator(".chat-thread-inner")
.getByText("Visible progress from the targetless message tool.")
.waitFor({ timeout: 10_000 });
await gateway.emitChatFinal({ runId, text: "Visible automatic final reply." });
await page.getByText("Visible automatic final reply.").waitFor({ timeout: 10_000 });
await page
.locator(".chat-thread-inner")
.getByText("Visible automatic final reply.")
.waitFor({ timeout: 10_000 });
const bubbleTexts = await page.locator(".chat-thread .chat-bubble").allTextContents();
for (const expectedText of [
prompt,
@@ -1320,7 +1349,13 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await expect
.poll(() => workbench.getAttribute("class"))
.toContain("chat-workbench--dock-bottom");
expect(await page.locator(".chat-workspace-rail").isVisible()).toBe(true);
const workspaceRail = page.locator(".chat-workspace-rail");
await expect
.poll(async () => {
const box = await workspaceRail.boundingBox();
return Boolean(box && box.width > 0 && box.height > 0);
})
.toBe(true);
expect(await page.locator(".chat-workspace-rail__dock").count()).toBe(0);
expect(await page.locator(".chat-workspace-rail__grip").count()).toBe(0);
} finally {
@@ -1825,7 +1860,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
expect(await gateway.getRequests("models.list")).toHaveLength(0);
expect(await gateway.getRequests("commands.list")).toHaveLength(0);
await gateway.emitChatFinal({ runId, text: "History race stayed visible." });
await page.getByText("History race stayed visible.").waitFor({ timeout: 10_000 });
await page
.locator(".chat-thread-inner")
.getByText("History race stayed visible.")
.waitFor({ timeout: 10_000 });
await page.locator(".agent-chat__composer-combobox textarea").fill("/");
expect(await gateway.getRequests("commands.list")).toHaveLength(0);
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
@@ -1875,7 +1913,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
});
await page.getByText(partialText).waitFor({ timeout: 10_000 });
await page.getByText("Error: gateway disconnected").waitFor({ timeout: 10_000 });
await page
.locator(".chat-thread-inner")
.getByText("Error: gateway disconnected")
.waitFor({ timeout: 10_000 });
} finally {
await closeBrowserContext(context);
}
@@ -2347,7 +2388,6 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
const thread = page.locator(".chat-thread");
await thread.hover();
await page.mouse.wheel(0, -1_000_000);
await page.getByText(/^older retained message 1\n/).waitFor({ timeout: 10_000 });
await expect
.poll(() =>
page
@@ -2359,6 +2399,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
),
)
.toBe(140);
// Prepending preserves the visible anchor. A renewed upward gesture
// reaches the newly loaded start instead of teleporting the reader.
await page.mouse.wheel(0, -1_000_000);
await page.getByText(/^older retained message 1\n/).waitFor({ timeout: 10_000 });
await sessionA.click();
await page.getByText(/^short session 2\n/).waitFor({ timeout: 10_000 });
@@ -3476,7 +3520,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await gateway.closeLatest(1006, "lost during tool call");
await page.getByText("Recovered from refreshed history.").waitFor({ timeout: 15_000 });
await page
.locator(".chat-thread-inner")
.getByText("Recovered from refreshed history.")
.waitFor({ timeout: 15_000 });
expect(await page.locator(".chat-queue").count()).toBe(0);
} finally {
await closeBrowserContext(context);
+202 -30
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { chromium, type Browser, type Locator } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
@@ -18,6 +18,119 @@ const suite = available || !allowMissing ? describe : describe.skip;
let browser: Browser;
let server: ControlUiE2eServer;
type VisibleVirtualRow = {
key: string;
viewportTop: number;
};
async function firstVisibleVirtualRow(thread: Locator): Promise<VisibleVirtualRow> {
return thread.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const row = Array.from(
element.querySelectorAll<HTMLElement>(".chat-virtual-row[data-virtual-row-key]"),
).find((candidate) => {
const rect = candidate.getBoundingClientRect();
return (
candidate.dataset.virtualRowKey !== "history" &&
rect.bottom > viewport.top &&
rect.top < viewport.bottom
);
});
if (!row) {
throw new Error("expected a visible virtual transcript row");
}
return {
key: row.dataset.virtualRowKey ?? "",
viewportTop: Math.round(row.getBoundingClientRect().top - viewport.top),
};
});
}
type VirtualRowPrependSample = {
phase: "before" | "mutation" | "frame";
viewportTop: number | null;
};
async function startVirtualRowPrependProbe(thread: Locator, anchor: VisibleVirtualRow) {
await thread.evaluate((element, expected) => {
const target = globalThis as typeof globalThis & {
__chatPrependProbe?: {
observer: MutationObserver;
samples: VirtualRowPrependSample[];
};
};
const samples: VirtualRowPrependSample[] = [];
let framePending = false;
const sample = (phase: VirtualRowPrependSample["phase"]) => {
const row = Array.from(
element.querySelectorAll<HTMLElement>(".chat-virtual-row[data-virtual-row-key]"),
).find((candidate) => candidate.dataset.virtualRowKey === expected.key);
samples.push({
phase,
viewportTop: row
? Math.round(row.getBoundingClientRect().top - element.getBoundingClientRect().top)
: null,
});
};
sample("before");
const observer = new MutationObserver(() => {
sample("mutation");
if (framePending) {
return;
}
framePending = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
framePending = false;
sample("frame");
});
});
});
observer.observe(element, {
attributeFilter: ["style"],
attributes: true,
childList: true,
subtree: true,
});
target.__chatPrependProbe = { observer, samples };
}, anchor);
}
async function finishVirtualRowPrependProbe(thread: Locator) {
return thread.evaluate(() => {
const target = globalThis as typeof globalThis & {
__chatPrependProbe?: {
observer: MutationObserver;
samples: VirtualRowPrependSample[];
};
};
const probe = target.__chatPrependProbe;
if (!probe) {
throw new Error("expected an active virtual row prepend probe");
}
probe.observer.disconnect();
delete target.__chatPrependProbe;
return probe.samples;
});
}
function expectStableVirtualRowPrepend(
anchor: VisibleVirtualRow,
samples: VirtualRowPrependSample[],
) {
expect(samples.some((sample) => sample.phase === "mutation")).toBe(true);
expect(samples.some((sample) => sample.phase === "frame")).toBe(true);
const paintedSamples = samples.filter((sample) => sample.phase !== "mutation");
expect(
paintedSamples.every((sample) => sample.viewportTop !== null),
JSON.stringify({ anchor, samples }),
).toBe(true);
expect(
paintedSamples.every((sample) => Math.abs((sample.viewportTop ?? 0) - anchor.viewportTop) <= 1),
JSON.stringify({ anchor, samples }),
).toBe(true);
}
suite("Claude native session catalog", () => {
beforeAll(async () => {
if (!available) {
@@ -140,26 +253,32 @@ suite("Claude native session catalog", () => {
.toBe(true);
const initialReadCount = (await gateway.getRequests("sessions.catalog.read")).length;
await gateway.deferNext("sessions.catalog.read");
const before = await thread.evaluate((element) => {
await thread.evaluate((element) => {
element.scrollTop = 0;
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
element.dispatchEvent(new Event("scroll"));
});
await page.clock.runFor(100);
await page.locator('.chat-virtual-row:not([data-virtual-row-key="history"])').first().waitFor();
const anchor = await firstVisibleVirtualRow(thread);
await expect
.poll(() => gateway.getRequests("sessions.catalog.read").then((requests) => requests.length))
.toBe(initialReadCount + 1);
await page.locator(".chat-history-loading").waitFor();
expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0);
await startVirtualRowPrependProbe(thread, anchor);
await gateway.resolveDeferred("sessions.catalog.read");
await expect.poll(() => page.getByText("older question", { exact: true }).count()).toBe(1);
const after = await thread.evaluate((element) => ({
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
}));
expect(after.scrollTop).toBeGreaterThan(0);
expect(after.scrollTop).toBeCloseTo(
before.scrollTop + (after.scrollHeight - before.scrollHeight),
0,
);
await expect
.poll(() =>
page
.locator("openclaw-chat-pane")
.evaluate(
(element) =>
(element as HTMLElement & { catalogMessages: unknown[] }).catalogMessages.length,
),
)
.toBe(41);
await page.clock.runFor(100);
expectStableVirtualRowPrepend(anchor, await finishVirtualRowPrependProbe(thread));
expect(await page.locator(".agent-chat__composer-combobox > textarea").isDisabled()).toBe(true);
await expect
.poll(() => page.getByText("This session is on a paired node and is view-only.").count())
@@ -197,9 +316,11 @@ suite("Claude native session catalog", () => {
cursor: "older",
});
const exhaustedReadCount = (await gateway.getRequests("sessions.catalog.read")).length;
await thread.evaluate((element) => {
element.scrollTop = 0;
});
await thread.hover();
await page.mouse.wheel(0, -10_000);
await page.clock.runFor(100);
await expect.poll(() => thread.evaluate((element) => element.scrollTop)).toBe(0);
await expect.poll(() => page.getByText("older question", { exact: true }).count()).toBe(1);
await page.clock.runFor(500);
expect(await page.locator(".chat-history-loading").count()).toBe(0);
expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0);
@@ -261,19 +382,20 @@ suite("Claude native session catalog", () => {
await expect
.poll(() => thread.evaluate((element) => element.scrollHeight > element.clientHeight + 100))
.toBe(true);
await page.locator(".chat-history-sentinel").waitFor();
await thread.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
await gateway.deferNext("chat.history");
const before = await thread.evaluate((element) => {
await thread.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
});
await page.locator('.chat-virtual-row:not([data-virtual-row-key="history"])').first().waitFor();
const anchor = await firstVisibleVirtualRow(thread);
await gateway.waitForRequest("chat.history");
await page.locator(".chat-history-loading").waitFor();
await startVirtualRowPrependProbe(thread, anchor);
await gateway.resolveDeferred("chat.history");
await expect
.poll(() =>
@@ -286,17 +408,8 @@ suite("Claude native session catalog", () => {
),
)
.toBe(140);
await page.getByText(/^older native message 1\n/).waitFor();
const after = await thread.evaluate((element) => ({
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
}));
expect(after.scrollTop).toBeGreaterThan(0);
expect(after.scrollTop).toBeCloseTo(
before.scrollTop + (after.scrollHeight - before.scrollHeight),
0,
);
await page.clock.runFor(100);
expectStableVirtualRowPrepend(anchor, await finishVirtualRowPrependProbe(thread));
expect((await gateway.getRequests("chat.history")).at(-1)?.params).toMatchObject({
limit: 100,
offset: 100,
@@ -304,10 +417,69 @@ suite("Claude native session catalog", () => {
const exhaustedRequestCount = (await gateway.getRequests("chat.history")).length;
await thread.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await page.clock.runFor(100);
await page.getByText(/^older native message 1\n/).waitFor();
await page.clock.runFor(300);
expect(await page.locator(".chat-history-loading").count()).toBe(0);
expect(await gateway.getRequests("chat.history")).toHaveLength(exhaustedRequestCount);
await page.close();
});
it("keeps a focused message action mounted while its row scrolls out of view", async () => {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const messages = Array.from({ length: 200 }, (_, index) => ({
__openclaw: { seq: index + 1 },
content: [
{
type: "text",
text: `focus retention message ${index + 1}\n${"transcript detail line\n".repeat(3)}`,
},
],
role: index % 2 === 0 ? "assistant" : "user",
timestamp: Date.now() + index,
}));
await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup"],
methodResponses: {
"chat.startup": {
messages,
hasMore: false,
totalMessages: messages.length,
sessionId: "focus-retention",
thinkingLevel: null,
},
},
});
await page.goto(`${server.baseUrl}chat`);
await page.getByText(/^focus retention message 200\n/).waitFor();
const thread = page.locator(".chat-thread");
const action = thread.locator("button.chat-group-delete").last();
await action.focus();
const focusedRowKey = await action.evaluate(
(element) => element.closest<HTMLElement>(".chat-virtual-row")?.dataset.virtualRowKey ?? "",
);
expect(focusedRowKey).not.toBe("");
await thread.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await expect.poll(() => thread.evaluate((element) => Math.round(element.scrollTop))).toBe(0);
await page.getByText(/^focus retention message 1\n/).waitFor();
await expect
.poll(() =>
thread.evaluate((element, key) => {
const row = Array.from(
element.querySelectorAll<HTMLElement>(".chat-virtual-row[data-virtual-row-key]"),
).find((candidate) => candidate.dataset.virtualRowKey === key);
return Boolean(row?.contains(document.activeElement));
}, focusedRowKey),
)
.toBe(true);
expect(await thread.locator(".chat-virtual-row").count()).toBeLessThan(30);
await page.close();
});
});