mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
test: prove virtualized chat workflows
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user