Skip to content

test(interaction-harness): a WebKit touch gesture keeps its scripted gaps after a stall, and a proof can check and re-take its pacing - #8910

Draft
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:harness/touch-pacing
Draft

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:harness/touch-pacing

Conversation

@vjeux

@vjeux vjeux commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@90da4a7...cec68ad

Why

WebKit's touch command takes no timestamp, so the page times each touch by its arrival. Under load a dispatch stalls 27 to 31 ms and the moves behind it land 1 to 2 ms apart: BottomSheet's 2,000 px/s flick reached the page at 3,627 to 13,200 px/s.

What led to it

The driver aimed every event at the gesture's start, so after a stall it sent the overdue moves together, and nothing told a spec its input was off script.

Options and tradeoffs

  • Rerun until green, or wait out load: hides the cause.
  • Timestamps in WebKit's command: none exist.
  • Keep gaps after a stall, check what the page got, re-take once on proof (this pull request).

Why this choice

  • playSteps sends each event at its scripted time and never sooner after the one before it than the script spaces them: a stall stretches one gap instead of bunching the rest.
  • play(script, {checkPacing: true}) throws TouchPacingError, with both gap lists, where the page's timeStamps decide a release: an event missing, either of the last two move intervals (what a release velocity reads) more than TOUCH_PACING_TOLERANCE_MS (4 ms) off, or the lift 40 ms or more after the last move (UIKit's stillness). The first moves and the lift carry the page's own handling time and are not judged. Opt-in.
  • retakeOnTouchPacing(testInfo, attempt) runs a proof's attempt once more, only on a TouchPacingError, as a touch-retake annotation; a second fails with that cause; a product failure is never re-taken.
  • driver.synthesizesClicks and SCRIPTED_CLICK_SKIP: a proof that needs a scripted tap's click skips by name where WebKit synthesises none.

How to check it

  1. pnpm exec vitest run --project node internal/interaction-harness/src/touch.pacing.test.ts: 9 pass.
  2. pnpm test:interaction internal/interaction-harness/src/harness.interaction.browser.spec.ts --grep "scripted tap clicks|off its pacing": both pass in both engines.

Evidence

vjeux added 2 commits October 29, 2026 15:40
…ter a stalled dispatch, and a proof can check the pacing the page received

WebKit's touch command takes no timestamp, so the page times each event by its arrival. Under box contention a dispatch stalls 27 to 31 ms, and the driver, which aimed every event at the gesture's start, then sent the overdue moves together, 1 to 2 ms apart: C17 measured BottomSheet's flick at 2000 px/s reaching the page as 3,627 to 13,200 px/s.

playSteps now sends each event at its scripted time and never sooner after the one before it than the script spaces them, so a stall stretches one gap instead of bunching the rest. With `checkPacing`, WebKit's play reads the timeStamps the page received (a passive capture listener on touch events) and throws TouchPacingError, naming both gap lists, when an event is missing or a gap is more than TOUCH_PACING_TOLERANCE_MS off the script. retakeOnTouchPacing(testInfo, attempt) runs a proof's attempt once more on a TouchPacingError only: two attempts in all, a product failure is never re-taken, and each re-take is listed as a `touch-retake` annotation.

