Files
opencode/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts

914 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>()
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: '<svg xmlns="http://www.w3.org/2000/svg" width="340" height="600"><rect width="340" height="600" fill="steelblue"/></svg>',
})
})
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<void>()
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: `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="${growsBeforeDrag ? 4000 : 300}"><rect width="300" height="${growsBeforeDrag ? 4000 : 300}" fill="steelblue"/></svg>`,
})
})
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<void>()
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: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><rect width="300" height="300" fill="steelblue"/></svg>',
})
})
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<HTMLElement>("[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
}