fix: stabilize workbench bottom follow scroll

This commit is contained in:
lyon
2026-06-19 02:45:53 +08:00
parent 940310433d
commit f095e61369
8 changed files with 455 additions and 59 deletions
@@ -0,0 +1,186 @@
import type { Locator, Page, TestInfo } from "@playwright/test";
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors } from "../fixtures/selectors";
interface ScrollSample {
t: number;
scrollTop: number;
clientHeight: number;
scrollHeight: number;
distanceToBottom: number;
following: string | null;
firstVisibleText: string | null;
lastVisibleText: string | null;
}
test.describe("Workbench bottom-follow scrolling", () => {
test.use({ scenarioId: "scroll-follow-long-trace" });
test("Trace, main conversation, and raw Trace lists stay bottom-pinned without top flashes", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/ses_scroll_follow");
const panel = page.locator(selectors.conversationList);
const trace = page.locator(`${selectors.traceTimeline}[data-status="running"]`).last();
await expect(trace).toBeVisible();
await openTrace(trace);
const traceList = trace.locator("ol");
await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(70);
await page.locator(`${selectors.messageCard}[data-role="agent"] .message-detail-button`).last().click();
await expect(page.locator(selectors.messageDetailDialog)).toBeVisible();
await page.locator(selectors.messageDetailDialog).getByRole("button", { name: "原始事件" }).click();
const rawList = page.locator(".trace-raw-list");
await expect.poll(async () => await rawList.locator("li").count()).toBeGreaterThan(70);
await waitUntilScrollable(panel);
await waitUntilScrollable(traceList);
await waitUntilScrollable(rawList);
await scrollToBottom(panel);
await scrollToBottom(traceList);
await scrollToBottom(rawList);
await expect(panel).toHaveAttribute("data-following", "true");
await expect(trace).toHaveAttribute("data-following", "true");
await expect(rawList).toHaveAttribute("data-following", "true");
await saveScreenshot(page, testInfo, "scroll-follow-bottom-before-append");
const samplesPromise = collectScrollTimelines(page, {
panel: selectors.conversationList,
trace: `${selectors.traceTimeline}[data-status="running"] ol`,
raw: ".trace-raw-list"
}, 1800);
await appendEvents(page, 4);
await page.waitForTimeout(250);
await appendEvents(page, 4);
await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(90);
await expect.poll(async () => await rawList.locator("li").count()).toBeGreaterThan(90);
const samples = await samplesPromise;
const panelSamples = samples.panel ?? [];
const traceSamples = samples.trace ?? [];
const rawSamples = samples.raw ?? [];
await attachTimeline(testInfo, "conversation-panel-scroll-timeline", panelSamples);
await attachTimeline(testInfo, "trace-list-scroll-timeline", traceSamples);
await attachTimeline(testInfo, "raw-trace-scroll-timeline", rawSamples);
assertNoTopFlash(panelSamples, "conversation-panel", 64);
assertNoTopFlash(traceSamples, "trace-timeline", 32);
assertNoTopFlash(rawSamples, "trace-raw-list", 32);
await saveScreenshot(page, testInfo, "scroll-follow-bottom-after-append");
await scrollToMiddle(traceList);
await expect(trace).toHaveAttribute("data-following", "false");
const beforePausedAppend = await readScroll(traceList);
await appendEvents(page, 3);
await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(100);
const afterPausedAppend = await readScroll(traceList);
expect(Math.abs(afterPausedAppend.scrollTop - beforePausedAppend.scrollTop)).toBeLessThanOrEqual(48);
expect(afterPausedAppend.distanceToBottom).toBeGreaterThan(96);
await scrollToBottom(traceList);
await expect(trace).toHaveAttribute("data-following", "true");
await appendEvents(page, 2);
await expect.poll(async () => (await readScroll(traceList)).distanceToBottom).toBeLessThanOrEqual(32);
await saveScreenshot(page, testInfo, "scroll-follow-user-pause-and-resume");
});
});
async function openTrace(trace: Locator): Promise<void> {
const details = trace.locator("details.trace-disclosure");
const open = await details.evaluate((node) => (node as HTMLDetailsElement).open);
if (!open) await trace.locator("summary.trace-disclosure-summary").click();
await expect(details).toHaveJSProperty("open", true);
}
async function appendEvents(page: Page, count: number): Promise<void> {
const response = await page.request.post("/__e2e/scroll-follow/append", { data: { count } });
expect(response.ok()).toBeTruthy();
const payload = await response.json() as { ok?: boolean; status?: string; eventCount?: number };
expect(payload.ok).toBe(true);
expect(payload.status).toBe("appended");
}
async function waitUntilScrollable(locator: Locator): Promise<void> {
await expect.poll(async () => await locator.evaluate((node) => {
const element = node as HTMLElement;
return element.scrollHeight > element.clientHeight + 24;
})).toBe(true);
}
async function scrollToBottom(locator: Locator): Promise<void> {
await locator.evaluate((node) => {
const element = node as HTMLElement;
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
}
async function scrollToMiddle(locator: Locator): Promise<void> {
await locator.evaluate((node) => {
const element = node as HTMLElement;
element.scrollTop = Math.max(0, Math.floor((element.scrollHeight - element.clientHeight) / 2));
element.dispatchEvent(new Event("scroll"));
});
}
async function readScroll(locator: Locator): Promise<Pick<ScrollSample, "scrollTop" | "clientHeight" | "scrollHeight" | "distanceToBottom">> {
return await locator.evaluate((node) => {
const element = node as HTMLElement;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
return { scrollTop: element.scrollTop, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, distanceToBottom: maxScrollTop - element.scrollTop };
});
}
async function collectScrollTimelines(page: Page, targets: Record<string, string>, durationMs: number): Promise<Record<string, ScrollSample[]>> {
return await page.evaluate(({ selectors: targetSelectors, duration }) => new Promise<Record<string, ScrollSample[]>>((resolve) => {
const entries = Object.entries(targetSelectors).map(([name, selector]) => [name, document.querySelector(selector) as HTMLElement | null] as const);
const samples = Object.fromEntries(entries.map(([name]) => [name, []])) as Record<string, ScrollSample[]>;
const startedAt = performance.now();
const visibleTexts = (element: HTMLElement): { first: string | null; last: string | null } => {
const containerRect = element.getBoundingClientRect();
const candidates = Array.from(element.querySelectorAll(":scope > li, :scope > article, .trace-render-row, .trace-raw-row")) as HTMLElement[];
const visible = candidates.filter((item) => {
const rect = item.getBoundingClientRect();
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
}).map((item) => {
const text = String(item.textContent ?? "").replace(/\s+/gu, " ").trim();
return text ? text.slice(0, 120) : null;
}).filter((value): value is string => Boolean(value));
return { first: visible[0] ?? null, last: visible.at(-1) ?? null };
};
const collect = () => {
const now = performance.now();
for (const [name, element] of entries) {
if (!element) continue;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
const visible = visibleTexts(element);
samples[name]?.push({
t: Math.round(now - startedAt),
scrollTop: Math.round(element.scrollTop),
clientHeight: Math.round(element.clientHeight),
scrollHeight: Math.round(element.scrollHeight),
distanceToBottom: Math.round(maxScrollTop - element.scrollTop),
following: element.dataset.following ?? element.closest("[data-following]")?.getAttribute("data-following") ?? null,
firstVisibleText: visible.first,
lastVisibleText: visible.last
});
}
if (now - startedAt >= duration) resolve(samples);
else window.requestAnimationFrame(collect);
};
window.requestAnimationFrame(collect);
}), { selectors: targets, duration: durationMs });
}
async function attachTimeline(testInfo: TestInfo, name: string, samples: ScrollSample[]): Promise<void> {
await testInfo.attach(`${name}.json`, { body: JSON.stringify(samples, null, 2), contentType: "application/json" });
}
function assertNoTopFlash(samples: ScrollSample[], name: string, bottomThreshold: number): void {
const scrollable = samples.filter((sample) => sample.scrollHeight > sample.clientHeight + 24);
expect(scrollable.length, `${name} did not produce scrollable samples`).toBeGreaterThan(4);
const maxScrollTop = Math.max(...scrollable.map((sample) => sample.scrollTop));
expect(maxScrollTop, `${name} never sampled away from the top`).toBeGreaterThan(40);
const topFlashes = scrollable.filter((sample, index) => index > 1 && maxScrollTop > 80 && sample.scrollTop <= 4);
expect(topFlashes, `${name} flashed to top: ${JSON.stringify(topFlashes.slice(0, 3))}`).toEqual([]);
const final = scrollable.at(-1);
expect(final?.distanceToBottom ?? Number.POSITIVE_INFINITY, `${name} final sample was not bottom-pinned`).toBeLessThanOrEqual(bottomThreshold);
}
@@ -10,7 +10,7 @@ test("TraceTimeline renders readable rows and hides backend noise", async ({ pag
await expect(page.locator(`${selectors.traceRow}[data-row-kind="tool"]`)).toContainText("commandExecution");
await expect(page.locator(`${selectors.traceRow}[data-terminal="true"]`)).toContainText("构建和 Trace 渲染检查完成");
await expect(page.locator(selectors.conversationList)).not.toContainText("token_count");
await page.locator(".message-detail-button").first().click();
await page.locator(`${selectors.messageCard}[data-role="agent"] .message-detail-button`).first().click();
await expect(page.locator(selectors.messageDetailDialog)).toContainText("trc_completed");
await expect(page.locator(selectors.messageDetailDialog)).toContainText("ses_completed");
await saveScreenshot(page, testInfo, "trace-rendering-detail");