From 9650421a9b309bbc37e2ef13d3395625583bb37f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 15:33:41 -0800 Subject: [PATCH 1/2] test(interaction-harness): name a double tap the page read as two taps A double tap's second touch-down must reach the page within UIKit's maximumIntervalBetweenTaps (350 ms) of the first lift. The gesture arena times that window from the lift's stamp on the real clock. On a starved runner a play can send the second touch-down late. In Chromium the page then sees the scripted stamps, but the window's timer fires before the touch arrives. Lightbox's double-tap busy proof saw a second touch-down arrive 419 ms after the lift's stamp, where the script spaces them 120 ms. The page read two single taps, no zoom started, and the proof failed with a bare "no animation started". A play now takes boundaries.tapIntervalMs, the component's interval between taps. Every driver judges it. A tap play reads, on the page's clock, each touch's stamp, when the page received it, and when the page drew each frame. In Chromium the page sees the scripted stamps, so the play arms a page-side record of its touch events and frames. In WebKit the arrival stamps and the probe's frames serve. The window closes at the lift's stamp plus the interval. tapIntervalFault names a touch-down received after the close only where the page drew a frame between the close and the touch-down: the main thread was free first, so the component's timer ran first. Without such a frame the component can still pair the taps, so it decides. A gap the script itself spaces past the interval is no fault. The play then throws TouchTapIntervalError with both spacings and the frame. Lightbox's double-tap plays pass UIKit's interval. Under a heavier load than CI's (two pinned CPUs, six workers, four busy loops), all 37 of 57 double taps with a frame after the close failed to zoom, and all 20 others zoomed, two of them received after the close with no frame between. Under CI's profile (one worker), all 120 double taps zoomed, and none was named. touch.taps.test.ts covers the frame gate, a main thread held across the close, arrival stamps, taps the script spaces apart, and a lift that leaves a finger down. A new self-test stalls the runner 450 ms after the first lift. On main the play passes, though the page read two taps; with the change it throws TouchTapIntervalError, naming the frame. --- internal/interaction-harness/README.md | 6 + .../src/harness.interaction.browser.spec.ts | 40 +++++ .../src/touch.taps.test.ts | 121 +++++++++++++ internal/interaction-harness/src/touch.ts | 169 +++++++++++++++++- ...LightboxPaging.interaction.browser.spec.ts | 10 +- .../LightboxZoom.interaction.browser.spec.ts | 37 ++-- ...htboxZoomModes.interaction.browser.spec.ts | 12 +- 7 files changed, 377 insertions(+), 18 deletions(-) create mode 100644 internal/interaction-harness/src/touch.taps.test.ts diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index db603cf2f7054..a331acb26bfe5 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -686,6 +686,12 @@ the release speed a pager pages from (`releasePxPerS`), or the hold after which it catches the content (`holdMs`). In WebKit the play then fails by name when the page read the release, or the first finger's hold from touch-down to lift, on the other side of the threshold from the script. A +multi-tap gesture names the component's interval between taps +(`tapIntervalMs`, UIKit's `maximumIntervalBetweenTaps`). The window closes that +long after the lift's stamp. In every engine, a touch-down can reach the page +after the close, where the script taps within the window. If the page drew a +frame in between, the play fails with `TouchTapIntervalError`: the page read +separate taps. Without such a frame, the component decides. A scrolling surface reads its release as Core's `scrollVelocity` does: pass `release: 'scroll'`. Each pacing error names the longest time the page drew no frame during the gesture. A page that drew nothing for 100 ms or more never diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index f97f277ddeaf1..c28926946b13f 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -139,6 +139,7 @@ import { withFinger, writeEvidence, } from './index'; +import type {TapIntervalFault} from './touch'; // An element positioned against an anchor: a composited animation of it lays // out in every main-thread frame, so it shows any per-frame main-thread work. @@ -560,6 +561,45 @@ test.describe('touch input', () => { ); }); + test('a double tap whose second touch-down reaches the page past the tap interval, after a frame, fails the play by name', async ({ + page, + }) => { + // The driver comes first, so its mark does too. + const driver = await touchDriver(page); + await fixture(page); + const at = {x: 160, y: 150}; + const doubleTap = [ + {t: 0, fingers: [{id: 0, ...at}]}, + {t: 50, fingers: []}, + {t: 170, fingers: [{id: 0, ...at}]}, + {t: 220, fingers: []}, + ]; + const boundaries = {tapIntervalMs: 350}; + // On time, the second tap lands 120 ms after the lift. + await driver.play(doubleTap, {boundaries}); + // The runner stalls after the lift, as a starved one does. So the second + // touch-down leaves 450 ms later, and the page reads two taps. + const stall = async (_step: unknown, index: number) => { + if (index === 1) { + await new Promise(resolve => setTimeout(resolve, 450)); + } + }; + const late = await driver + .play(doubleTap, {boundaries, onEvent: stall}) + .then( + () => null, + (error: unknown) => error, + ); + expect((late as Error | null)?.name).toBe('TouchTapIntervalError'); + const {fault} = late as Error & {fault: TapIntervalFault}; + expect(fault.step).toBe(2); + expect(fault.scriptedGapMs).toBe(120); + expect(fault.gapMs).toBeGreaterThan(350); + expect((late as Error).message).toContain('The page drew a frame'); + // Without the boundary, the play does not judge its taps. + await driver.play(doubleTap, {onEvent: stall}); + }); + test("a page's own window capture listener counts as the page's listeners when the driver is created before the page loads", async ({ page, browserName, diff --git a/internal/interaction-harness/src/touch.taps.test.ts b/internal/interaction-harness/src/touch.taps.test.ts new file mode 100644 index 0000000000000..0e3e40884633d --- /dev/null +++ b/internal/interaction-harness/src/touch.taps.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touch.taps.test.ts + * @input touch.ts's tap-interval rule (`tapIntervalFault`, a play's + * `boundaries.tapIntervalMs`) + * @output Unit cases: a second touch-down received after the tap interval + * closed is a fault only where the page drew a frame in between. A main + * thread held across the close leaves the pairing to the component. Neither are taps the script spaces past the + * interval, nor a lift that leaves a finger down, nor a play without the + * page's times. + * @position Node unit tests beside the harness's browser self-tests. + * + * SYNC: When modified, update touch.ts (tapIntervalFault). + */ + +import {describe, expect, it} from 'vitest'; +import {toTouchEvents, type TouchScript} from './scripts'; +import {tapIntervalFault} from './touch'; + +const at = {x: 100, y: 200}; +const doubleTap: TouchScript = [ + {t: 0, fingers: [{id: 0, ...at}]}, + {t: 50, fingers: []}, + {t: 170, fingers: [{id: 0, ...at}]}, + {t: 220, fingers: []}, +]; +// A frame every 16 ms from 0 to 800 ms. +const everyFrame = Array.from({length: 51}, (_, i) => i * 16); + +describe('tapIntervalFault', () => { + it('names a second touch-down that arrived after the interval closed, with the frame between', () => { + const steps = toTouchEvents(doubleTap); + // The page sees the scripted stamps; the touch-down arrives at 525 ms. + const page = { + stamps: [0, 50, 170, 220], + receivedAt: [5, 60, 525, 540], + frames: everyFrame, + }; + // The interval closes at 400 ms; the page draws from 416 to 512 ms. + expect(tapIntervalFault(steps, page, 350)).toEqual({ + step: 2, + gapMs: 475, + scriptedGapMs: 120, + tapIntervalMs: 350, + frameAfterCloseMs: 16, + framesAfterClose: 7, + }); + }); + + it('leaves the pairing to the component when the main thread was held across the close', () => { + const steps = toTouchEvents(doubleTap); + // No frame from 384 ms until after the touch-down arrived at 525 ms. + const page = { + stamps: [0, 50, 170, 220], + receivedAt: [5, 60, 525, 540], + frames: everyFrame.filter(time => time <= 384 || time >= 528), + }; + expect(tapIntervalFault(steps, page, 350)).toBeNull(); + }); + + it('finds none when the touch-down arrives before the interval closes', () => { + const steps = toTouchEvents(doubleTap); + const page = { + stamps: [0, 50, 170, 220], + receivedAt: [5, 60, 380, 400], + frames: everyFrame, + }; + expect(tapIntervalFault(steps, page, 350)).toBeNull(); + }); + + it('reads arrival stamps where the page stamps touches on arrival', () => { + const steps = toTouchEvents(doubleTap); + const stamps = [0, 50, 470, 520]; + expect( + tapIntervalFault( + steps, + {stamps, receivedAt: stamps, frames: everyFrame}, + 350, + )?.gapMs, + ).toBe(420); + }); + + it('leaves taps the script spaces past the interval to the script', () => { + const apart: TouchScript = [ + {t: 0, fingers: [{id: 0, ...at}]}, + {t: 50, fingers: []}, + {t: 600, fingers: [{id: 0, ...at}]}, + {t: 650, fingers: []}, + ]; + const page = { + stamps: [0, 50, 600, 650], + receivedAt: [5, 60, 700, 720], + frames: everyFrame, + }; + expect(tapIntervalFault(toTouchEvents(apart), page, 350)).toBeNull(); + }); + + it('ends a tap only where its last finger lifts', () => { + // The first finger lifts and lands again while the second stays down: + // one touch, so the late landing is no second tap. + const second = {id: 1, x: 150, y: 200}; + const overlapping: TouchScript = [ + {t: 0, fingers: [{id: 0, ...at}]}, + {t: 50, fingers: [{id: 0, ...at}, second]}, + {t: 100, fingers: [second]}, + {t: 150, fingers: [second, {id: 0, ...at}]}, + {t: 200, fingers: []}, + ]; + const page = { + stamps: [0, 50, 100, 150, 200], + receivedAt: [5, 55, 105, 700, 720], + frames: everyFrame, + }; + expect(tapIntervalFault(toTouchEvents(overlapping), page, 350)).toBeNull(); + }); + + it("proves nothing without the page's times", () => { + expect(tapIntervalFault(toTouchEvents(doubleTap), null, 350)).toBeNull(); + }); +}); diff --git a/internal/interaction-harness/src/touch.ts b/internal/interaction-harness/src/touch.ts index 064a8ee10360d..c44b3b5521c52 100644 --- a/internal/interaction-harness/src/touch.ts +++ b/internal/interaction-harness/src/touch.ts @@ -172,10 +172,19 @@ export interface PlayOptions { * lift fails the same way. Then the proof never judges a gesture the page * did not receive. Only a driver whose page stamps touches on arrival * (WebKit) judges them. + * + * `tapIntervalMs` is the longest the component waits from a lift for the + * next tap, as UIKit's `maximumIntervalBetweenTaps`. Every driver judges it. + * A touch-down can reach the page after that window closes, where the + * script taps within it. If the page drew a frame in between, the play + * fails with `TouchTapIntervalError`: the page read separate taps. Without + * such a frame, the component decides, so a play that passes does not prove + * the page paired the taps. */ readonly boundaries?: { readonly releasePxPerS?: number; readonly holdMs?: number; + readonly tapIntervalMs?: number; }; /** * Called after each event is dispatched, before the next one (storyboards). @@ -897,6 +906,109 @@ export class TouchStarvationError extends Error { } } +/** + * Where a multi-tap play's touch-down reached the page past the component's + * tap interval (`tapIntervalFault`). + */ +export interface TapIntervalFault { + /** The touch-down's index among the play's touch events. */ + readonly step: number; + /** The gap from the lift before it, as the page received it, ms. */ + readonly gapMs: number; + /** The same gap as the script spaces it, ms. */ + readonly scriptedGapMs: number; + /** The component's tap interval, ms. */ + readonly tapIntervalMs: number; + /** + * When the page drew its first frame after the interval closed and before + * the touch-down, ms after the close. + */ + readonly frameAfterCloseMs: number; + /** How many frames the page drew between the close and the touch-down. */ + readonly framesAfterClose: number; +} + +/** + * A multi-tap play's touch-down reached the page past the component's tap + * interval after the lift before it, where the script taps within it. So the + * page read separate taps, not the gesture the script makes: a dispatch + * stalled before the page, as on a starved runner. Always a red, never a + * pass: the proof judges no gesture the page did not receive. + */ +export class TouchTapIntervalError extends Error { + constructor(readonly fault: TapIntervalFault) { + super( + `touchDriver: the touch-down at step ${fault.step} reached the page ${fault.gapMs.toFixed(0)} ms after the lift before it, past the ${fault.tapIntervalMs} ms the component waits for the next tap. The page drew a frame ${fault.frameAfterCloseMs.toFixed(0)} ms after that window closed, before the touch-down (${fault.framesAfterClose} in all). The script spaces them ${fault.scriptedGapMs.toFixed(0)} ms. So the page read separate taps: a dispatch stalled before the page, as on a starved runner.`, + ); + this.name = 'TouchTapIntervalError'; + } +} + +/** + * For each touch event of a play, its stamp and when the page received it. + * Also when the page drew each frame meanwhile. All are on the page's clock. + * Where the page stamps touches on arrival (WebKit), the stamp and the + * receipt are the same. + */ +export interface TouchPageTimes { + readonly stamps: readonly number[]; + readonly receivedAt: readonly number[]; + readonly frames: readonly number[]; +} + +/** + * The first touch-down of a play that reached the page after the component's + * tap interval had closed; null when none did. The interval runs from the + * lift's stamp. It counts as closed first only where the page drew a frame + * after it closed and before the touch-down arrived. Then the main thread was + * free before the touch came, so the component's timer ran first. Without + * such a frame, the component can still pair the taps, so it decides. A gap + * the script itself spaces past the interval is no fault. Only a lift that + * leaves no finger down ends a tap. Without the page's times, nothing proves + * the window closed first. + */ +export function tapIntervalFault( + steps: readonly TouchEventStep[], + page: TouchPageTimes | null, + tapIntervalMs: number, +): TapIntervalFault | null { + if ( + page == null || + page.stamps.length !== steps.length || + page.receivedAt.length !== steps.length + ) { + return null; + } + let lift: number | null = null; + for (const [index, step] of steps.entries()) { + if (step.type === 'touchEnd' && step.fingers.length === 0) { + lift = index; + continue; + } + if (step.type !== 'touchStart' || lift == null) { + continue; + } + const scriptedGapMs = step.t - steps[lift].t; + const closesAt = page.stamps[lift] + tapIntervalMs; + const receivedAt = page.receivedAt[index]; + const drawn = page.frames.filter( + time => time > closesAt && time < receivedAt, + ); + if (scriptedGapMs <= tapIntervalMs && drawn.length > 0) { + return { + step: index, + gapMs: receivedAt - page.stamps[lift], + scriptedGapMs, + tapIntervalMs, + frameAfterCloseMs: drawn[0] - closesAt, + framesAfterClose: drawn.length, + }; + } + lift = null; + } + return null; +} + /** * A play's lift reached the page more than the stillness interval after its * finger's last move, where the script throws. The scripted gap plus the time @@ -1057,6 +1169,7 @@ async function playSteps( dispatch: (step: TouchEventStep, timestamp: number) => Promise, stampFor: (t: number) => number, probe: TouchArrivalProbe | null, + readTapTimes: (() => Promise) | null = null, ): Promise { const { realtime = true, @@ -1072,11 +1185,13 @@ async function playSteps( // timestamps carry it. Every real-time play is judged there, unless its // proof reads no release. const judges = probe != null && realtime && !releaseUnread; + // A tap play reads the page's arrivals and frames too. + const probed = judges || (probe != null && boundaries?.tapIntervalMs != null); // A driver gives a probe only where the page stamps touches on arrival. // There, a play behind its schedule never shrinks a scripted gap, so page // work in `onEvent` cannot eat the rest before a lift. const keepsGaps = probe != null; - if (judges) { + if (probed) { await probe.arm(); } const played: PlayedEvent[] = []; @@ -1108,8 +1223,23 @@ async function playSteps( onEventMs.push(performance.now() - watched); } // Judged once the lift is out, so no finger is left down. - if (judges) { - const {arrivals, frames} = await probe.read(); + const probedTimes = probed ? await probe.read() : null; + const read = judges ? probedTimes : null; + if (boundaries?.tapIntervalMs != null) { + const stamps = probedTimes?.arrivals.map(arrival => arrival.stamp); + const split = tapIntervalFault( + steps, + stamps == null || probedTimes == null + ? ((await readTapTimes?.()) ?? null) + : {stamps, receivedAt: stamps, frames: probedTimes.frames}, + boundaries.tapIntervalMs, + ); + if (split != null) { + throw new TouchTapIntervalError(split); + } + } + if (read != null) { + const {arrivals, frames} = read; // A page that lost events, such as a target removed mid-gesture, gives no // release to read. Only a `'spacing'` play, which asks for the // judgment, fails then. @@ -1173,6 +1303,24 @@ async function chromiumDriver(page: Page): Promise { press: (at, options) => held.press(at, options), async play(script, options = {}) { assertNoneHeld(held); + // A tap play reads when the page received each touch and drew each + // frame. The page sees the scripted stamps, so only its own listeners + // tell a late touch-down. + const taps = + options.boundaries?.tapIntervalMs == null + ? null + : await armInputRun(page, CHROMIUM_TOUCH_TYPES, {frames: true}); + const readTapTimes = async (): Promise => + taps == null + ? null + : taps.evaluate(run => { + run.stop(); + return { + stamps: run.stamps.slice(), + receivedAt: run.receivedAt.slice(), + frames: run.frames.slice(), + }; + }); // Stamps follow the wall clock the page's own clock is on: with real-time // pacing an event's stamp and its dispatch agree to ~0.1 ms. const base = Date.now(); @@ -1197,7 +1345,12 @@ async function chromiumDriver(page: Page): Promise { }, t => base + t, null, - ); + readTapTimes, + ).finally(async () => { + // Stopped again, as a play that threw before its read never stopped it. + await taps?.evaluate(run => run.stop()).catch(() => undefined); + await taps?.dispose(); + }); }, async tap(at) { assertNoneHeld(held); @@ -1231,6 +1384,14 @@ export function webkitPageSession(page: Page): WebKitPageProxySession { return session; } +// The touch events alone, one for each step of a play in Chromium. +const CHROMIUM_TOUCH_TYPES = [ + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel', +]; + /** * The events a paced play's probe records: a gesture host samples a finger * from its pointer events, and a page can handle either kind. diff --git a/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts index c509405fd0ff0..463126a386d0f 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts @@ -45,6 +45,7 @@ import { withStorybook, writeEvidence, } from '@astryxdesign/interaction-harness'; +import {UIKIT_TAP} from '../../motion/uikit/gestures'; import {slowViewer} from './viewerTempo'; const storybook = withStorybook(test); @@ -177,6 +178,12 @@ function doubleTap(at: {x: number; y: number}): TouchScript { ]; } +// A double tap whose second touch reaches the page past UIKit's interval +// between taps is two taps there: the play fails by name. +const DOUBLE_TAP = { + boundaries: {tapIntervalMs: UIKIT_TAP.maximumIntervalBetweenTaps.value}, +}; + /** A slow drag by dx, held still before the lift (no release velocity). */ function slowDrag(from: {x: number; y: number}, dx: number): TouchScript { return drag( @@ -247,7 +254,7 @@ test('a zoomed photo keeps the drag until its edge; the next drag from the edge const g = await openGallery(page); const centre = {x: g.box.x + g.box.width / 2, y: g.box.y + g.box.height / 2}; const current = g.viewport.locator('[data-current] img'); - await g.driver.play(doubleTap(centre)); + await g.driver.play(doubleTap(centre), DOUBLE_TAP); await waitForAnimation(current); await waitForRest(current); const zoomed = await g.photo(); @@ -1168,6 +1175,7 @@ test("two pages on, a rotation keeps a zoomed photo's centre point", async ({ x: fitted.x + fitted.width * 0.4, y: fitted.y + fitted.height / 2, }), + DOUBLE_TAP, ); await waitForAnimation(img); await waitForRest(img); diff --git a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts index 6fbff925b2b4d..5b6c9f2447716 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts @@ -72,6 +72,7 @@ import { toTouchEvents, type TouchScript, } from '@astryxdesign/interaction-harness'; +import {UIKIT_TAP} from '../../motion/uikit/gestures'; import {recordLiftOffset} from './liftOffset'; import {slowViewer} from './viewerTempo'; @@ -235,6 +236,12 @@ function doubleTap(at: {x: number; y: number}): TouchScript { ]; } +// A double tap whose second touch reaches the page past UIKit's interval +// between taps is two taps there: the play fails by name. +const DOUBLE_TAP = { + boundaries: {tapIntervalMs: UIKIT_TAP.maximumIntervalBetweenTaps.value}, +}; + const pageScale = async (page: Page) => page.evaluate(() => window.visualViewport?.scale ?? 1); @@ -335,6 +342,7 @@ for (const theme of SHIPPED_THEMES) { } await driver.play( doubleTap({x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}), + DOUBLE_TAP, ); await expect .poll(async () => @@ -697,7 +705,7 @@ test('a double tap zooms until the photo fills the screen, about the tapped poin const hold = await armHold(page, photo, { judges: {target: photo, property: 'scale'}, }); - await driver.play(doubleTap(at)); + await driver.play(doubleTap(at), DOUBLE_TAP); const held = await hold.held(); const duration = Math.max(...held.map(a => a.curveEndMs)); const samples = curveOf( @@ -734,7 +742,7 @@ test('a double tap zooms until the photo fills the screen, about the tapped poin expect(zoomed.fit.y + zoomed.y).toBeCloseTo(0, 0); await expect(stage).toHaveAttribute('aria-pressed', 'true'); // Again: back to the fitted size. - await driver.play(doubleTap({x: 300, y: at.y})); + await driver.play(doubleTap({x: 300, y: at.y}), DOUBLE_TAP); await waitForAnimation(photo); await waitForRest(photo); const fitted = await state(); @@ -753,6 +761,7 @@ test("a double tap near the photo's top edge stops at the screen's top edge", as x: before.stage.x + before.stage.width / 2, y: before.stage.y + before.fit.y + 10, }), + DOUBLE_TAP, ); await waitForAnimation(photo); await waitForRest(photo); @@ -766,7 +775,7 @@ test('a zoomed pan tracks the finger, rubber-bands past the edge and returns', a const {driver, photo, state} = await openViewer(page); const before = await state(); const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; - await driver.play(doubleTap(mid)); + await driver.play(doubleTap(mid), DOUBLE_TAP); await waitForAnimation(photo); await waitForRest(photo); const zoomed = await state(); @@ -805,7 +814,7 @@ test("a single zoomed photo's drag outward from the edge it rests at rubber-band const {driver, photo, state} = await openViewer(page); const before = await state(); const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; - await driver.play(doubleTap(mid)); + await driver.play(doubleTap(mid), DOUBLE_TAP); await waitForAnimation(photo); await waitForRest(photo); const zoomed = await state(); @@ -848,7 +857,7 @@ test('a zoomed flick coasts and stops at the far edge', async ({page}) => { const {driver, photo, state} = await openViewer(page); const before = await state(); const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; - await driver.play(doubleTap(mid)); + await driver.play(doubleTap(mid), DOUBLE_TAP); await waitForAnimation(photo); await waitForRest(photo); // Bring the photo within 160 px of its far edge with a slow drag, held @@ -898,7 +907,7 @@ test('a finger landing on a coasting photo stops it where it shows', async ({ await installTimerGate(page); const before = await state(); const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; - await driver.play(doubleTap(mid)); + await driver.play(doubleTap(mid), DOUBLE_TAP); await waitForAnimation(photo); await waitForRest(photo); // The photo's offset at the flick's lift, read in the page. @@ -980,7 +989,7 @@ test('a finger left down after a pinch keeps panning without a jump', async ({ test.skip(!driver.partialLifts, PARTIAL_LIFT_SKIP); const before = await state(); const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; - await driver.play(doubleTap(mid)); + await driver.play(doubleTap(mid), DOUBLE_TAP); await waitForAnimation(photo); await waitForRest(photo); // Two fingers spread and drift left together, then the right one lifts @@ -1055,10 +1064,10 @@ test('the double-tap zoom keeps advancing every frame while the main thread is b const aheadControl = await startBusyControl(page, {ahead: true}); const at = {x: 201, y: before.stage.y + before.stage.height / 2}; // The same zoom at the default tempo, to compare the slowed one with. - await driver.play(doubleTap(at)); + await driver.play(doubleTap(at), DOUBLE_TAP); const atTempo1 = curvesByProperty(await waitForAnimation(photo)); await waitForRest(photo); - await driver.play(doubleTap(at)); + await driver.play(doubleTap(at), DOUBLE_TAP); await waitForAnimation(photo); await waitForRest(photo); // With the main thread blocked, a loaded runner's compositor presents a @@ -1071,7 +1080,7 @@ test('the double-tap zoom keeps advancing every frame while the main thread is b // spring covers most of its travel early, and a round trip from the test // would start the block too late. const blocking = blockMainThread(page, ZOOM_BLOCK_MS, {during: photo}); - await driver.play(doubleTap(at)); + await driver.play(doubleTap(at), DOUBLE_TAP); const block = await blocking; // Lightbox's own zoom played at that tempo. Each curve is that many times // as long as at tempo 1, and still runs after the block. @@ -1274,6 +1283,7 @@ for (const pace of ['main-thread', 'frozen'] as const) { const cast = await startScreencast(page); await driver.play( doubleTap({x: 201, y: before.stage.y + before.stage.height / 2}), + DOUBLE_TAP, ); // Neither runs on the compositor, so the block starts after the tap. await page.waitForTimeout(150); @@ -1329,6 +1339,7 @@ test('the zoom settles and the pinch do no Layout or Paint per frame', async ({ const zoomTrace = await traceRendering(page, async () => { await driver.play( doubleTap({x: 201, y: before.stage.y + before.stage.height / 2}), + DOUBLE_TAP, ); [zoom] = await waitForAnimation(photo); await waitForRest(photo); @@ -1382,6 +1393,7 @@ test('the zoomed photo is redrawn sharp', async ({page}, testInfo) => { const before = await state(); await driver.play( doubleTap({x: 201, y: before.stage.y + before.stage.height / 2}), + DOUBLE_TAP, ); await waitForAnimation(photo); await waitForRest(photo); @@ -1564,7 +1576,10 @@ test( const hold = await armHold(page, photo, { judges: {target: photo, property: 'scale'}, }); - await driver.play(doubleTap({x: 120, y: s.stage.y + s.stage.height / 2})); + await driver.play( + doubleTap({x: 120, y: s.stage.y + s.stage.height / 2}), + DOUBLE_TAP, + ); const held = await hold.held(); await freezeTimers(page); const {storyboard: zoomIn} = await animationStoryboard(page, { diff --git a/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts index af85e272ac9b0..a1c2abd18bcfc 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts @@ -40,6 +40,7 @@ import { waitForRest, withStorybook, } from '@astryxdesign/interaction-harness'; +import {UIKIT_TAP} from '../../motion/uikit/gestures'; import {recordLiftOffset} from './liftOffset'; const storybook = withStorybook(test); @@ -147,6 +148,12 @@ function doubleTap(at: {x: number; y: number}): TouchScript { ]; } +// A double tap whose second touch reaches the page past UIKit's interval +// between taps is two taps there: the play fails by name. +const DOUBLE_TAP = { + boundaries: {tapIntervalMs: UIKIT_TAP.maximumIntervalBetweenTaps.value}, +}; + test('with a fine pointer (a desktop) the stage fills the viewer, as under a coarse pointer: its gestures take the whole viewer', async ({ browser, }) => { @@ -224,6 +231,7 @@ test('under Reduce Motion a double tap cross-fades to its zoom instead of travel }); await driver.play( doubleTap({x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}), + DOUBLE_TAP, ); const held = await hold.held(); const duration = Math.max(...held.map(a => a.curveEndMs)); @@ -571,7 +579,7 @@ test('under Reduce Motion a zoomed flick coasts on and settles at the edge, as o x: s.stage.x + s.stage.width / 2, y: s.stage.y + s.stage.height / 2, }; - await driver.play(doubleTap(mid)); + await driver.play(doubleTap(mid), DOUBLE_TAP); await waitForRest(img); await frames(page, 2); const liftOffset = await recordLiftOffset(img); @@ -615,7 +623,7 @@ test('under Reduce Motion a zoomed photo pulled past its edge settles back to it x: s.stage.x + s.stage.width / 2, y: s.stage.y + s.stage.height / 2, }; - await driver.play(doubleTap(mid)); + await driver.play(doubleTap(mid), DOUBLE_TAP); await waitForRest(img); await frames(page, 2); // One slow drag right across the stage: the photo, centred and zoomed, From 758da1423881677013bcaeaf7dc6cc442c9f0530 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 08:36:40 -0800 Subject: [PATCH 2/2] test(interaction-harness): a page's own stall is not the runner's A page task that holds the main thread across the tap interval's close ends, in Chromium, with a frame before the touch-down it held. The tap check read that frame as proof that a dispatch stalled before the page, so it named a starved runner. The fault now keeps the frame gap across the close, and the error names the cause by it. Where the page kept drawing, within FRAME_STARVATION_MS, a dispatch stalled before the page, as on a starved runner. Where it drew no frame for longer, its main thread was held across the close, by a task of its own or a starved machine. --- internal/interaction-harness/README.md | 7 +- .../src/harness.interaction.browser.spec.ts | 117 ++++++++++++++++-- .../src/touch.taps.test.ts | 53 +++++++- internal/interaction-harness/src/touch.ts | 36 ++++-- 4 files changed, 192 insertions(+), 21 deletions(-) diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index febc3a537e7e3..25553d5911e31 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -714,7 +714,12 @@ multi-tap gesture names the component's interval between taps long after the lift's stamp. In every engine, a touch-down can reach the page after the close, where the script taps within the window. If the page drew a frame in between, the play fails with `TouchTapIntervalError`: the page read -separate taps. Without such a frame, the component decides. A +separate taps. Without such a frame, the component decides. The error names +the cause by the page's frames across the close. Where the page kept drawing, +a dispatch stalled before the page. Where it drew no frame for longer than +`FRAME_STARVATION_MS`, its main thread was held across the close. In +Chromium, a page task across the close ends with a frame before the touch-down +it held. A scrolling surface reads its release as Core's `scrollVelocity` does: pass `release: 'scroll'`. Each pacing error names the longest time the page drew no frame during the gesture. A page that drew nothing for 100 ms or more never diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index 337afbaa03fd5..f7a298a3bbf99 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -183,6 +183,59 @@ async function busyFixture(page: Page) { // A real navigation (not setContent, whose document.open() erases the window // listeners the input log installs). +/** A double tap: the second touch lands 120 ms after the first lift. */ +const DOUBLE_TAP = [ + {t: 0, fingers: [{id: 0, x: 160, y: 150}]}, + {t: 50, fingers: []}, + {t: 170, fingers: [{id: 0, x: 160, y: 150}]}, + {t: 220, fingers: []}, +]; + +/** + * Holds the page's main thread for `holdMs`, from 50 ms after the first lift, + * as a page's own long task does. Records when each touch reached the page. + */ +async function holdAfterFirstLift(page: Page, holdMs: number): Promise { + await page.evaluate(ms => { + const seen: {type: string; at: number}[] = []; + (window as unknown as {__tapsSeen: typeof seen}).__tapsSeen = seen; + for (const type of ['touchstart', 'touchend']) { + addEventListener(type, () => seen.push({type, at: performance.now()}), { + capture: true, + }); + } + addEventListener( + 'touchend', + () => { + setTimeout(() => { + const end = performance.now() + ms; + while (performance.now() < end) { + // The page's own task holds the main thread. + } + }, 50); + }, + {capture: true, once: true}, + ); + }, holdMs); +} + +/** How long after the first lift the next touch-down reached the page, ms. */ +async function secondTouchGapMs(page: Page): Promise { + return page.evaluate(() => { + const seen = ( + window as unknown as {__tapsSeen: {type: string; at: number}[]} + ).__tapsSeen; + const lift = seen.findIndex(entry => entry.type === 'touchend'); + const down = seen.findIndex( + (entry, index) => index > lift && entry.type === 'touchstart', + ); + if (lift < 0 || down < 0) { + throw new Error(`no second touch-down: ${JSON.stringify(seen)}`); + } + return seen[down].at - seen[lift].at; + }); +} + async function fixture(page: Page) { await installInputLog(page); await page.route('http://harness.test/', route => @@ -567,13 +620,7 @@ test.describe('touch input', () => { // The driver comes first, so its mark does too. const driver = await touchDriver(page); await fixture(page); - const at = {x: 160, y: 150}; - const doubleTap = [ - {t: 0, fingers: [{id: 0, ...at}]}, - {t: 50, fingers: []}, - {t: 170, fingers: [{id: 0, ...at}]}, - {t: 220, fingers: []}, - ]; + const doubleTap = DOUBLE_TAP; const boundaries = {tapIntervalMs: 350}; // On time, the second tap lands 120 ms after the lift. await driver.play(doubleTap, {boundaries}); @@ -595,11 +642,65 @@ test.describe('touch input', () => { expect(fault.step).toBe(2); expect(fault.scriptedGapMs).toBe(120); expect(fault.gapMs).toBeGreaterThan(350); - expect((late as Error).message).toContain('The page drew a frame'); + // The page kept drawing across the close, so the error names the runner. + expect(fault.closeFrameGapMs).toBeLessThanOrEqual(FRAME_STARVATION_MS); + expect((late as Error).message).toContain('as on a starved runner'); // Without the boundary, the play does not judge its taps. await driver.play(doubleTap, {onEvent: stall}); }); + test("a page task that holds the main thread across the tap interval's close never names the runner", async ({ + page, + browserName, + }) => { + // The driver comes first, so its mark does too. + const driver = await touchDriver(page); + await fixture(page); + // The page holds its main thread from 50 ms after the lift for 400 ms, + // past the close at 350 ms. The runner keeps its schedule. + await holdAfterFirstLift(page, 400); + const outcome = await driver + .play(DOUBLE_TAP, {boundaries: {tapIntervalMs: 350}}) + .then( + () => 'resolved', + (error: unknown) => error, + ); + // The page read separate taps, but its own task held the touch-down. + expect(await secondTouchGapMs(page)).toBeGreaterThan(350); + // Chromium draws a frame after the task, before the touch-down it held. + // So the play names the held main thread. WebKit draws no frame between + // them, so the component decides (touch.taps.test.ts covers that case). + const named = + outcome instanceof Error && + outcome.name === 'TouchTapIntervalError' && + (outcome as Error & {fault: TapIntervalFault}).fault.closeFrameGapMs > + FRAME_STARVATION_MS && + !outcome.message.includes('runner') + ? 'a held main thread' + : outcome; + expect(named).toBe( + browserName === 'webkit' ? 'resolved' : 'a held main thread', + ); + }); + + test('a page task that ends before the tap interval closes leaves the double tap on time', async ({ + page, + }) => { + // The driver comes first, so its mark does too. + const driver = await touchDriver(page); + await fixture(page); + // The same task, cut to 250 ms, ends before the close. + await holdAfterFirstLift(page, 250); + const outcome = await driver + .play(DOUBLE_TAP, {boundaries: {tapIntervalMs: 350}}) + .then( + () => 'resolved', + (error: unknown) => error, + ); + expect(await secondTouchGapMs(page)).toBeLessThan(350); + expect(outcome).toBe('resolved'); + }); + test("a page's own window capture listener counts as the page's listeners when the driver is created before the page loads", async ({ page, browserName, diff --git a/internal/interaction-harness/src/touch.taps.test.ts b/internal/interaction-harness/src/touch.taps.test.ts index 0e3e40884633d..48a399f3cf8cf 100644 --- a/internal/interaction-harness/src/touch.taps.test.ts +++ b/internal/interaction-harness/src/touch.taps.test.ts @@ -5,10 +5,11 @@ * @input touch.ts's tap-interval rule (`tapIntervalFault`, a play's * `boundaries.tapIntervalMs`) * @output Unit cases: a second touch-down received after the tap interval - * closed is a fault only where the page drew a frame in between. A main - * thread held across the close leaves the pairing to the component. Neither are taps the script spaces past the - * interval, nor a lift that leaves a finger down, nor a play without the - * page's times. + * closed is a fault only where the page drew a frame in between. The fault + * keeps the frame gap across the close, and the error names the cause by + * it. A frameless arrival leaves the pairing to the component. Neither are + * taps the script spaces past the interval, nor a lift that leaves a finger + * down, nor a play without the page's times. * @position Node unit tests beside the harness's browser self-tests. * * SYNC: When modified, update touch.ts (tapIntervalFault). @@ -16,7 +17,8 @@ import {describe, expect, it} from 'vitest'; import {toTouchEvents, type TouchScript} from './scripts'; -import {tapIntervalFault} from './touch'; +import {FRAME_STARVATION_MS} from './frame-starvation'; +import {tapIntervalFault, TouchTapIntervalError} from './touch'; const at = {x: 100, y: 200}; const doubleTap: TouchScript = [ @@ -45,6 +47,7 @@ describe('tapIntervalFault', () => { tapIntervalMs: 350, frameAfterCloseMs: 16, framesAfterClose: 7, + closeFrameGapMs: 16, }); }); @@ -59,6 +62,46 @@ describe('tapIntervalFault', () => { expect(tapIntervalFault(steps, page, 350)).toBeNull(); }); + it('keeps the frame gap across the close when the page drew again only before the touch-down', () => { + const steps = toTouchEvents(doubleTap); + // The page's own task holds its main thread from 384 ms. It draws again + // at 512 ms, before the touch-down it held arrives at 525 ms. + const page = { + stamps: [0, 50, 170, 220], + receivedAt: [5, 60, 525, 540], + frames: everyFrame.filter(time => time <= 384 || time >= 512), + }; + expect(tapIntervalFault(steps, page, 350)).toMatchObject({ + step: 2, + frameAfterCloseMs: 112, + framesAfterClose: 1, + closeFrameGapMs: 128, + }); + }); + + it('names a held main thread, not the runner, where a frame gap past the starvation bound spans the close', () => { + const fault = { + step: 2, + gapMs: 475, + scriptedGapMs: 120, + tapIntervalMs: 350, + frameAfterCloseMs: 112, + framesAfterClose: 1, + closeFrameGapMs: FRAME_STARVATION_MS + 1, + }; + const held = new TouchTapIntervalError(fault).message; + expect(held).toContain( + `drew no frame for ${FRAME_STARVATION_MS + 1} ms across that window's close`, + ); + expect(held).not.toContain('runner'); + const kept = new TouchTapIntervalError({ + ...fault, + closeFrameGapMs: FRAME_STARVATION_MS, + }).message; + expect(kept).toContain('kept drawing across'); + expect(kept).toContain('as on a starved runner'); + }); + it('finds none when the touch-down arrives before the interval closes', () => { const steps = toTouchEvents(doubleTap); const page = { diff --git a/internal/interaction-harness/src/touch.ts b/internal/interaction-harness/src/touch.ts index c44b3b5521c52..37dd986b489a2 100644 --- a/internal/interaction-harness/src/touch.ts +++ b/internal/interaction-harness/src/touch.ts @@ -926,19 +926,33 @@ export interface TapIntervalFault { readonly frameAfterCloseMs: number; /** How many frames the page drew between the close and the touch-down. */ readonly framesAfterClose: number; + /** + * The gap between the page's last frame before the interval closed and its + * first frame after, ms. Past `FRAME_STARVATION_MS`, the page's main thread + * was held across the close. + */ + readonly closeFrameGapMs: number; } /** * A multi-tap play's touch-down reached the page past the component's tap * interval after the lift before it, where the script taps within it. So the - * page read separate taps, not the gesture the script makes: a dispatch - * stalled before the page, as on a starved runner. Always a red, never a - * pass: the proof judges no gesture the page did not receive. + * page read separate taps, not the gesture the script makes. The error names + * the cause by the page's frames across the close. Where the page kept + * drawing, a dispatch stalled before the page, as on a starved runner. Where + * it drew no frame for longer than `FRAME_STARVATION_MS`, its main thread was + * held across the close. Always a red, never a pass: the proof judges no + * gesture the page did not receive. */ export class TouchTapIntervalError extends Error { constructor(readonly fault: TapIntervalFault) { + const after = fault.frameAfterCloseMs.toFixed(0); + const cause = + fault.closeFrameGapMs > FRAME_STARVATION_MS + ? `The page drew no frame for ${fault.closeFrameGapMs.toFixed(0)} ms across that window's close, then a frame ${after} ms after it, before the touch-down. Its main thread was held across the close, by a task of its own or a starved machine. So the page read separate taps: the touch-down waited for that main thread.` + : `The page kept drawing across that window's close, and drew a frame ${after} ms after it, before the touch-down (${fault.framesAfterClose} in all). So the page read separate taps: a dispatch stalled before the page, as on a starved runner.`; super( - `touchDriver: the touch-down at step ${fault.step} reached the page ${fault.gapMs.toFixed(0)} ms after the lift before it, past the ${fault.tapIntervalMs} ms the component waits for the next tap. The page drew a frame ${fault.frameAfterCloseMs.toFixed(0)} ms after that window closed, before the touch-down (${fault.framesAfterClose} in all). The script spaces them ${fault.scriptedGapMs.toFixed(0)} ms. So the page read separate taps: a dispatch stalled before the page, as on a starved runner.`, + `touchDriver: the touch-down at step ${fault.step} reached the page ${fault.gapMs.toFixed(0)} ms after the lift before it, past the ${fault.tapIntervalMs} ms the component waits for the next tap. The script spaces them ${fault.scriptedGapMs.toFixed(0)} ms. ${cause}`, ); this.name = 'TouchTapIntervalError'; } @@ -966,6 +980,11 @@ export interface TouchPageTimes { * the script itself spaces past the interval is no fault. Only a lift that * leaves no finger down ends a tap. Without the page's times, nothing proves * the window closed first. + * + * The fault keeps the frame gap across the close, and the error names the + * cause by it. In Chromium, a page task that holds the main thread across the + * close ends with a frame before the touch-down it held. So a frame after the + * close does not prove that the runner was late. */ export function tapIntervalFault( steps: readonly TouchEventStep[], @@ -979,6 +998,7 @@ export function tapIntervalFault( ) { return null; } + const frames = [...page.frames].sort((a, b) => a - b); let lift: number | null = null; for (const [index, step] of steps.entries()) { if (step.type === 'touchEnd' && step.fingers.length === 0) { @@ -991,10 +1011,11 @@ export function tapIntervalFault( const scriptedGapMs = step.t - steps[lift].t; const closesAt = page.stamps[lift] + tapIntervalMs; const receivedAt = page.receivedAt[index]; - const drawn = page.frames.filter( - time => time > closesAt && time < receivedAt, - ); + const drawn = frames.filter(time => time > closesAt && time < receivedAt); if (scriptedGapMs <= tapIntervalMs && drawn.length > 0) { + // Without a frame before the close, the page last ran at the lift. + const lastBefore = + frames.filter(time => time <= closesAt).at(-1) ?? page.receivedAt[lift]; return { step: index, gapMs: receivedAt - page.stamps[lift], @@ -1002,6 +1023,7 @@ export function tapIntervalFault( tapIntervalMs, frameAfterCloseMs: drawn[0] - closesAt, framesAfterClose: drawn.length, + closeFrameGapMs: drawn[0] - lastBefore, }; } lift = null;