From 90b7950ccdcffbd9a87cec29d44951a9c9a5efce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 28 Aug 2026 03:06:14 -0700 Subject: [PATCH] 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 --- ...hat-flow.active-run-follow-ups.e2e.test.ts | 18 +- .../chat-flow.history-recovery.e2e.test.ts | 15 +- ...t-transcript-disclosure-anchor.e2e.test.ts | 165 +++++++++++++----- 3 files changed, 149 insertions(+), 49 deletions(-) diff --git a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts index f36b613b7f74..de37ffb1c860 100644 --- a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts @@ -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); diff --git a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts index 1a248d7bb3a8..c7e6fac0d129 100644 --- a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts +++ b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts @@ -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); } diff --git a/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts b/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts index 4876a0476090..57d0f476d8c5 100644 --- a/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts +++ b/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts @@ -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( - ".chat-pane-cache__pane--active .chat-thread", - ); - return ( - scroller && - scroller.scrollTop > 0 && - (!pointerInterruption || - Array.from( - scroller.querySelectorAll( - '.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( + ".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( + ".chat-pane-cache__pane--active .chat-thread", + ); + return ( + scroller && + scroller.scrollTop > 0 && + (!pointerInterruption || + Array.from( + scroller.querySelectorAll( + '.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(".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);