import { devices, expect, test, type Page } from "@playwright/test" import { assistantMessage, partDelta, partUpdated, renderedPartID, session, sessionID, setupTimeline, shell, textPart, userMessage, } from "../utils/timeline" import { capturePromptMotion, markPrompt, promptMotionIssues, readPromptPositions } from "../utils/prompt-motion" // The thumb's track is the part of the timeline above the composer region, which the timeline scrolls on under. const thumbSelector = '[data-slot="session-timeline-visible"] [data-orientation="vertical"]' // Compositor prediction can add 20–25px to discrete CDP moves even in a plain // scrollport. Disable it so the visual assertion measures the supplied gesture. test.use({ colorScheme: "light", launchOptions: { args: ["--disable-features=ResamplingScrollEvents"] } }) for (const device of ["Pixel 7", "iPhone 13"]) { test.describe(device, () => { // Chromium drives native touch input, including the virtualizer's iOS user-agent path. test.use({ isMobile: true, hasTouch: true, userAgent: devices[device].userAgent, viewport: { width: 390, height: 844 }, }) test("reversing a touch drag stops following streamed output", async ({ page }, testInfo) => { const partID = "prt_mobile_stream" const content = Array.from({ length: 60 }, (_, index) => `Reading earlier output ${index}.\n\n`).join("") const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, content)], { completed: false })], viewport: { width: 390, height: 844 }, }) const scroller = page .locator('[data-slot="session-timeline-scroll"]') .getByRole("region", { name: "scrollable content", exact: true }) const part = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`) const anchor = part.getByText("Reading earlier output 59.", { exact: true }) await page.evaluate(() => document.fonts.ready) await expect(scroller.locator("[data-timeline-virtual-content]")).toBeVisible() await expect(anchor).toBeInViewport() const bounds = await scroller.boundingBox() expect(bounds).not.toBeNull() if (!bounds) return const devtools = await page.context().newCDPSession(page) const x = bounds.x + bounds.width / 3 const y = bounds.y + bounds.height * 0.75 await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }) for (let step = 1; step <= 12; step++) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y - step * 10 }] }) for (let step = 1; step <= 8; step++) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y - 120 + step * 10 }], }) const before = await anchor.boundingBox() expect(before).not.toBeNull() if (!before) return await timeline.send( partUpdated(textPart(partID, `${content}New streamed output.\n\n${"More output.\n\n".repeat(10)}`)), ) await expect(part).toContainText("New streamed output.") await expect(part.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0) await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }) await testInfo.attach("held-touch.png", { body: await page.screenshot(), contentType: "image/png" }) expect((await anchor.boundingBox())?.y).toBeCloseTo(before.y, 0) }) test("keeps the reading anchor when 2 earlier images load during a drag", async ({ page }, testInfo) => { const image = Promise.withResolvers() const url = new URL("/mobile-scroll-images.svg", testInfo.project.use.baseURL).href await page.route(url, async (route) => { await image.promise await route.fulfill({ contentType: "image/svg+xml", body: '', }) }) await setupTimeline(page, { messages: [ userMessage(), assistantMessage( Array.from({ length: 6 }, (_, index) => textPart( `prt_images_${index}`, index < 2 ? `Image ${index}.\n\n![Image ${index}](${url})` : Array.from({ length: 12 }, (_, line) => `Part ${index} line ${line}.`).join("\n\n"), ), ), { completed: false }, ), ], viewport: { width: 390, height: 844 }, }) const reading = await readFrom(page, "Part 2 line 0.") const images = [0, 1].map((index) => page.getByAltText(`Image ${index}`, { exact: true })) const rows = images.map((image) => reading.scroller.locator("[data-timeline-key]", { has: image })) const heights = await Promise.all( rows.map((row) => row.evaluate((element) => element.getBoundingClientRect().height)), ) const before = await reading.anchor.evaluate((element) => element.getBoundingClientRect().top) const bounds = (await reading.scroller.boundingBox())! const devtools = await page.context().newCDPSession(page) await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 }], timestamp: restingTimestamp(), }) image.resolve() for (const [index, row] of rows.entries()) { await expect(images[index]).toHaveJSProperty("naturalHeight", 600) await expect .poll(() => row.evaluate((element) => element.getBoundingClientRect().height)) .toBe(heights[index] + 600) } await testInfo.attach("images-held.png", { body: await page.screenshot(), contentType: "image/png" }) expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0) for (let step = 1; step <= 21; step++) { await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 + step * 30 }], timestamp: restingTimestamp(), }) await expect .poll(() => reading.anchor.evaluate((element) => element.getBoundingClientRect().top)) .toBeCloseTo(before + step * 30 - 15, 0) } await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [], timestamp: Date.now() / 1000, }) await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1) expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo( before + 615, 0, ) }) test("reaches the start without a gap or release jump after earlier output shrinks", async ({ page }, testInfo) => { const timeline = await setupTimeline(page, { messages: [ userMessage(), assistantMessage( Array.from({ length: 6 }, (_, index) => index < 2 ? shell( `prt_shrink_${index}`, "running", Array.from({ length: 4 }, (_, line) => `Output ${line}.`).join("\n\n"), ) : textPart( `prt_shrink_text_${index}`, Array.from({ length: 12 }, (_, line) => `Part ${index} line ${line}.`).join("\n\n"), ), ), { completed: false }, ), ], settings: { shellToolPartsExpanded: true }, viewport: { width: 390, height: 844 }, }) const reading = await readFrom(page, "Part 2 line 0.") const bounds = (await reading.scroller.boundingBox())! const devtools = await page.context().newCDPSession(page) const part = page.locator(`[data-timeline-part-id="${renderedPartID("prt_shrink_0")}"]`) const row = reading.scroller.locator("[data-timeline-key]", { has: part }) const height = await row.evaluate((element) => element.getBoundingClientRect().height) await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 }], }) await timeline.send(partUpdated(shell("prt_shrink_0", "running", "Updated shorter output."))) await expect(part).toContainText("Updated shorter output.") await expect.poll(() => row.evaluate((element) => element.getBoundingClientRect().height)).toBeLessThan(height) const capture = await capturePromptMotion(page, bounds) for (let step = 1; step <= 21; step++) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 + step * 30 }], }) const first = reading.scroller.locator('[data-timeline-row="UserMessage"]') await expect(first).toBeInViewport() await testInfo.attach("start-held.png", { body: await page.screenshot(), contentType: "image/png" }) expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(reading.start, 0) const before = await reading.anchor.evaluate((element) => element.getBoundingClientRect().top) await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }) await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1) await testInfo.attach("start-released.png", { body: await page.screenshot(), contentType: "image/png" }) expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(reading.start, 0) expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0) const painted = await capture() await testInfo.attach("painted-prompt-motion", { body: JSON.stringify(painted.positions), contentType: "application/json", }) expect(painted.positions.length).toBeGreaterThan(1) const issues = promptMotionIssues(painted.positions) if (issues.length) { for (const index of [Math.max(0, issues[0].frame - 1), issues[0].frame]) await testInfo.attach(`painted-motion-${index}`, { body: Buffer.from(painted.frames[index], "base64"), contentType: "image/jpeg", }) } expect(issues, "The painted prompt must not reverse or disappear during a one-direction drag").toEqual([]) }) for (const nestedStart of [500, 0]) { test(`nested output owns the gesture or chains at its boundary (${nestedStart})`, async ({ page }, testInfo) => { const timeline = await setupTimeline(page, { messages: [ userMessage(), assistantMessage( [ shell( "prt_nested", "completed", Array.from({ length: 120 }, (_, index) => `Output line ${index}`).join("\n"), ), textPart("prt_nested_tail", "Latest output."), ], { completed: false }, ), ], settings: { shellToolPartsExpanded: true }, seedHistory: true, viewport: { width: 390, height: 844 }, }) const root = page.locator('[data-slot="session-timeline-scroll"]') const nested = page.locator(`[data-timeline-part-id="${renderedPartID("prt_nested")}"] [data-scrollable]`) const tail = page.getByText("Latest output.", { exact: true }) await expect(root.locator("[data-timeline-virtual-content]")).toBeVisible() await expect(tail).toBeInViewport() if (nestedStart) { await page.evaluate(() => document.fonts.ready) const position = await tail.evaluate((element) => element.getBoundingClientRect().top) await nested.evaluate((element) => { element.scrollTop = 500 // Control+End animates, and Chromium drops a key scroll that starts after that animation reaches // the end but before it finishes. Wait for the scroll to end there, not only for its offset. element.addEventListener("scrollend", function settled() { if (element.scrollHeight - element.clientHeight - element.scrollTop >= 1) return element.dataset.endSettled = "" element.removeEventListener("scrollend", settled) }) }) await nested.press("Control+End") await expect(nested).toHaveAttribute("data-end-settled") await nested.press("Control+Home") await expect(nested).toHaveJSProperty("scrollTop", 0) expect(await tail.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(position, 0) } await nested.evaluate((element, top) => (element.scrollTop = top), nestedStart) await expect(nested).toHaveJSProperty("scrollTop", nestedStart) const before = await tail.evaluate((element) => element.getBoundingClientRect().top) const bounds = (await nested.boundingBox())! const x = bounds.x + 100 const y = bounds.y + bounds.height * (nestedStart ? 0.75 : 0.25) const devtools = await page.context().newCDPSession(page) await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }) if (nestedStart) { for (let step = 1; step <= 12; step++) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y - step * 10 }], }) await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBeGreaterThan(550) const far = await nested.evaluate((element) => element.scrollTop) for (let step = 1; step <= 6; step++) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y - 120 + step * 10 }], }) await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBeLessThan(far) expect(await tail.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0) } if (!nestedStart) { for (let step = 1; step <= 12; step++) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + step * 10 }], }) await expect .poll(() => tail.evaluate((element) => element.getBoundingClientRect().top)) .toBeGreaterThan(before + 50) } await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }) await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1) const released = await tail.evaluate((element) => element.getBoundingClientRect().top) await timeline.send( partUpdated( textPart( "prt_nested_tail", `Latest output.\n\nNew stream.\n\n${"Additional line.\n\n".repeat(30)}Latest stream end.`, ), ), ) const latest = page .locator(`[data-timeline-part-id="${renderedPartID("prt_nested_tail")}"]`) .getByText("New stream.", { exact: true }) await expect(latest).toBeAttached() await expect(root.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0) await testInfo.attach("nested-after-stream.png", { body: await page.screenshot(), contentType: "image/png" }) if (nestedStart) { await expect(page.getByText("Latest stream end.", { exact: true })).toBeInViewport() } if (!nestedStart) expect(await tail.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(released, 0) }) } for (const [width, release] of [ [390, "touchEnd"], [1000, "touchCancel"], ] as const) { test(`detached session gestures do not unpin the selected session (${width}px, ${release})`, async ({ page, }, testInfo) => { const second = "ses_gesture_destination" const content = Array.from({ length: 60 }, (_, index) => `Read output ${index}.`).join("\n\n") const fixture = await setupTimeline(page, { tabs: [sessionID, second], sessions: [session(), session({ id: second, title: "Second gesture session" })], messages: [userMessage(), assistantMessage([textPart("prt_session_gesture", content)], { completed: false })], viewport: { width, height: 900 }, }) const timeline = page.locator('[data-slot="session-timeline-scroll"]') const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true }) const tail = page.getByText("Read output 59.", { exact: true }) await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible() await expect(tail).toBeInViewport() await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0) await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1) const before = await tail.evaluate((element) => element.getBoundingClientRect().top) const bounds = (await scroller.boundingBox())! const point = { x: bounds.x + 150, y: bounds.y + 200 } const oldTarget = await page.evaluateHandle((point) => document.elementFromPoint(point.x, point.y), point) const oldRoot = (await scroller.elementHandle())! const devtools = await page.context().newCDPSession(page) await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] }) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: point.x, y: point.y + 40 }], }) await expect.poll(() => tail.evaluate((element) => element.getBoundingClientRect().top)).toBeGreaterThan(before) if (width < 768) { await page.locator('[data-slot="mobile-tabs-trigger"]').click() await page .locator('[data-slot="mobile-tabs-drawer"]') .locator(`[data-titlebar-tab-link][href$="/session/${second}"]`) .click() await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText("Second gesture session") } if (width >= 768) { await page.locator(`[data-titlebar-tab-link][href$="/session/${second}"]`).click() await expect(page.getByRole("heading", { name: "Second gesture session", exact: true })).toBeVisible() } await expect(page).toHaveURL(new RegExp(`/session/${second}$`)) await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible() await expect(tail).toBeInViewport() await expect.poll(() => oldTarget.evaluate((element) => element?.isConnected)).toBe(false) expect(await oldRoot.evaluate((element) => element.isConnected)).toBe(false) const selected = await tail.evaluate((element) => element.getBoundingClientRect().top) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: point.x, y: point.y + 70 }], }) await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] }) expect(await tail.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(selected, 0) await oldTarget.dispose() await oldRoot.dispose() const delta = partDelta( "prt_session_gesture", `\n\n${Array.from({ length: 30 }, (_, index) => `Newly streamed ${index}.`).join("\n\n")}`, ) if (delta.type !== "session.text.delta") throw new Error("Expected a text delta") await fixture.send({ ...delta, data: { ...delta.data, sessionID: second } }) await expect(page.getByText("Newly streamed 29.", { exact: true })).toBeInViewport() await testInfo.attach("selected-session-follows.png", { body: await page.screenshot(), contentType: "image/png", }) }) } // Released Home, Control+Home, Control+End, and scrollbar take the same prepareNavigation path as these rows. for (const handoff of [ { key: "Home", held: true }, { key: "End", held: false }, { key: "Control+Home", held: true }, { key: "latest", held: false }, { key: "scrollbar", held: true }, { key: "scrollbar-large", held: true }, ] as const) { test(`${handoff.key} owns pending touch adjustments (${handoff.held ? "held" : "released"})`, async ({ page, }, testInfo) => { const growsDuringDrag = handoff.key === "scrollbar" && handoff.held const growsBeforeDrag = handoff.key === "scrollbar-large" const usesScrollbar = handoff.key.startsWith("scrollbar") const image = Promise.withResolvers() const imageURL = new URL("/scrollbar-drag-image.svg", testInfo.project.use.baseURL).href if (growsDuringDrag || growsBeforeDrag) await page.route(imageURL, async (route) => { await image.promise await route.fulfill({ contentType: "image/svg+xml", body: ``, }) }) const fixture = await setupTimeline(page, { messages: [ userMessage(), assistantMessage( [ textPart( "prt_handoff_prefix", Array.from({ length: 40 }, (_, index) => `Prefix ${index}.`).join("\n\n") + (growsDuringDrag || growsBeforeDrag ? `\n\n![Earlier diagram](${imageURL})` : ""), ), shell("prt_handoff_shell", "running", "A short."), textPart( "prt_handoff_reading", Array.from({ length: 60 }, (_, index) => `Reading ${index}.`).join("\n\n"), ), ], { completed: false }, ), ], settings: { shellToolPartsExpanded: true }, viewport: { width: 390, height: 844 }, }) const timeline = page.locator('[data-slot="session-timeline-scroll"]') const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true }) await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible() await expect(page.getByText("Reading 59.", { exact: true })).toBeInViewport() await page.evaluate(() => document.fonts.ready) await scroller.evaluate((element) => { element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1 })) element.scrollTop = 0 }) const first = scroller.locator('[data-timeline-row="UserMessage"]') await expect(first).toBeInViewport() const start = await first.evaluate((element) => element.getBoundingClientRect().top) await expect(page.getByText("Prefix 39.", { exact: true })).toBeAttached() await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0) await scroller.evaluate( (element, distance) => (element.scrollTop = element.scrollHeight - element.clientHeight - distance), growsBeforeDrag ? 1200 : handoff.key === "latest" ? 800 : 80, ) await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1) const latest = page.getByRole("button", { name: "Jump to latest", exact: true }) if (handoff.key === "latest") await expect(latest.locator("..")).toHaveCSS("opacity", "1") const row = scroller.locator("[data-timeline-key]", { has: page.locator(`[data-timeline-part-id="${renderedPartID("prt_handoff_shell")}"]`), }) const height = await row.evaluate((element) => element.getBoundingClientRect().height) const extent = await scroller.evaluate((element) => element.scrollHeight) const bounds = (await scroller.boundingBox())! const devtools = await page.context().newCDPSession(page) if (handoff.key.startsWith("Control+")) await scroller.evaluate((element) => { document.addEventListener("keydown", function observe(event) { if (!event.ctrlKey || !["Home", "End"].includes(event.key)) return element.dataset.nativeScrollPrevented = String(event.defaultPrevented) document.removeEventListener("keydown", observe) }) }) await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: bounds.x + 100, y: bounds.y + 200 }], }) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: bounds.x + 100, y: bounds.y + 230 }], }) await fixture.send( partUpdated( shell( "prt_handoff_shell", "running", Array.from({ length: 10 }, (_, index) => `Shell A line ${index}.`).join("\n\n"), ), ), ) await expect .poll(() => row.evaluate((element) => element.getBoundingClientRect().height)) .toBeGreaterThan(height) // The row grew, but its native scroll extent is still translated: the // new navigation must take ownership before the idle reconciliation. await expect.poll(() => scroller.evaluate((element) => element.scrollHeight)).toBe(extent) if (growsBeforeDrag) { image.resolve() const diagram = page.getByAltText("Earlier diagram", { exact: true }) await expect(diagram).toHaveJSProperty("naturalHeight", 4000) await expect .poll(() => scroller .locator("[data-timeline-key]", { has: diagram }) .evaluate((element) => element.getBoundingClientRect().height), ) .toBeGreaterThan(4000) await expect.poll(() => scroller.evaluate((element) => element.scrollHeight)).toBe(extent) } const thumb = usesScrollbar ? page.locator(thumbSelector) : undefined await thumb?.hover() const grip = await thumb?.boundingBox() const touchTop = thumb ? await page .getByText("Reading 50.", { exact: true }) .evaluate((element) => element.getBoundingClientRect().top) : undefined await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: bounds.x + 100, y: bounds.y + 260 }], }) if (touchTop !== undefined) await expect .poll(() => page.getByText("Reading 50.", { exact: true }).evaluate((element) => element.getBoundingClientRect().top), ) .toBeCloseTo(touchTop + 30, 0) if (!handoff.held) await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }) if (handoff.key === "latest") { await latest.click() // The hidden button no longer covers the timeline, so a resting pointer would hover it and keep the thumb shown. await page.mouse.move(0, 0) } if (handoff.key !== "latest" && !usesScrollbar) await scroller.press(handoff.key) if (handoff.key.startsWith("Control+")) await expect(scroller).toHaveAttribute("data-native-scroll-prevented", "false") if (usesScrollbar) { expect(grip).toBeTruthy() if (!grip) return const anchor = page.getByText("Reading 50.", { exact: true }) const before = await anchor.evaluate((element) => element.getBoundingClientRect().top) await page.mouse.down() expect(await anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0) await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 - 4) await expect .poll(() => anchor.evaluate((element) => element.getBoundingClientRect().top)) .toBeGreaterThan(before) expect((await anchor.evaluate((element) => element.getBoundingClientRect().top)) - before).toBeLessThan(60) if (growsDuringDrag) { const prefix = scroller.locator("[data-timeline-key]", { has: page.locator(`[data-timeline-part-id="${renderedPartID("prt_handoff_prefix")}"]`), }) const prefixHeight = await prefix.evaluate((element) => element.getBoundingClientRect().height) const extent = await scroller.evaluate((element) => element.scrollHeight) // More content can arrive after the thumb has already captured the pointer. image.resolve() await expect(page.getByAltText("Earlier diagram", { exact: true })).toHaveJSProperty("naturalHeight", 300) await expect .poll(() => prefix.evaluate((element) => element.getBoundingClientRect().height)) .toBeGreaterThan(prefixHeight) await expect.poll(() => scroller.evaluate((element) => element.scrollHeight)).toBe(extent) } await page.mouse.move(grip.x + grip.width / 2, bounds.y + 5) await page.mouse.up() await page.mouse.move(0, 0) } const toStart = handoff.key.endsWith("Home") || usesScrollbar if (toStart) await expect(first).toBeInViewport() if (!toStart) await expect(page.getByText("Reading 59.", { exact: true })).toBeInViewport() await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1) if (handoff.held) await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }) if (toStart) expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(start, 0) await testInfo.attach("touch-navigation-handoff.png", { body: await page.screenshot(), contentType: "image/png", }) }) } for (const detach of ["scroll", "stream"] as const) { for (const release of ["touchEnd", "touchCancel"] as const) { const cause = detach === "scroll" ? "scrolling the touch target out of view" : "streaming replaces the touch target" test(`returns Home after ${cause} (${release})`, async ({ page }, testInfo) => { const image = Promise.withResolvers() const url = new URL(`/${detach}-target-image.svg`, testInfo.project.use.baseURL).href await page.route(url, async (route) => { await image.promise await route.fulfill({ contentType: "image/svg+xml", body: '', }) }) const fixture = await setupTimeline(page, { messages: [ userMessage(), assistantMessage( detach === "scroll" ? Array.from({ length: 80 }, (_, index) => textPart( `prt_lifecycle_${index}`, `Part ${index}.${index === 46 ? `\n\n![Delayed image](${url})` : ""}`, ), ) : [ textPart( "prt_target_prefix", Array.from({ length: 60 }, (_, index) => `Prefix ${index}.`).join("\n\n"), ), textPart("prt_target_image", `Earlier image.\n\n![Delayed image](${url})`), textPart( "prt_target_reading", Array.from({ length: 25 }, (_, index) => `Reading ${index}.`).join("\n\n"), ), textPart("prt_target_heading", "Heading text"), ], { completed: false }, ), ], viewport: { width: 390, height: 844 }, }) const timeline = page.locator('[data-slot="session-timeline-scroll"]') const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true }) const hidden = page.locator(`${thumbSelector}[data-visible="false"]`) const heading = page.locator(`[data-timeline-part-id="${renderedPartID("prt_target_heading")}"]`) const last = detach === "scroll" ? page.getByText("Part 79.", { exact: true }) : heading await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible() await expect(last).toBeInViewport() await page.evaluate(() => document.fonts.ready) await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0) await scroller.evaluate((element) => { element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1 })) element.scrollTop = 0 }) const first = scroller.locator('[data-timeline-row="UserMessage"]') await expect(first).toBeInViewport() const start = await first.evaluate((element) => element.getBoundingClientRect().top) // Measure the history before exercising a gesture across the virtual window. if (detach === "scroll") for (let index = 0; index < 10; index++) { await scroller.evaluate((element) => (element.scrollTop += 300)) await page.screenshot() await expect(hidden).toHaveCount(1) } if (detach === "stream") await expect(hidden).toHaveCount(1) await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) await expect(last).toBeInViewport() await expect(hidden).toHaveCount(1) const devtools = await page.context().newCDPSession(page) if (detach === "scroll") { const bounds = (await scroller.boundingBox())! // The timeline scrolls on under the composer, and rows behind it stay mounted, so the drag must carry the // touched row past the bottom of the screen before virtualization removes it. const touched = page.getByText("Part 68.", { exact: true }) await expect(touched).toBeInViewport() const box = (await touched.boundingBox())! const point = { x: bounds.x + 100, y: box.y + box.height / 2 } await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point], timestamp: restingTimestamp(), }) for (let step = 1; step <= 23; step++) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: point.x, y: point.y + step * 30 }], timestamp: restingTimestamp(), }) await expect(touched).not.toBeInViewport() await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [], timestamp: Date.now() / 1000, }) await expect(hidden).toHaveCount(1) await expect(touched).toHaveCount(0) } if (detach === "stream") { const bounds = (await heading.getByText("Heading text", { exact: true }).boundingBox())! const point = { x: bounds.x + 25, y: bounds.y + bounds.height / 2 } const target = await page.evaluateHandle((point) => document.elementFromPoint(point.x, point.y), point) await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] }) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: point.x, y: point.y + 60 }], }) await expect(page.locator(`${thumbSelector}[data-visible="true"]`)).toHaveCount(1) await fixture.send(partDelta("prt_target_heading", "\n\nMore content.")) await expect(heading).toContainText("More content.") await expect(heading.locator("p")).toHaveCount(2) await expect.poll(() => target.evaluate((element) => element?.isConnected)).toBe(false) await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] }) await target.dispose() await expect(hidden).toHaveCount(1) } const anchor = page.getByText(detach === "scroll" ? "Part 48." : "Reading 20.", { exact: true }) await expect(anchor).toBeInViewport() const before = await anchor.evaluate((element) => element.getBoundingClientRect().top) image.resolve() const loaded = page.getByAltText("Delayed image", { exact: true }) await expect(loaded).toHaveJSProperty("naturalHeight", 300) await expect .poll(() => scroller .locator("[data-timeline-key]", { has: loaded }) .evaluate((element) => element.getBoundingClientRect().height), ) .toBeGreaterThan(300) await page.screenshot() expect(await anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0) await scroller.press("Home") await expect(first).toBeInViewport() await expect(hidden).toHaveCount(1) expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(start, 0) await testInfo.attach("home-after-touch.png", { body: await page.screenshot(), contentType: "image/png" }) }) } } test("keeps the visible row anchored through reflow and touch cancellation", async ({ page }, testInfo) => { await setupTimeline(page, { messages: [ userMessage(), assistantMessage( Array.from({ length: 40 }, (_, index) => textPart(`prt_reflow_${index}`, `Section ${index}. ${"Adjacent part content. ".repeat(16)}`), ), ), ], viewport: { width: 700, height: 844 }, }) const timeline = page.locator('[data-slot="session-timeline-scroll"]') const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true }) await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible() const tail = page.getByText("Section 39.", { exact: false }) await expect(tail).toBeInViewport() await page.evaluate(() => document.fonts.ready) await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0) const bounds = (await scroller.boundingBox())! const before = await tail.evaluate((element) => element.getBoundingClientRect().top) const devtools = await page.context().newCDPSession(page) await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: bounds.x + 130, y: bounds.y + 100 }], }) await devtools.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: bounds.x + 130, y: bounds.y + 200 }], }) await expect .poll(() => tail.evaluate((element) => element.getBoundingClientRect().top)) .toBeGreaterThan(before + 50) await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1) const chosen = await scroller.evaluate((root) => { const view = root.getBoundingClientRect() const rows = [...root.querySelectorAll("[data-timeline-key]")] const above = rows.filter((row) => row.getBoundingClientRect().bottom <= view.top).slice(-2) const first = rows.find((row) => row.getBoundingClientRect().bottom > view.top)! return { above: above.map((row) => ({ key: row.dataset.timelineKey, height: row.getBoundingClientRect().height })), key: first.dataset.timelineKey, top: first.getBoundingClientRect().top, } }) expect(chosen.above).toHaveLength(2) await page.setViewportSize({ width: 390, height: 844 }) for (const row of chosen.above) await expect .poll(async () => (await scroller.locator(`[data-timeline-key="${row.key}"]`).boundingBox())?.height) .toBeGreaterThan(row.height) const anchor = scroller.locator(`[data-timeline-key="${chosen.key}"]`) await testInfo.attach("reflow-held.png", { body: await page.screenshot(), contentType: "image/png" }) expect((await anchor.boundingBox())?.y).toBeCloseTo(chosen.top, 0) await devtools.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [] }) await testInfo.attach("reflow-cancelled.png", { body: await page.screenshot(), contentType: "image/png" }) expect((await anchor.boundingBox())?.y).toBeCloseTo(chosen.top, 0) }) }) } test("painted motion rejects a prompt whose pixels disappear", async ({ page }) => { await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart("prt_pixel_control", "Content.")])], viewport: { width: 390, height: 844 }, }) const timeline = page.locator('[data-slot="session-timeline-scroll"]') await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible() await page.evaluate(() => document.fonts.ready) const prompt = timeline.locator('[data-timeline-row="UserMessage"]') await expect(prompt).toBeInViewport() await markPrompt(page) const view = (await timeline.getByRole("region", { name: "scrollable content", exact: true }).boundingBox())! const frames = [(await page.screenshot({ type: "jpeg" })).toString("base64")] // Negative control: a captured disappearance must fail even if the layout recovers. await prompt.evaluate((element) => (element.style.transform = "translateY(-500px)")) frames.push((await page.screenshot({ type: "jpeg" })).toString("base64")) await prompt.evaluate((element) => element.style.removeProperty("transform")) frames.push((await page.screenshot({ type: "jpeg" })).toString("base64")) const positions = await readPromptPositions(page, frames, view) expect(positions.map((position) => position.top !== null)).toEqual([true, false, true]) expect(promptMotionIssues(positions)).toEqual([{ frame: 1, reason: "missing" }]) }) async function readFrom(page: Page, text: string) { const timeline = page.locator('[data-slot="session-timeline-scroll"]') const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true }) await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible() await page.evaluate(() => document.fonts.ready) await scroller.evaluate((element) => { element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1 })) element.scrollTop = 0 }) const first = scroller.locator('[data-timeline-row="UserMessage"]') await expect(first).toBeInViewport() const start = await first.evaluate((element) => element.getBoundingClientRect().top) const anchor = scroller.getByText(text, { exact: true }) await expect(anchor).toBeAttached() await expect(scroller.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0) await anchor.evaluate((element) => { const root = element.closest("[data-scrollable]")! root.scrollTop += element.getBoundingClientRect().top - root.getBoundingClientRect().top - 8 }) await expect(anchor).toBeInViewport() return { timeline, scroller, anchor, start } } // Chromium flings a touchEnd that lands within 80 ms of a touchMove made within 40 ms of the previous one // (ui/events/velocity_tracker), and CDP stamps an unstamped event when it arrives. A drag released as soon // as a quick check passed then carried momentum past the position it asserts. Stamping the drag's // touchStart and touchMoves 100 ms early, and its release from the same clock, makes the finger always // rest at least 100 ms before it lifts. function restingTimestamp() { return (Date.now() - 100) / 1000 }