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 { 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 { 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 { 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 { await locator.evaluate((node) => { const element = node as HTMLElement; element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); } async function scrollToMiddle(locator: Locator): Promise { 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> { 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, durationMs: number): Promise> { return await page.evaluate(({ selectors: targetSelectors, duration }) => new Promise>((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; 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 { 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); }