test(app): fix races in the mobile timeline scroll e2e tests (#55881)

This commit is contained in:
Filip 2026-10-18 00:06:23 +02:00 • committed by GitHub
parent 579b86fc80
commit cbbfd22333
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 12 deletions

View file

@ -117,6 +117,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
await devtools.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 }],
timestamp: restingTimestamp(),
})
image.resolve()
@ -134,13 +135,18 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
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: [] })
await devtools.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
timestamp: Date.now() / 1000,
})
await expect(reading.timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(
before + 615,
@ -252,11 +258,18 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
if (nestedStart) {
await page.evaluate(() => document.fonts.ready)
const position = await tail.evaluate((element) => element.getBoundingClientRect().top)
await nested.evaluate((element) => (element.scrollTop = 500))
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
.poll(() => nested.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThan(1)
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)
@ -705,15 +718,21 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
await devtools.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: bounds.x + 100, y: bounds.y + 70 }],
timestamp: restingTimestamp(),
})
for (let step = 1; step <= 23; step++)
await devtools.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: bounds.x + 100, y: bounds.y + 70 + step * 30 }],
timestamp: restingTimestamp(),
})
await expect(touched).not.toBeInViewport()
await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] })
await devtools.send("Input.dispatchTouchEvent", {
type: release,
touchPoints: [],
timestamp: Date.now() / 1000,
})
await expect(hidden).toHaveCount(1)
await expect(touched).toHaveCount(0)
}
@ -871,3 +890,12 @@ async function readFrom(page: Page, text: string) {
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
}

View file

@ -5,11 +5,11 @@ export type PromptPosition = { frame: number; top: number | null }
// Records painted compositor frames; the returned stop function reads the prompt position in each frame.
export async function capturePromptMotion(page: Page, view: { x: number; y: number; width: number }) {
const session = await page.context().newCDPSession(page)
const frames: string[] = []
const frames: { data: string; timestamp?: number }[] = []
const acknowledgements: Promise<unknown>[] = []
const onFrame = (frame: { data: string; sessionId: number }) => {
frames.push(frame.data)
const onFrame = (frame: { data: string; sessionId: number; metadata: { timestamp?: number } }) => {
frames.push({ data: frame.data, timestamp: frame.metadata.timestamp })
acknowledgements.push(session.send("Page.screencastFrameAck", { sessionId: frame.sessionId }))
}
@ -29,7 +29,17 @@ export async function capturePromptMotion(page: Page, view: { x: number; y: numb
await Promise.all(acknowledgements)
await session.detach()
return { positions: await readPromptPositions(page, frames, view), frames }
// Chromium stamps each frame when it is captured, then JPEG-encodes up to three at once on unsequenced
// thread-pool tasks and emits each one when its encode finishes, so a slow encode arrives after newer frames.
const stamped = frames.filter(
(frame): frame is { data: string; timestamp: number } => frame.timestamp !== undefined,
)
if (stamped.length !== frames.length) throw new Error("Screencast frame without a timestamp")
const ordered = stamped.toSorted((a, b) => a.timestamp - b.timestamp).map((frame) => frame.data)
return { positions: await readPromptPositions(page, ordered, view), frames: ordered }
}
}
@ -41,10 +51,10 @@ export async function readPromptPositions(
): Promise<PromptPosition[]> {
return page.evaluate(
async ({ frames, view, viewport }) => {
const positions: { frame: number; top: number | null }[] = []
const positions: PromptPosition[] = []
for (const [frame, encoded] of frames.entries()) {
const position = { frame, top: null as number | null }
const position: PromptPosition = { frame, top: null }
const image = await createImageBitmap(
new Blob([Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))], { type: "image/jpeg" }),