The tolerance is measured: on one worker of a loaded machine, 440 gaps over 40 WebKit flicks moved at most 4 ms (99th percentile 3 ms; the page's stamps are whole ms); under eight workers 4% of flicks had a gap further off, up to 21 ms.

Red first: the stalled-dispatch case fails on the previous scheduling; checkTouchPacing and retakeOnTouchPacing are new, with the three re-take cases (both attempts off pace stays red, a product failure after good input is not re-taken, off pace then good passes and is listed). In the browser a 40 ms driver stall makes WebKit's play throw TouchPacingError, and Chromium, whose page sees the scripted stamps, plays on.
…a click, and SCRIPTED_CLICK_SKIP names the skip where it does not

Playwright's WebKit synthesises a click only from its tap gesture (`driver.tap`), never from touches sent one by one, so a proof that needs the click of a scripted press and lift has to know. `driver.synthesizesClicks` says it (Chromium true, WebKit false), and such a proof skips with `test.skip(!driver.synthesizesClicks, SCRIPTED_CLICK_SKIP)`, as PARTIAL_LIFT_SKIP does for a partial lift.

The self-test plays a scripted tap and then driver.tap on one button and reads the order of touchstart, touchend and click: driver.tap's click comes last and a scripted tap's click, if any, before driver.tap's touchstart, so it waits on that order rather than on the clock. This came from the web-input drivers helper line, which no longer carries it.
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 30, 2026 12:36am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 30, 2026, 12:59 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 30, 2026
…se reads it, its last two move intervals and its lift

C17 adopted checkPacing on BottomSheet's flick detent proofs and every WebKit attempt failed, even on an idle worker: WebKit delivers a gesture's first moves with the page's own touchstart handling time (gaps of 4, 11, 3 ms against 8, 8, 8) and its lift about a frame late (8 to 11 ms against 2.1). Neither feeds a release velocity: Core's tracker reads the last three moved samples, two intervals, and a release reads as still only 40 ms after the last move. Before the check the same flicks reached the page at 2,000 px/s.

checkPacing now judges what decides a release: an event missing, either of the last two move intervals more than TOUCH_PACING_TOLERANCE_MS (4 ms) off the script, or the lift TOUCH_LIFT_STILLNESS_MS (40 ms, UIKit's measured stillness, spec:AST-100 FR2) or more after the last move. checkTouchPacing takes the played steps, so it knows which events are moves.

Red first: C17's received gaps fail the previous check and pass this one; a stall and its burst in the last two intervals still fails, and so does a lift 45 ms late, while the same stall earlier in the gesture passes. In the browser a 40 ms driver stall before the last move throws in WebKit, and Chromium plays on.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: keeping the scripted gaps after a stall is sound, but the new pacing check misfires on gestures that rest before lifting and one new assertion is timing-sensitive.

  • checkTouchPacing compares the lift with a fixed 40 ms, so a script that rests before lifting (for example drag(..., {restMs: 120})) throws TouchPacingError on both attempts even when WebKit delivers it on script; please judge the lift against the script's own lift gap (touch.ts, lines 220-225).
  • expect(sentGaps[2]).toBeGreaterThanOrEqual(40) fails when Node's 40 ms timer resolves slightly early, and the same 1 ms slack the later gaps get would fix it (touch.pacing.test.ts, line 59).
  • The TOUCH_LIFT_STILLNESS_MS comment calls 40 ms UIKit's stillness, while AST-100 FR2 and Core's constant say the stillness reset is where Astryx departs from iOS, and the README's engine-limits table still promises about 1 ms pacing.

[Automated review]

}
}
const liftAfter = received[lift] - received[lastMove];
if (liftAfter >= TOUCH_LIFT_STILLNESS_MS) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This compares the lift with a fixed 40 ms rather than with the script, so a gesture that rests 40 ms or more before lifting throws even when the page receives it exactly on script. In WebKit, the self-test's own drag(..., {restMs: 120}) played with checkPacing arrives with every gap on pace (16 to 17 ms against 16.7, the lift at 120 ms) and still throws on both attempts of retakeOnTouchPacing. Failing only when the scripted and received lift gaps fall on different sides of the interval keeps the check's purpose:

const scriptedLift = steps[lift].t - steps[lastMove].t;
if (
  (scriptedLift > TOUCH_LIFT_STILLNESS_MS) !==
  (liftAfter > TOUCH_LIFT_STILLNESS_MS)
) {

Using > also matches Core's tracker, which still releases a velocity at exactly 40 ms.

scriptedGaps[i] - 1,
);
}
expect(sentGaps[2]).toBeGreaterThanOrEqual(40);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Node can resolve setTimeout(…, 40) up to about 1 ms early by performance.now(): repeating this case 120 times gave a stalled gap under 40 ms three times (minimum 39.31 ms), which would turn the required test check red with no cause in the code. The same 1 ms slack the later gaps get keeps the case red on the old scheduling, whose later gaps measure about 0 ms:

expect(sentGaps[2]).toBeGreaterThanOrEqual(39);


/**
* How long after its last move a lift may reach the page before the release
* reads as still, in ms: UIKit's measured stillness (`spec:AST-100` FR2).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AST-100 FR2 says the stillness reset is "the one case where it differs from iOS", and Core's constant in packages/core/src/motion/uikit/gestures.ts reads "Not a UIKit value" (a guess taken from Android and Flutter). Citing it as Core's internal stillness interval, with a SYNC note naming that file, keeps the two from drifting.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…ample elements in one call, and read a watch's whole record

A hold the test takes after an action reaches the page as late as the runner
lets it: on a loaded machine a 1 s settle can end before the test's next call
arrives, and a proof that reads it then fails on the runner, not the product.

- `armHold(scope, {property, to})`, taken before the action: when an animation
  of the scope's own element whose last keyframe sets `property` to `to` is
  recorded, everything running in the scope is held, into the same held set
  as `holdAnimations`, in a microtask of the task that created it. Until the
  page's next frame, an animation the same task starts in the scope later is
  held too. A script animation is held before its first frame (a call from
  rest at 0 ms, one on an explicit start time where it stands); a CSS
  animation or transition at its first frame. `held()` returns the held
  animations and the one that fired, or rejects after three of the page's
  frames naming what the element started instead, and stands the hold down.
- `holdAnimations` and `armHold` share one page-side hold, and
  `waitForAnimation` and a watch one page-side description.
- `sampleCurves(locators, times)`: several elements in one page call, so
  nothing the page runs between two calls lands between one element's
  samples and another's.
- `watch.recorded()`: every animation recorded on the element since the
  watch, running or finished, with no wait. `started()` answers with the
  running ones whenever one has progressed, so a short fade that ended beside
  a part still running is only in the record.

The Item flick proof, the first user, also needs the touch driver's script
stamps (meta#8910, 8ca17da) for its result under load: with the engine's own
stamps a loaded runner puts the throw off its scripted pacing, and the proof
fails by name with a TouchPacingError.

This branch was successfully deployed

1 active deployment
Preview — cec68ad3 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant