Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions internal/interaction-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,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
Expand Down
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 @@ -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');
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
// 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,
Expand Down
121 changes: 121 additions & 0 deletions internal/interaction-harness/src/touch.taps.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading