Compare commits

...
Author SHA1 Message Date
Filip Hejmowski a4ec105c3a test(app): fix races in the mobile timeline scroll e2e tests
Four tests in mobile-timeline-scroll.spec.ts failed intermittently in CI
for three test-side reasons. None is a product bug, and no timeout, retry
or assertion changed.

Release flings (:79 on Linux, :630 touchEnd). CDP stamps an unstamped
touch event when it arrives. Chromium's velocity tracker flings a touchEnd
that lands within 80 ms of a touchMove made within 40 ms of the previous
one. When the check before the release passed on its first try, the drag
carried momentum. The :79 anchor ended 108-717 px past its expected
position. The :630 viewport travelled another ~158 px, bringing the
delayed image's row into view, so its growth pushed the anchor down by
300 px. Both drags now stamp touchStart and touchMoves 100 ms early and
the release from the same clock, so the release always follows a rest.

Dropped Control+Home (:225 (500), Linux). Control+End is a smooth native
scroll that reaches its end offset a frame before it finishes. Chromium
drops a keyboard scroll that arrives in that gap, so scrollTop stayed at
2229. The test now waits for the nested output's scrollend at its end
before pressing Control+Home.

Reordered screencast frames (:151, Windows). Chromium JPEG-encodes up to
three screencast frames at once and emits each one when its encode
finishes, so a slow encode arrives after newer frames. That read as a
reversed or missing prompt. capturePromptMotion now sorts frames by
their capture timestamp before reading motion.
2026-10-16 01:04:42 +02:00
2 changed files with 50 additions and 12 deletions

No files matched your search

@@ -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
}
+16 -6
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" }),