Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/interaction-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,17 @@ 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. 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
Expand Down
141 changes: 141 additions & 0 deletions internal/interaction-harness/src/harness.interaction.browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -182,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. */
Comment on lines 184 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this comment explains why fixture (line 239) navigates instead of calling setContent. The new helpers now sit between them, so it reads as a note on DOUBLE_TAP. Move the helpers above line 184.

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<void> {
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<number> {
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 =>
Expand Down Expand Up @@ -597,6 +651,93 @@ 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 doubleTap = DOUBLE_TAP;
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);
// 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,
Expand Down
164 changes: 164 additions & 0 deletions internal/interaction-harness/src/touch.taps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// 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. 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).
*/

import {describe, expect, it} from 'vitest';
import {toTouchEvents, type TouchScript} from './scripts';
import {FRAME_STARVATION_MS} from './frame-starvation';
import {tapIntervalFault, TouchTapIntervalError} 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,
closeFrameGapMs: 16,
});
});

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('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 = {
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();
});
});
Loading
Loading