mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
test(ui): synchronize transcript pointer interruption (#131539)
* test(ui): synchronize transcript pointer interruption * test(ui): sample steer geometry in one browser turn * test(ui): scope retained transcript history assertions
This commit is contained in:
committed by
GitHub
parent
b576bb41cf
commit
90b7950ccd
@@ -274,10 +274,20 @@ suite.define(() => {
|
||||
await streamingRow.waitFor();
|
||||
expect(await streamingRow.getAttribute("data-virtual-row-key")).not.toBe(workingRowKey);
|
||||
const steerBubble = page.locator(".chat-group.user", { hasText: steerText }).last();
|
||||
const [steerBounds, streamingBounds] = await Promise.all([
|
||||
steerBubble.boundingBox(),
|
||||
streamingBubble.boundingBox(),
|
||||
]);
|
||||
const steerElement = await steerBubble.elementHandle();
|
||||
// Scrolling between separate protocol reads can make adjacent rows appear to overlap.
|
||||
const [steerBounds, streamingBounds] = await streamingBubble.evaluate(
|
||||
(streaming, steer) =>
|
||||
[steer, streaming].map((element) => {
|
||||
if (!element?.isConnected || element.getClientRects().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
}),
|
||||
steerElement,
|
||||
);
|
||||
await steerElement?.dispose();
|
||||
expect(steerBounds).not.toBeNull();
|
||||
expect(streamingBounds).not.toBeNull();
|
||||
expect(streamingBounds!.y).toBeGreaterThanOrEqual(steerBounds!.y + steerBounds!.height - 1);
|
||||
|
||||
@@ -75,7 +75,12 @@ suite.define(() => {
|
||||
);
|
||||
await sessionLink(sessionB).click();
|
||||
await page.getByText(sessionBText, { exact: true }).waitFor({ timeout: 10_000 });
|
||||
const historyRequestsBeforePeerDelete = (await gateway.getRequests("chat.history")).length;
|
||||
const retainedHistoryRequests = async () =>
|
||||
(await gateway.getRequests("chat.history")).filter(({ params }) => {
|
||||
const { sessionKey } = requireRecord(params);
|
||||
return sessionKey === sessionA || sessionKey === sessionB;
|
||||
});
|
||||
const historyRequestsBeforePeerDelete = (await retainedHistoryRequests()).length;
|
||||
const startupRequestsBeforePeerDelete = (await gateway.getRequests("chat.startup")).length;
|
||||
await page.evaluate(() => {
|
||||
window.addEventListener("storage", (event) => {
|
||||
@@ -117,10 +122,10 @@ suite.define(() => {
|
||||
|
||||
await sessionLink(sessionA).click();
|
||||
await page.getByText(sessionAText, { exact: true }).waitFor({ timeout: 10_000 });
|
||||
await Promise.all([
|
||||
expectRequestCountStable(gateway, "chat.history", historyRequestsBeforePeerDelete),
|
||||
expectRequestCountStable(gateway, "chat.startup", startupRequestsBeforePeerDelete),
|
||||
]);
|
||||
// Prefetch may warm C without reloading either retained pane. Request capture is
|
||||
// append-only, so checking history after the startup window covers the same interval.
|
||||
await expectRequestCountStable(gateway, "chat.startup", startupRequestsBeforePeerDelete);
|
||||
expect(await retainedHistoryRequests()).toHaveLength(historyRequestsBeforePeerDelete);
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
controlUiBundledSettingsStorageKey,
|
||||
controlUiE2eWaitTimeoutMs,
|
||||
installMockGateway,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { chatThreadDistanceFromBottom, waitForChatScrollIdle } from "./chat-flow.test-support.ts";
|
||||
@@ -113,16 +114,25 @@ async function showSplitDashboard(page: import("playwright").Page, sessionKey: s
|
||||
|
||||
suite.define(() => {
|
||||
it.each([
|
||||
{ reducedMotion: "no-preference", interruption: "wheel" },
|
||||
{ reducedMotion: "reduce", interruption: "wheel" },
|
||||
{ reducedMotion: "no-preference", interruption: "pointer" },
|
||||
{ reducedMotion: "no-preference", interruption: "wheel", recoveryPosition: "within-viewport" },
|
||||
{ reducedMotion: "reduce", interruption: "wheel", recoveryPosition: "within-viewport" },
|
||||
{
|
||||
reducedMotion: "no-preference",
|
||||
interruption: "synthetic-pointer",
|
||||
recoveryPosition: "within-viewport",
|
||||
},
|
||||
{
|
||||
reducedMotion: "no-preference",
|
||||
interruption: "native-pointer",
|
||||
recoveryPosition: "above-viewport",
|
||||
},
|
||||
] as const)(
|
||||
"remeasures recovered assistant text after interrupted scrolling ($reducedMotion, $interruption)",
|
||||
async ({ reducedMotion, interruption }) => {
|
||||
"remeasures recovered assistant text after interrupted scrolling ($reducedMotion, $interruption, $recoveryPosition)",
|
||||
async ({ reducedMotion, interruption, recoveryPosition }) => {
|
||||
const artifactDir = path.resolve(
|
||||
process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR ??
|
||||
".artifacts/control-ui-e2e/virtual-sizing/after",
|
||||
interruption === "wheel" ? reducedMotion : `${reducedMotion}-${interruption}`,
|
||||
`${reducedMotion}-${interruption}-${recoveryPosition}`,
|
||||
);
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
await suite.withPage(
|
||||
@@ -144,7 +154,7 @@ suite.define(() => {
|
||||
__openclaw: {
|
||||
id: `sizing-message-${index}`,
|
||||
seq: index + 1,
|
||||
...(index === 1 || (interruption === "pointer" && index % 2 === 1)
|
||||
...(index === 1 || (interruption === "native-pointer" && index % 2 === 1)
|
||||
? { truncated: true, reason: "display-cap" }
|
||||
: {}),
|
||||
},
|
||||
@@ -163,31 +173,86 @@ suite.define(() => {
|
||||
.locator('.chat-bubble[data-entry-id="sizing-message-1"]')
|
||||
.waitFor({ state: "visible" });
|
||||
await page.screenshot({ path: path.join(artifactDir, "01-before-scroll.png") });
|
||||
await page.locator(".chat-scroll-to-bottom").click();
|
||||
await page.waitForFunction((pointerInterruption) => {
|
||||
const scroller = document.querySelector<HTMLElement>(
|
||||
".chat-pane-cache__pane--active .chat-thread",
|
||||
);
|
||||
return (
|
||||
scroller &&
|
||||
scroller.scrollTop > 0 &&
|
||||
(!pointerInterruption ||
|
||||
Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>(
|
||||
'.chat-bubble[data-entry-id^="sizing-message-"]',
|
||||
),
|
||||
).some(
|
||||
(bubble) =>
|
||||
Number(bubble.dataset.entryId!.slice("sizing-message-".length)) % 2 === 1 &&
|
||||
bubble.closest(".chat-virtual-row")!.getBoundingClientRect().bottom <=
|
||||
scroller.getBoundingClientRect().top,
|
||||
))
|
||||
);
|
||||
}, interruption === "pointer");
|
||||
const during = await thread.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
max: element.scrollHeight - element.clientHeight,
|
||||
}));
|
||||
let during: { top: number; max: number };
|
||||
if (interruption === "synthetic-pointer") {
|
||||
// Check native gutter hit testing separately from animation timing.
|
||||
const pointer = await thread.evaluateHandle((element) => {
|
||||
const observed = { trusted: false, scroller: false };
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
observed.trusted = event.isTrusted;
|
||||
observed.scroller = event.target === element;
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
return observed;
|
||||
});
|
||||
const track = await thread.boundingBox();
|
||||
expect(track).not.toBeNull();
|
||||
await page.mouse.click(track!.x + track!.width - 3, track!.y + 20);
|
||||
expect(await pointer.jsonValue()).toEqual({ trusted: true, scroller: true });
|
||||
await pointer.dispose();
|
||||
// Synthetic intent runs in the first positive native scroll callback;
|
||||
// a Node round trip can outlive the fixed row's visible range.
|
||||
// Smooth animation and production cancellation remain real.
|
||||
during = await page
|
||||
.locator(".chat-scroll-to-bottom")
|
||||
.evaluate((button, waitTimeout) => {
|
||||
const scroller = document.querySelector<HTMLElement>(
|
||||
".chat-pane-cache__pane--active .chat-thread",
|
||||
)!;
|
||||
return new Promise<{ top: number; max: number }>((resolve, reject) => {
|
||||
const interrupt = (event: Event) => {
|
||||
if (!event.isTrusted || scroller.scrollTop <= 0) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
scroller.removeEventListener("scroll", interrupt);
|
||||
const position = {
|
||||
top: scroller.scrollTop,
|
||||
max: scroller.scrollHeight - scroller.clientHeight,
|
||||
};
|
||||
scroller.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }),
|
||||
);
|
||||
resolve(position);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
scroller.removeEventListener("scroll", interrupt);
|
||||
reject(new Error("Native scrolling did not reach the interruption geometry"));
|
||||
}, waitTimeout);
|
||||
scroller.addEventListener("scroll", interrupt);
|
||||
(button as HTMLElement).click();
|
||||
});
|
||||
}, controlUiE2eWaitTimeoutMs);
|
||||
} else {
|
||||
await page.locator(".chat-scroll-to-bottom").click();
|
||||
await page.waitForFunction((pointerInterruption) => {
|
||||
const scroller = document.querySelector<HTMLElement>(
|
||||
".chat-pane-cache__pane--active .chat-thread",
|
||||
);
|
||||
return (
|
||||
scroller &&
|
||||
scroller.scrollTop > 0 &&
|
||||
(!pointerInterruption ||
|
||||
Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>(
|
||||
'.chat-bubble[data-entry-id^="sizing-message-"]',
|
||||
),
|
||||
).some(
|
||||
(bubble) =>
|
||||
Number(bubble.dataset.entryId!.slice("sizing-message-".length)) % 2 === 1 &&
|
||||
bubble.closest(".chat-virtual-row")!.getBoundingClientRect().bottom <=
|
||||
scroller.getBoundingClientRect().top,
|
||||
))
|
||||
);
|
||||
}, interruption === "native-pointer");
|
||||
during = await thread.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
max: element.scrollHeight - element.clientHeight,
|
||||
}));
|
||||
}
|
||||
if (reducedMotion === "no-preference") {
|
||||
expect(during.top).toBeLessThan(during.max);
|
||||
}
|
||||
@@ -195,11 +260,13 @@ suite.define(() => {
|
||||
await thread.hover();
|
||||
await page.mouse.wheel(0, -100_000);
|
||||
} else {
|
||||
const track = await thread.boundingBox();
|
||||
expect(track).not.toBeNull();
|
||||
// A real pointer press in the scroll gutter can take over without
|
||||
// a wheel event or a changed offset.
|
||||
await page.mouse.click(track!.x + track!.width - 3, track!.y + 20);
|
||||
if (interruption === "native-pointer") {
|
||||
const track = await thread.boundingBox();
|
||||
expect(track).not.toBeNull();
|
||||
// A real pointer press in the scroll gutter can take over without
|
||||
// a wheel event or a changed offset.
|
||||
await page.mouse.click(track!.x + track!.width - 3, track!.y + 20);
|
||||
}
|
||||
await page.locator(".chat-scroll-to-bottom").waitFor({ state: "visible" });
|
||||
// Chromium can commit its last canceled animation offset after the
|
||||
// pointer action returns. Capture the reader before releasing text.
|
||||
@@ -216,7 +283,7 @@ suite.define(() => {
|
||||
// Native smooth scrolling can pass the first message before the pointer
|
||||
// arrives. Recover a still-mounted pending row above the settled reader.
|
||||
const messageId =
|
||||
interruption === "pointer"
|
||||
interruption === "native-pointer"
|
||||
? await thread.evaluate((element) => {
|
||||
const top = element.getBoundingClientRect().top;
|
||||
const bubbles = Array.from(
|
||||
@@ -246,8 +313,19 @@ suite.define(() => {
|
||||
expect(pendingRequest).toBeDefined();
|
||||
const initial = await bubble.evaluate((element) => {
|
||||
const row = element.closest<HTMLElement>(".chat-virtual-row")!;
|
||||
return { key: row.dataset.virtualRowKey, height: row.offsetHeight };
|
||||
return {
|
||||
key: row.dataset.virtualRowKey,
|
||||
height: row.offsetHeight,
|
||||
bottom: row.getBoundingClientRect().bottom,
|
||||
viewportTop: row.closest(".chat-thread")!.getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
if (interruption !== "wheel") {
|
||||
expect(
|
||||
initial.bottom <= initial.viewportTop,
|
||||
`recovered row must remain mounted ${recoveryPosition}`,
|
||||
).toBe(recoveryPosition === "above-viewport");
|
||||
}
|
||||
const fullText = Array.from(
|
||||
{ length: 5 },
|
||||
(_, index) =>
|
||||
@@ -293,13 +371,20 @@ suite.define(() => {
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (interruption === "pointer") {
|
||||
if (interruption !== "wheel") {
|
||||
// Growth above the reader legitimately adjusts scrollTop; the visible
|
||||
// row must stay anchored regardless of where the pointer stopped scrolling.
|
||||
expect(finalAnchor.key).toBe(interruptedAnchor.key);
|
||||
expect(
|
||||
Math.abs(finalAnchor.viewportTop - interruptedAnchor.viewportTop),
|
||||
).toBeLessThanOrEqual(1);
|
||||
if (interruption === "synthetic-pointer") {
|
||||
expect(
|
||||
Math.abs(
|
||||
(await thread.evaluate((element) => element.scrollTop)) - interruptedOffset,
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(final.key).toBe(initial.key);
|
||||
expect(final.height).toBeGreaterThan(initial.height);
|
||||
|
||||
Reference in New Issue
Block a user