fix: stabilize workbench bottom follow scroll
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user