diff --git a/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts b/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts index b83a499d5116..83707b3a1fa8 100644 --- a/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts +++ b/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts @@ -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 +} diff --git a/packages/app/e2e/utils/prompt-motion.ts b/packages/app/e2e/utils/prompt-motion.ts index d6e3c08312de..d9baf53e39f6 100644 --- a/packages/app/e2e/utils/prompt-motion.ts +++ b/packages/app/e2e/utils/prompt-motion.ts @@ -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[] = [] - 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 { 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" }),