Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
117 commits
Select commit Hold shift + click to select a range
dc6ae1e
test(interaction): real touch input for browser interaction proofs
vjeux Oct 24, 2026
75fc6cb
fix(interaction): release only the lifted finger on a partial lift in…
vjeux Oct 24, 2026
0a07aa1
fix(interaction): join the proofs to test, keep their setup in scope,…
vjeux Oct 24, 2026
5f2ed50
fix(interaction): releaseVelocity measures from when the finger reach…
vjeux Oct 24, 2026
d3c13cc
test(interaction): curve sampling and deterministic storyboards
vjeux Oct 24, 2026
65c017e
fix(interaction): curves end before a held end value; rest judges pre…
vjeux Oct 24, 2026
d8af73a
fix(interaction): hold only running animations at their own offsets, …
vjeux Oct 24, 2026
2e170f5
fix(interaction): film from each animation's own offset, label sample…
vjeux Oct 25, 2026
04cbfc5
fix(interaction): waitForAnimation sees a short animation that ended …
vjeux Oct 25, 2026
d29981d
refactor(interaction): the storyboard layer moves to its own pull req…
vjeux Oct 25, 2026
beecc80
test(interaction): a second hold adds to the held set
vjeux Oct 25, 2026
4a6dfbe
test(interaction): deterministic storyboards
vjeux Oct 25, 2026
eec2404
test(interaction): a touch filmstrip's handler clock follows its pacing
vjeux Oct 25, 2026
0954697
test(interaction): frame evidence and the BottomSheet exemplar
vjeux Oct 24, 2026
6be18c6
test(interaction): make the busy-main-thread verdict robust to a load…
vjeux Oct 24, 2026
3f39b21
test(BottomSheet): pin the settle's easing and duration instead of re…
vjeux Oct 24, 2026
03eadf9
test(interaction): block after the frame's rendering update; rebase W…
vjeux Oct 25, 2026
ffc2bd4
Merge branch 'main' into interaction-harness-2-curves
vjeux Oct 25, 2026
e918a2f
Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards
vjeux Oct 25, 2026
79a1fed
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
8d0a829
test(interaction): a held animation the page cancels leaves the held set
vjeux Oct 25, 2026
cb37bd7
Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards
vjeux Oct 25, 2026
0b4d12b
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
a499860
docs(interaction): the touch filmstrip's clock limit names no missing…
vjeux Oct 25, 2026
212ec1d
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
b36bf63
docs(interaction): the touch filmstrip's clock limit names the screen…
vjeux Oct 25, 2026
91f8ddd
test(interaction): a hold stays where it was read
vjeux Oct 25, 2026
b5783ed
Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards
vjeux Oct 25, 2026
2a718ad
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
3f44fe9
test(interaction): a block never starts on a pending animation
vjeux Oct 25, 2026
bc744cb
test(interaction): the WebKit container keeps compiled shaders betwee…
vjeux Oct 25, 2026
f898984
Merge commit 'refs/f3/main' into interaction-harness-1-touch
vjeux Oct 25, 2026
824bc84
Merge branch 'interaction-harness-1-touch' into interaction-harness-2…
vjeux Oct 25, 2026
0e3e379
Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards
vjeux Oct 25, 2026
9716595
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
4538572
ci: the harness's typecheck runs through pnpm typecheck, once
vjeux Oct 25, 2026
26f2b98
test(interaction): a watch on first contact ignores what already runs
vjeux Oct 25, 2026
d8fbea2
Merge branch 'interaction-harness-1-touch' into interaction-harness-2…
vjeux Oct 25, 2026
5850e7a
test(interaction): a later hold never moves the first hold's times
vjeux Oct 25, 2026
c6d91e8
ci: pin the interaction routing's fail-closed edges by running them
vjeux Oct 25, 2026
15a1a76
Merge branch 'interaction-harness-1-touch' into interaction-harness-2…
vjeux Oct 25, 2026
0a9e7bf
docs(interaction): seek and sample times count from the held origin
vjeux Oct 25, 2026
2438fc7
ci: a head cut before the interaction proofs passes with a notice
vjeux Oct 25, 2026
295c0da
Merge branch 'interaction-harness-1-touch' into interaction-harness-2…
vjeux Oct 25, 2026
eeef165
ci: a head cut before the interaction proofs passes with a notice
vjeux Oct 25, 2026
a9b2acf
fix(interaction): a watch keeps the start of an animation the page ca…
vjeux Oct 25, 2026
cf0ad08
chore(interaction): the WebKit container script moves up the stack
vjeux Oct 25, 2026
8afa44a
Merge branch 'interaction-harness-1-touch' into interaction-harness-2…
vjeux Oct 25, 2026
990fa36
chore(interaction): the BottomSheet detent exemplar leaves the harnes…
vjeux Oct 25, 2026
2642e06
fix(interaction): a storyboard retakes a photograph Chromium returns …
vjeux Oct 25, 2026
ca4fee0
test(interaction): pin what the long-task log sees; the cache test wa…
vjeux Oct 25, 2026
18b3bd3
docs(interaction): the storyboard retake waits two frames, three atte…
vjeux Oct 25, 2026
a68b352
test(interaction): the self-test films a cast window
vjeux Oct 25, 2026
44278bf
Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards
vjeux Oct 25, 2026
56d5de0
chore(interaction): the WebKit container script lands with the storyb…
vjeux Oct 25, 2026
cc613ef
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
618ed4e
Merge remote-tracking branch 'refs/remotes/origin/main' into interact…
vjeux Oct 25, 2026
37050d3
ci: the interaction jobs keep to the 15-minute budget and report thei…
vjeux Oct 25, 2026
cd141ab
Merge branch 'interaction-harness-1-touch' into interaction-harness-2…
vjeux Oct 25, 2026
9864ff8
Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards
vjeux Oct 25, 2026
fd1e7e1
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
b6cb3d8
Merge main (#8043 landed) into the curve sampling
cixzhang Oct 25, 2026
b9c5d7b
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 25, 2026
7ee8fea
Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards
vjeux Oct 25, 2026
cd9e5cf
fix(interaction): curve samples stay true for percentage translates, …
cixzhang Oct 25, 2026
804b28e
fix(interaction): compareCurve fails a point it cannot compare
cixzhang Oct 25, 2026
19ef35c
fix(interaction): the busy-main-thread verdict judges the frames the …
vjeux Oct 25, 2026
4f6dd9c
Merge main (#8462 curve oracles and #8053 landed) into the curve samp…
cixzhang Oct 26, 2026
2912517
Merge remote-tracking branch 'refs/remotes/fork/interaction-harness-2…
vjeux Oct 26, 2026
e5a32e1
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 26, 2026
432ef44
fix(interaction): curve ends under a non-linear effect easing, and ro…
cixzhang Oct 26, 2026
8082d27
Merge remote-tracking branch 'refs/remotes/fork/interaction-harness-2…
vjeux Oct 26, 2026
c4e0f0d
Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames
vjeux Oct 26, 2026
0cc6d8e
fix(interaction): a held end is solved through a cubic-bézier easing,…
cixzhang Oct 26, 2026
e2da8b9
fix(interaction): judge a just-started animation from when it reaches…
vjeux Oct 26, 2026
fb3423f
fix(interaction): an animation reversed by reverse() reads to its ful…
cixzhang Oct 26, 2026
e2e775a
fix(interaction): a reverse()d animation is reported from its real st…
cixzhang Oct 26, 2026
2efaf04
fix(interaction): a held run counts only when it reaches offset 1 and…
cixzhang Oct 26, 2026
7c8ab74
fix(interaction): the busy verdict reads the compositor's own frames …
vjeux Oct 26, 2026
ad21d14
test(interaction): held offsets against the runners' own start times
vjeux Oct 26, 2026
35b0f22
fix(interaction): a reversed run's progress counts from its end; wait…
vjeux Oct 26, 2026
a261384
fix(interaction): the busy verdict counts the frames a screencast del…
vjeux Oct 26, 2026
10ba166
fix(interaction): a run going backward is dated from when it began
cixzhang Oct 26, 2026
92bc9c2
fix(interaction): a finish read before its event goes through the rat…
cixzhang Oct 26, 2026
a2ac6f9
fix(interaction): waitForRest waits out a backward run's end delay an…
cixzhang Oct 26, 2026
1e242be
fix(interaction): a rendering trace reports DevTools-run tasks apart …
vjeux Oct 26, 2026
efdffbe
refactor(interaction): the animation-state waits move to their own pu…
cixzhang Oct 26, 2026
c833823
feat(interaction): animation-state waits for interaction proofs
cixzhang Oct 26, 2026
2bcd5b2
docs(interaction): the waits read a run reversed before it starts; a …
cixzhang Oct 26, 2026
e8b0bb6
Merge main (#8044 landed) into the animation-state waits
cixzhang Oct 26, 2026
4eac57c
Merge #8505 (e8b0bb62: main with #8044, plus the animation-state wait…
cixzhang Oct 26, 2026
772a4b5
Merge #8348 (main with #8044 and #8505's waits) into the frame evidence
cixzhang Oct 26, 2026
571f088
fix(interaction): openStory turns Storybook's highlight module off
vjeux Oct 26, 2026
4c41132
fix(interaction): a trace sets apart only the harness's own reads, ne…
vjeux Oct 26, 2026
9320644
fix(interaction): a wait keeps waiting while the action's animation i…
cixzhang Oct 26, 2026
156e4b3
test(interaction): openStory's highlight self-test runs in both engines
vjeux Oct 26, 2026
b4234d7
Merge #8505 (9320644717: a wait keeps waiting while the action's anim…
vjeux Oct 26, 2026
cb907b7
fix(interaction): the busy verdict judges a sparse cast from its firs…
vjeux Oct 26, 2026
462d82c
fix(interaction): a scroll- or view-timeline animation does not hold …
cixzhang Oct 26, 2026
803e6d6
test(interaction): the long-task self-test judges the harness read, n…
vjeux Oct 26, 2026
fa8c8e2
Merge #8505 (462d82caaf: a scroll- or view-timeline animation does no…
vjeux Oct 26, 2026
a9be409
fix(interaction): a scroll-driven animation at its end is not a finis…
cixzhang Oct 26, 2026
02ff48f
fix(interaction): a watch stamps after the newest entry, and an exit …
cixzhang Oct 26, 2026
528dd2d
fix(interaction): after an on-screen start, the busy verdict judges t…
vjeux Oct 26, 2026
e05cdd1
Merge #8505 (02ff48fafe: a watch stamps after the newest entry, and a…
vjeux Oct 26, 2026
93c611d
Merge main (#8505 landed) into the storyboards
cixzhang Oct 26, 2026
79aa498
Merge #8348 (main with #8505 landed) into the frame evidence
cixzhang Oct 26, 2026
5ad1170
fix(interaction): the WebKit container runs as the invoking user unde…
cixzhang Oct 26, 2026
38ea7d9
fix(interaction): rootless docker runs the WebKit container without -…
cixzhang Oct 26, 2026
5712c49
Merge #8348 (38ea7d9c: rootless docker, WebKit container user) into t…
cixzhang Oct 26, 2026
4045092
chore(interaction): move four instruments out of the frame evidence
cixzhang Oct 26, 2026
6762cc5
chore(interaction): move rendering traces and the long-task log out o…
cixzhang Oct 26, 2026
03058f6
chore(interaction): move the compositor presentation record out of th…
cixzhang Oct 26, 2026
3e3c4e7
Merge main (#8348 landed) into the frame evidence
cixzhang Oct 26, 2026
5613501
docs(interaction): the README's evidence list and busy recipe match w…
cixzhang Oct 26, 2026
bac4669
fix(interaction): a window that delivered under four frames is judged…
cixzhang Oct 26, 2026
8715133
docs(interaction): the busy verdict's docs name the thin-window rule
cixzhang Oct 26, 2026
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
101 changes: 88 additions & 13 deletions internal/interaction-harness/README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
# @astryxdesign/interaction-harness

Playwright instruments for touch interaction and animation proofs: real touch
input, curve sampling and storyboards. **Internal and unpublished.** Specs live
next to their component as `*.interaction.browser.spec.ts` and run under
input, frame evidence, curve sampling and storyboards. **Internal and
unpublished.** Specs live next to their component as
`*.interaction.browser.spec.ts` and run under
[`playwright.interaction.config.ts`](../../playwright.interaction.config.ts) on
a phone-sized touch screen (402 × 714 CSS px page, 402 × 874 screen, 3 device
pixels per CSS px, touch, mobile viewport rules), in Chromium and WebKit.

The harness proves its own instruments in
[`src/harness.interaction.browser.spec.ts`](src/harness.interaction.browser.spec.ts),
on fixture pages written inline.
[`src/harness.interaction.browser.spec.ts`](src/harness.interaction.browser.spec.ts).
The busy-main-thread proof has a negative control beside its positive check
(a rAF animation that freezes under a blocked main thread); other self-tests
check an instrument against a known answer.

## Running

```sh
pnpm storybook:build # component specs read stories from the built Storybook
pnpm storybook:build # the specs read stories from the built Storybook
pnpm exec playwright install chromium webkit
pnpm test:interaction --project chromium # everything, Chromium
pnpm test:interaction <spec file> # one spec, both engines
Expand Down Expand Up @@ -42,6 +45,8 @@ committed: a pull request links or attaches it.
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact final-move speed, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. |
| `touch.ts` | `touchDriver(page).play(script)`: the script as real engine input. `installInputLog` / `readInputLog`: what the page received. |
| `frames.ts` | `startScreencast` (timestamped composited frames), `markElement` (a marker chip or outline), `trackMarker` (marker box per frame), `frameStats`. |
| `busy.ts` | `blockMainThread(page, ms)` and `busyWindowProof(tracked, window)`: frames presented, frames that moved, longest stall, a verdict; `presented` adds the compositor's own frames. |
| `curves.ts` | `holdAnimations`, `seekAnimations`, `sampleCurve` (presented transform / opacity at any t), `waitForAnimation`, `watchAnimations`, `waitForRest`, `releaseAnimations`. |
| `math.ts` | Oracles independent of the code under test: `cubicBezier`, `timingFunction`, `springStep` (SwiftUI response / dampingFraction, initial velocity), `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`, `timeline`. |
| `storyboard.ts` | `animationStoryboard` (seek to t = 0, 16.7, 33.3 … ms), `touchStoryboard` (a frame per scripted event): PNG contact sheet + JSON trace. |
Expand Down Expand Up @@ -97,23 +102,91 @@ set): a short entrance no longer races the first look on a cold page. When the
action may be the page's first contact with the harness, take
`watchAnimations(locator)` before it and read `watch.started()` after.

**Busy main thread.** Mark the element, start the screencast, start the
animation, wait until it runs (`waitForAnimation`: an animation cannot start
while the thread is held), block the main thread for 1 s (`spec:AST-094`
FR5's verification row), stop. Always
start the gesture or animation first and block mid-flight, never before:
in headless Chromium a touch that starts while the main thread is busy does
not scroll or start a gesture even with only passive listeners (the input
waits for the main thread). Tracking a finger under a busy main thread is
therefore proven on the iOS simulator, not here. The
screencast is captured off the main thread, so a compositor animation shows a
new position every frame of the block, and a main-thread animation shows none
(nothing changed, so no frame is sent at all: `busyWindowProof` counts the
whole window as one stall). `settledWithin` (2 px) stops judging at the easing
tail: the screencast rounds to whole pixels, so a tail that still moves shows
plateaus within a pixel or two of rest. A judged part shorter than four of the
screencast's intervals fails: the animation under test was not moving during
the block. With
`during`, `blockMainThread` starts the loop from a task queued in the frame
that saw the animation, after that frame's rendering update: WebKit hands a
newly started animation to its compositor there, and a loop inside the rAF
callback would freeze even a compositor animation. Call it before the release
that starts a settle and await it after, so the page is already waiting when
the settle begins. An animation counts once it is no longer pending and has
run at least one frame: a `minProgressMs` under 16 is raised to 16, since one
created in the frame the page looks is not on the compositor yet. The `verdict` judges the
frames the screencast delivered, whatever its rate (a loaded machine's can
deliver one frame in five or six), and tolerates nothing a frozen animation
does: at least one frame in the judged part, no stall longer than three of the
screencast's intervals (two dropped frames), and at least 90% of the frames it
delivered in the judged part presenting a new position (one miss is always
allowed; a frame it did not deliver is a gap, for the stall check, unless
it delivered fewer than four frames there: then the judged part's span counts
every frame, so one late frame is not "1 of 1 moved"). Pass
`maxMedianFrameMs` (20 at 60 Hz) only in a proof that claims display rate.
`verdict.failures` names each miss with its numbers, and `summary` carries the
raw numbers for the report. Frames are judged in presentation order: a loaded
machine can deliver them out of order.

A just-started animation can reach the screen after the block began (50–100 ms
later in WebKit on a loaded machine). When the cast saw the element at rest
before the block (wait for `cast.frameCount()` to pass 0 before starting the
animation), judging starts at its first presented change: the judged part still
ends with the block and still needs four intervals, and `onScreenFromMs` and the
summary report when it came. An element already moving is judged from the
block's start. WebKit's casts are 2 px quantized: the frames come at the
viewport's size, but the page in them is captured at half resolution, so
positions move in 2 CSS px steps (Chromium's in 1). With under two steps of
motion per frame, a frame delivered a few ms early shows no step and reads as
not moved. The proof reports `stepPx` and `pxPerFrame` and notes motion under
two steps per frame, which never fails it. Move a proof's element at least two
steps a frame: the self-tests move 560 px in 1,600 ms, about 4.8 px a frame.

On a loaded machine Chromium's screencast can skip frames the compositor
still presents. Pass when the compositor presented each frame (epoch ms on the
page clock, from a trace of `Display::FrameDisplayed`) as `presented`. A gap between two moved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No harness instrument records Display::FrameDisplayed at this head (startCompositorTrace left with the split), so a proof author cannot follow this sentence. Saying that presented comes from the proof's own trace until the recorder lands would fix it.

frames with nothing delivered inside it is then measured between the
compositor's own frames: the frames it presented there count as presented
(`recorderSkipped`, in the summary), and a gap it did not present through is
a stall as before. A gap that opens or closes the judged part, or holds a
delivered frame that did not move, is judged from the screencast, so a
frozen element fails whatever else the compositor draws (a rAF animation's
block presents no compositor frame at all). With the record, the 90% counts the
compositor's frames from the first to the last moved frame; the gaps before and
after are the stall check's. Without a record (WebKit has no such trace), the
screencast is judged alone.

**Storyboards.** Deterministic: frames come from seeking held animations or
from stepping scripted input, never from wall-clock capture. The JSON trace
carries each frame's measurements and the spec asserts on it; the sheet is
evidence. A component that finishes an animation from a timer (a transitionend
backstop, a cleanup render) would do so behind a held storyboard's back:
`installTimerGate` before the animation starts and `freezeTimers` while
photographing. The page can still cancel a held callback, a held frame callback
gets the time it actually runs, and the harness's own frame waits are not held.
gets the time it actually runs, and the harness's own waits (its frame waits,
`blockMainThread` with `during`) are not held.
An animation that repeats forever does not set a storyboard's default length;
with nothing else to film, pass `times`. A touch filmstrip keeps the scripted
event `timeStamp`s (Chromium), but a handler that reads `performance.now()` or
`Date.now()` sees one event per photograph, about 100 ms apart, so a
clock-timed release flings slower when filmed than when played: a storyboard
cannot show such a gesture; record it in real time instead. Chromium can return
an empty copy of the page's surface right after it changes ("Unable to capture
screenshot"); a storyboard takes that photograph again two frames later, up to
three attempts in all, and lists each retake under `recaptures` in the trace.
cannot show such a gesture; record it in real time instead, with
`startScreencast`. Chromium can return an empty copy of
the page's surface right after it changes ("Unable to capture screenshot"); a
storyboard takes that photograph again two frames later, up to three attempts
in all, and lists each retake under `recaptures` in the trace.

## Engine limits (measured with Playwright 1.61)

Expand All @@ -126,11 +199,13 @@ three attempts in all, and lists each retake under `recaptures` in the trace.
| Lifting one finger while others stay down | yes | pointer events right, but that `touchend` lists no remaining `touches`: skip with `PARTIAL_LIFT_SKIP` |
| Click synthesised from a touch | yes | only from `driver.tap` (WebKit's tap gesture), not from a raw touch sequence |
| Curve sampling, storyboards, input log | yes | yes |
| Screencast frame evidence (busy main thread) | yes | yes (WebKit's own screencast; frames keep coming while the main thread is blocked) |

What WebKit cannot show here — native scroll hand-off, flings, a finger
lifting while another stays down, and anything iOS-only — needs Mobile Safari
itself, on a device or the iOS simulator. Skip such a proof on the driver's
flag, citing the reason: `test.skip(!driver.partialLifts, PARTIAL_LIFT_SKIP)`.
lifting while another stays down, finger tracking under a busy main thread, and
anything iOS-only — needs Mobile Safari itself, on a device or the iOS
simulator. Skip such a proof on the driver's flag, citing the reason:
`test.skip(!driver.partialLifts, PARTIAL_LIFT_SKIP)`.

The WebKit input command is reached through Playwright's in-process server
object, so it needs a locally launched browser (the default), not `connect()`.
Expand Down
2 changes: 1 addition & 1 deletion internal/interaction-harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Internal: Playwright helpers for touch interaction and animation proofs (real touch input, curve sampling, storyboards). Never published.",
"description": "Internal: Playwright helpers for touch interaction and animation proofs (real touch input, frame evidence, curve sampling, storyboards). Never published.",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
Expand Down
Loading
Loading