187 lines
9.4 KiB
TypeScript
187 lines
9.4 KiB
TypeScript
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);
|
|
}
|