| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- import { expect, test } from "@playwright/test"
- import {
- defineVisualRegions,
- reportVisualStability,
- startVisualProbe,
- stopVisualProbe,
- visualPlan,
- } from "../../utils/visual-stability"
- import {
- assistantMessage,
- partUpdated,
- setupTimeline,
- shell,
- textPart,
- toolPart,
- userMessage,
- waitForVisualSettle,
- type TimelineMessage,
- } from "./fixture"
- test.describe("timeline adverse visual stability", () => {
- test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
- const activeShellID = "prt_adverse_01_shell"
- const messages = [
- ...history(24),
- userMessage(),
- assistantMessage([shell(activeShellID, "running")], { completed: false }),
- ]
- const timeline = await setupTimeline(page, {
- messages,
- settings: { shellToolPartsExpanded: true },
- cpuRate: 4,
- eventRetry: 30,
- })
- const scroller = page.locator(".scroll-view__viewport", {
- has: page.locator('[data-timeline-row="AssistantPart"]'),
- })
- await scroller.evaluate((element) => {
- element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
- element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
- })
- await page.waitForTimeout(150)
- await expect
- .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
- .toBeGreaterThan(100)
- const anchor = await scroller.evaluate((element) => {
- const view = element.getBoundingClientRect()
- return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
- const rect = row.getBoundingClientRect()
- return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
- })?.dataset.timelineKey
- })
- expect(anchor).toBeTruthy()
- await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
- const regions = defineVisualRegions({
- anchor: { selector: `[data-timeline-key="${anchor}"]` },
- })
- await startVisualProbe(page, regions)
- await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
- await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
- await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
- await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
- const trace = await stopVisualProbe<keyof typeof regions>(page)
- await reportVisualStability(
- testInfo,
- "scrolled-away-shell",
- trace,
- visualPlan(regions, [
- { type: "required", regions: ["anchor"] },
- { type: "unique", regions: ["anchor"] },
- { type: "stable", regions: ["anchor"] },
- { type: "fixed", regions: ["anchor"] },
- { type: "opacity", regions: "all" },
- { type: "continuity", regions: "all" },
- { type: "motion", regions: "all", maxPositionReversals: 0 },
- { type: "label-stability", regions: "all" },
- ]),
- )
- })
- test("preserves an explicit shell state across virtualization", async ({ page }) => {
- const targetID = "prt_virtual_shell"
- const messages = [
- userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
- assistantMessage([shell(targetID, "completed", lines(20))], {
- id: "msg_0001_virtual_assistant",
- parentID: "msg_0000_virtual_user",
- created: 1700000001000,
- }),
- ...history(35, 10),
- ]
- await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
- const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
- await scroller.evaluate((element) => {
- element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
- element.scrollTop = 0
- })
- await page.waitForTimeout(300)
- const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
- await expect(trigger).toBeVisible()
- await trigger.click()
- await expect(trigger).toHaveAttribute("aria-expanded", "true")
- await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
- await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
- await scroller.evaluate((element) => (element.scrollTop = 0))
- await expect(trigger).toBeVisible()
- await expect(trigger).toHaveAttribute("aria-expanded", "true")
- })
- test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
- const shellID = "prt_narrow_01_shell"
- const followingID = "prt_narrow_02_following"
- const timeline = await setupTimeline(page, {
- messages: [
- userMessage(),
- assistantMessage(
- [shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
- {
- completed: false,
- },
- ),
- ],
- settings: { shellToolPartsExpanded: true },
- viewport: { width: 430, height: 800 },
- cpuRate: 4,
- })
- await waitForVisualSettle(page, [
- `[data-timeline-part-id="${shellID}"]`,
- `[data-timeline-part-id="${followingID}"]`,
- ])
- const regions = defineVisualRegions({
- shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
- following: {
- selector: `[data-timeline-part-id="${followingID}"]`,
- closest: '[data-timeline-row="AssistantPart"]',
- },
- })
- await startVisualProbe(page, regions)
- await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
- await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
- await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
- const trace = await stopVisualProbe<keyof typeof regions>(page)
- await reportVisualStability(
- testInfo,
- "narrow-shell",
- trace,
- visualPlan(
- regions,
- [
- { type: "required", regions: ["shell", "following"] },
- { type: "unique", regions: ["shell", "following"] },
- { type: "stable", regions: ["shell", "following"] },
- { type: "opacity", regions: "all" },
- { type: "continuity", regions: "all" },
- { type: "motion", regions: "all", maxPositionReversals: 0 },
- { type: "label-stability", regions: "all" },
- { type: "preserve-bottom-anchor" },
- { type: "flow", regions: ["shell", "following"] },
- ],
- { perMarker: true },
- ),
- )
- })
- test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
- const shellID = "prt_resize_01_shell"
- const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
- const followingID = "prt_resize_04_following"
- await setupTimeline(page, {
- messages: [
- userMessage(),
- assistantMessage([
- shell(shellID, "completed", wideLines(15)),
- toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
- toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
- textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
- ]),
- ],
- settings: { shellToolPartsExpanded: true },
- cpuRate: 4,
- seedHistory: true,
- })
- const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
- const regions = defineVisualRegions({
- shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
- context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
- following: {
- selector: `[data-timeline-part-id="${followingID}"]`,
- closest: '[data-timeline-row="AssistantPart"]',
- },
- })
- await startVisualProbe(page, regions)
- await page.setViewportSize({ width: 430, height: 800 })
- await page.waitForTimeout(500)
- await page.setViewportSize({ width: 900, height: 800 })
- await page.waitForTimeout(500)
- await page.setViewportSize({ width: 1400, height: 900 })
- await page.waitForTimeout(500)
- const trace = await stopVisualProbe<keyof typeof regions>(page)
- await reportVisualStability(
- testInfo,
- "responsive-resize",
- trace,
- visualPlan(regions, [
- { type: "required", regions: ["shell", "context", "following"] },
- { type: "unique", regions: ["shell", "context", "following"] },
- { type: "stable", regions: ["shell", "context", "following"] },
- { type: "opacity", regions: "all" },
- { type: "continuity", regions: "all" },
- { type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
- { type: "label-stability", regions: "all" },
- { type: "flow", regions: ["shell", "context", "following"] },
- ]),
- )
- })
- })
- function history(count: number, offset = 0): TimelineMessage[] {
- return Array.from({ length: count }, (_, index) => {
- const value = index + offset
- const prefix = `msg_0${String(value).padStart(3, "0")}_history`
- const userID = `${prefix}_a_user`
- return [
- userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
- assistantMessage(
- [
- textPart(
- `prt_history_${String(value).padStart(3, "0")}`,
- `Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
- ),
- ],
- {
- id: `${prefix}_b_assistant`,
- parentID: userID,
- created: 1699990001000 + value * 10_000,
- },
- ),
- ]
- }).flat()
- }
- function lines(count: number) {
- return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
- }
- function wideLines(count: number) {
- return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
- }
|