From dc6ae1ef4b4588e04d06ecd5ced3aef90b9282b5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 23 Oct 2026 20:05:01 -0700 Subject: [PATCH 1/9] test(interaction): real touch input for browser interaction proofs Adds internal/interaction-harness (@astryxdesign/interaction-harness) and playwright.interaction.config.ts: phone-sized touch projects in Chromium and WebKit for *.interaction.browser.spec.ts specs. Touch scripts are data (tap, drag with hold and rest, flick with an exact release velocity and velocity profiles, hold-then-drag, pinch, rotate, any path, a second finger mid-gesture), played as real engine input: CDP Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own input command in WebKit. The harness's self-test proves the timestamps, a native fling and multi-touch delivery; a pr-interaction CI job runs the proofs against each PR's Storybook build, and a container script runs the WebKit project where host libraries are missing. --- .github/scripts/ci-test-routing.test.mjs | 49 +++ .github/workflows/ci.yml | 88 ++++ .github/workflows/deploy.yml | 3 + internal/README.md | 13 +- internal/interaction-harness/README.md | 67 +++ internal/interaction-harness/package.json | 21 + .../scripts/webkit-container.mjs | 72 ++++ .../src/harness.interaction.browser.spec.ts | 168 ++++++++ internal/interaction-harness/src/index.ts | 17 + .../interaction-harness/src/scripts.test.ts | 142 +++++++ internal/interaction-harness/src/scripts.ts | 395 ++++++++++++++++++ internal/interaction-harness/src/story.ts | 103 +++++ internal/interaction-harness/src/touch.ts | 295 +++++++++++++ internal/interaction-harness/tsconfig.json | 4 + package.json | 1 + playwright.interaction.config.ts | 77 ++++ pnpm-lock.yaml | 15 + 17 files changed, 1524 insertions(+), 6 deletions(-) create mode 100644 internal/interaction-harness/README.md create mode 100644 internal/interaction-harness/package.json create mode 100644 internal/interaction-harness/scripts/webkit-container.mjs create mode 100644 internal/interaction-harness/src/harness.interaction.browser.spec.ts create mode 100644 internal/interaction-harness/src/index.ts create mode 100644 internal/interaction-harness/src/scripts.test.ts create mode 100644 internal/interaction-harness/src/scripts.ts create mode 100644 internal/interaction-harness/src/story.ts create mode 100644 internal/interaction-harness/src/touch.ts create mode 100644 internal/interaction-harness/tsconfig.json create mode 100644 playwright.interaction.config.ts diff --git a/.github/scripts/ci-test-routing.test.mjs b/.github/scripts/ci-test-routing.test.mjs index 3f98eef8df4b8..1128817685567 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -588,3 +588,52 @@ describe('deploy.yml push gating', () => { } }); }); + +describe('ci.yml interaction proofs', () => { + const workflow = load('ci.yml'); + const job = workflow.jobs['pr-interaction']; + const step = name => job.steps.find(candidate => candidate.name === name); + + it('runs the interaction config in Chromium against the exact PR head', () => { + expect(KNOWN_RUNNERS.has(job['runs-on'])).toBe(true); + expect(job.needs).toEqual( + expect.arrayContaining(['check-scope', 'build-storybook']), + ); + const checkout = job.steps.find(candidate => + candidate.uses?.startsWith('actions/checkout@'), + ); + expect(checkout?.with?.ref).toBe( + '${{ github.event.pull_request.head.sha }}', + ); + const run = step('Run interaction proofs').run; + expect(run).toContain('--config playwright.interaction.config.ts'); + expect(run).toContain('--project chromium'); + const packageJson = JSON.parse(read('package.json')); + expect(packageJson.scripts['test:interaction']).toBe( + 'playwright test --config playwright.interaction.config.ts', + ); + }); + + it('runs whenever the harness, its config, a component or a story changes', () => { + const scope = step('Check interaction scope').run; + expect(scope).toContain('.github/scripts/pr-scope-touches.sh'); + for (const path of [ + 'internal/interaction-harness/', + 'playwright\\.interaction\\.config\\.ts', + 'packages/[^/]+/src/', + 'apps/storybook/', + ]) { + expect(scope).toContain(path); + } + // Unknown scope (exit 2) runs the proofs; only "out of scope" skips them. + expect(scope).toContain('if [ "$scope" = 1 ]; then'); + }); + + it('typechecks the harness in both workflows', () => { + for (const file of ['ci.yml', 'deploy.yml']) { + expect(runLines(load(file).jobs.typecheck), file).toContain( + '@astryxdesign/interaction-harness typecheck', + ); + } + }); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c4430a1336b8..8cc8e7d9a951e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1252,6 +1252,12 @@ jobs: if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' run: pnpm -F @astryxdesign/native typecheck:types + # The interaction harness is consumed as TypeScript source by Vitest and + # Playwright, which do not typecheck; this is the only gate over it. + - name: Typecheck interaction harness + if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' + run: pnpm -F @astryxdesign/interaction-harness typecheck + # Sandbox preview build — the single slowest piece of PR CI (~4min), run as # its own job so it overlaps build-storybook instead of serializing after it. # PR-only: merge_group runs never deploy previews, and docsite-only PRs skip @@ -3764,6 +3770,88 @@ jobs: retention-days: 30 delete-merged: true + # Touch interaction and animation proofs: the harness in + # internal/interaction-harness and every *.interaction.browser.spec.ts. + # Real touch input, composited frames, rendering traces and presented + # animation values only exist in a real engine, and the specs read stories + # from this PR's own Storybook build. The harness's self-test runs with + # them, so a change to an instrument is proven by its negative controls + # before a component spec relies on it. Chromium only on these runners; + # see the harness README for running WebKit. + pr-interaction: + needs: [check-scope, build-storybook] + if: github.event_name == 'pull_request' && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' + # Frame evidence measures timing: four cores, one Playwright worker. + runs-on: 4-core-ubuntu + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Verify evidence checkout + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" + + # The helper comes from the base, since an older PR head lacks it. + # Exit 1 means out of scope; 0 in scope; 2 unknown, so run. + - name: Check interaction scope + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + scope=2 + helper="$(mktemp)" + if git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \ + && git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then + scope=0 + bash "$helper" "$BASE_SHA" '^(internal/interaction-harness/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|apps/storybook/)' || scope=$? + fi + if [ "$scope" = 1 ]; then + echo "Neither the interaction harness nor a component changed; skipping the interaction proofs." + echo 'run=false' >> "$GITHUB_OUTPUT" + else + echo 'run=true' >> "$GITHUB_OUTPUT" + fi + + - name: Setup Node and pnpm + if: steps.scope.outputs.run == 'true' + uses: ./.github/actions/setup + with: + install: 'false' + + - name: Download Storybook artifact + if: steps.scope.outputs.run == 'true' + uses: actions/download-artifact@v8 + with: + name: storybook-${{ needs.build-storybook.outputs.short_hash }} + path: apps/storybook/dist + + - name: Install Playwright + if: steps.scope.outputs.run == 'true' + run: pnpm install --frozen-lockfile && npx playwright install chromium + + - name: Run interaction proofs + if: steps.scope.outputs.run == 'true' + run: | + pnpm exec playwright test \ + --config playwright.interaction.config.ts \ + --project chromium \ + --output interaction-proof-output + + # Storyboards (PNG contact sheets) and curve traces, for review. + - name: Upload interaction evidence + if: always() && steps.scope.outputs.run == 'true' + uses: actions/upload-artifact@v7 + with: + name: interaction-evidence + path: test-results/interaction-evidence + if-no-files-found: ignore + retention-days: 7 + # Build package test owner. The plugin's production CSS partition is a Build # integration contract: source inspection cannot prove which declaration # paints after Vite emits the final bundle. The `test-build` name makes that diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3eaee6914b595..f3b572c1dd629 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -132,6 +132,9 @@ jobs: - name: Typecheck accessibility spec-test contracts run: pnpm -F @astryxdesign/a11y-spec typecheck + - name: Typecheck interaction harness + run: pnpm -F @astryxdesign/interaction-harness typecheck + # Stable join for the three post-merge validation lanes. test: needs: [test-ui, test-node, typecheck] diff --git a/internal/README.md b/internal/README.md index 101fd8b07c28f..650fbd245d1a6 100644 --- a/internal/README.md +++ b/internal/README.md @@ -4,9 +4,10 @@ Internal tooling packages not published to npm. -| Directory | Role | Purpose | -| ------------- | ------- | ----------------------------------------------------------------------- | -| `a11y-spec/` | Testing | Reusable accessibility spec-test contracts and their evidence harnesses | -| `scripts/` | Tooling | One-off automation scripts (Crowdin screenshot upload, etc.) | -| `test-utils/` | Testing | Shared testing utilities and setup for Vitest | -| `vibe-tests/` | Testing | LLM vibeability test harness for component system | +| Directory | Role | Purpose | +| ---------------------- | ------- | --------------------------------------------------------------------------------- | +| `a11y-spec/` | Testing | Reusable accessibility spec-test contracts and their evidence harnesses | +| `interaction-harness/` | Testing | Playwright instruments for touch interaction and animation proofs and storyboards | +| `scripts/` | Tooling | One-off automation scripts (Crowdin screenshot upload, etc.) | +| `test-utils/` | Testing | Shared testing utilities and setup for Vitest | +| `vibe-tests/` | Testing | LLM vibeability test harness for component system | diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md new file mode 100644 index 0000000000000..a0b56d854c6b0 --- /dev/null +++ b/internal/interaction-harness/README.md @@ -0,0 +1,67 @@ +# @astryxdesign/interaction-harness + +Playwright instruments for touch interaction proofs: real touch input. +**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. + +## Running + +```sh +pnpm storybook:build # component specs read stories from the built Storybook +pnpm exec playwright install chromium webkit +pnpm test:interaction --project chromium # everything, Chromium +pnpm test:interaction # one spec, both engines +``` + +On Linux distributions whose system libraries are older than Playwright's +WebKit build needs, run the WebKit project inside the official Playwright +image (podman or docker; the repository is mounted at the same path): + +```sh +node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…] +``` + +## Instruments + +| Module | What it gives a spec | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact release velocity, 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. | +| `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`. | + +## Real touch + +Untrusted DOM events are not input: they start no native scroll, claim no +gesture, and read `isTrusted: false`. Chromium input goes through CDP +`Input.dispatchTouchEvent` with an explicit timestamp per event: the page's +`event.timeStamp` is the scripted time to the microsecond, whatever the +dispatch latency, and the events go through the compositor's gesture detection +(native scrolling, flings with velocity from those timestamps, `touch-action`, +`pointercancel`). WebKit input goes through the engine's own input command; see +the limits below. Read positions a frame after a move: Chromium delivers +`pointermove` aligned to animation frames. + +## Engine limits (measured with Playwright 1.61) + +| | Chromium 149 | WebKit 26.5 (Linux build) | +| ------------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------- | +| Trusted touch + pointer events, multi-touch | yes | yes (a `touchstart` lists only the fingers that landed in it; pointer events exact) | +| Page sees the scripted timestamps | yes, exactly | no: real dispatch time; real-time pacing keeps velocities within ~1 ms | +| Sub-pixel touch positions | yes | no: integer CSS px | +| Native scroll, fling, pinch-zoom from injected touches | yes | no | +| Click synthesised from a touch | yes | only from `driver.tap` (WebKit's tap gesture), not from a raw touch sequence | +| Input log | yes | yes | + +What WebKit cannot show here — native scroll hand-off, flings, and anything +iOS-only — needs Mobile Safari itself, on a device or the iOS simulator. + +The WebKit input command is reached through Playwright's in-process server +object, so it needs a locally launched browser (the default), not `connect()`. +Firefox has no touch input path and no project. diff --git a/internal/interaction-harness/package.json b/internal/interaction-harness/package.json new file mode 100644 index 0000000000000..08b1d02f61634 --- /dev/null +++ b/internal/interaction-harness/package.json @@ -0,0 +1,21 @@ +{ + "name": "@astryxdesign/interaction-harness", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Internal: Playwright helpers for touch interaction proofs (real touch input). Never published.", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./scripts": "./src/scripts.ts" + }, + "scripts": { + "typecheck": "tsc --project tsconfig.json --noEmit" + }, + "devDependencies": { + "@astryxdesign/a11y-spec": "workspace:*", + "@playwright/test": "^1.61.1", + "typescript": "^6.0.3", + "vitest": "^4.1.11" + } +} diff --git a/internal/interaction-harness/scripts/webkit-container.mjs b/internal/interaction-harness/scripts/webkit-container.mjs new file mode 100644 index 0000000000000..32cc0452396df --- /dev/null +++ b/internal/interaction-harness/scripts/webkit-container.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Runs the interaction proofs' WebKit project inside the official Playwright + * container image, for hosts where WebKit cannot launch (Playwright's Linux + * WebKit build needs newer system libraries than some distributions ship). + * + * node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…] + * + * The repository is mounted at the same path, so node_modules, the built + * Storybook and the evidence directory are the host's own. The image tag + * matches the installed playwright, so its browsers match the client. + * Uses podman when present, else docker. `--network=host` lets the specs' + * local Storybook server and the browser reach each other. + */ +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..', +); +const require = createRequire(path.join(root, 'package.json')); +const {version} = JSON.parse( + fs.readFileSync(require.resolve('playwright/package.json'), 'utf8'), +); +const image = + process.env.PLAYWRIGHT_IMAGE ?? + `mcr.microsoft.com/playwright:v${version}-noble`; +const engine = ['podman', 'docker'].find( + cmd => spawnSync(cmd, ['--version'], {stdio: 'ignore'}).status === 0, +); +if (engine == null) { + console.error('webkit-container: neither podman nor docker is installed'); + process.exit(1); +} +const args = process.argv.slice(2); +const env = [ + 'ASTRYX_STORYBOOK_DIR', + 'ASTRYX_INTERACTION_EVIDENCE', + 'INTERACTION_WORKERS', + 'CI', +] + .filter(name => process.env[name] != null) + .flatMap(name => ['-e', `${name}=${process.env[name]}`]); +const command = [ + 'run', + '--rm', + '--network=host', + '--ipc=host', + '-v', + `${root}:${root}`, + '-w', + root, + ...env, + image, + 'node', + path.join(root, 'node_modules/@playwright/test/cli.js'), + 'test', + '--config', + 'playwright.interaction.config.ts', + '--project', + 'webkit', + ...args, +]; +console.log(`${engine} ${command.join(' ')}`); +const result = spawnSync(engine, command, {stdio: 'inherit'}); +process.exit(result.status ?? 1); diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts new file mode 100644 index 0000000000000..10d41e668c9bf --- /dev/null +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -0,0 +1,168 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file harness.interaction.browser.spec.ts + * @input Fixture pages written inline (no Storybook), real Chromium/WebKit + * @output Proof that each touch instrument delivers real engine input: + * scripted timestamps reach the page; a flick flings natively (Chromium); + * a second finger and a pinch arrive as distinct pointers. + * @position The harness's own test. A component spec trusts these + * instruments; this file is why it may. + */ + +import {expect, test, type Page} from '@playwright/test'; +import { + drag, + flick, + installInputLog, + merge, + pinch, + readInputLog, + tap, + touchDriver, + withFinger, +} from './index'; + +const FIXTURE = ` + +
+
+
${Array.from({length: 200}, (_, i) => `
row ${i}
`).join('')}
`; + +// A real navigation (not setContent, whose document.open() erases the window +// listeners the input log installs). +async function fixture(page: Page) { + await installInputLog(page); + await page.route('http://harness.test/', route => + route.fulfill({contentType: 'text/html', body: FIXTURE}), + ); + await page.goto('http://harness.test/'); +} + +test.describe('touch input', () => { + test('the page receives trusted touches and pointers at the scripted positions and times', async ({ + page, + browserName, + }) => { + await fixture(page); + const driver = await touchDriver(page); + const script = drag( + {x: 100, y: 100}, + {x: 100, y: 220}, + {durationMs: 200, hz: 60, restMs: 120}, + ); + await driver.play(script); + const log = await readInputLog(page); + const moves = log.filter(r => r.type === 'pointermove'); + const down = log.find(r => r.type === 'pointerdown'); + const up = log.find(r => r.type === 'pointerup'); + expect(down?.isTrusted).toBe(true); + expect(down?.pointerType).toBe('touch'); + expect(up, JSON.stringify(log.map(r => r.type))).toBeDefined(); + // (Not `cancelable`: with only passive listeners an engine may dispatch + // touchmove as non-blocking, which reads cancelable false.) + expect( + log.filter(r => r.type === 'touchmove').every(r => r.isTrusted), + ).toBe(true); + expect(moves.at(-1)?.y).toBe(220); + if (browserName === 'chromium') { + // The scripted 16.67 ms spacing arrives exactly, and the 120 ms rest + // before the lift is in the timestamps too. + const deltas = moves + .slice(1) + .map((m, i) => m.timeStamp - moves[i].timeStamp); + for (const delta of deltas) { + expect(delta).toBeCloseTo(1000 / 60, 0); + } + expect( + (up?.timeStamp ?? NaN) - (moves.at(-1)?.timeStamp ?? NaN), + ).toBeCloseTo(120, 0); + } else { + // WebKit stamps the real dispatch time: real-time pacing keeps it close. + const deltas = moves + .slice(1) + .map((m, i) => m.timeStamp - moves[i].timeStamp); + const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length; + expect(mean).toBeGreaterThan(14); + expect(mean).toBeLessThan(20); + } + }); + + test('a flick flings a native scroller, farther for a faster finger', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'injected WebKit touches drive no native scrolling (README: engine limits)', + ); + await fixture(page); + const driver = await touchDriver(page); + const scroller = page.locator('#scroller'); + const distanceFor = async (velocity: number) => { + await scroller.evaluate(el => (el.scrollTop = 0)); + await driver.play( + flick( + {x: 200, y: 680}, + {velocity: {x: 0, y: -velocity}, durationMs: 60, hz: 120}, + ), + ); + await expect + .poll( + async () => { + const a = await scroller.evaluate(el => el.scrollTop); + await page.waitForTimeout(150); + return a === (await scroller.evaluate(el => el.scrollTop)); + }, + {timeout: 8_000}, + ) + .toBe(true); + return scroller.evaluate(el => el.scrollTop); + }; + const slow = await distanceFor(800); + const fast = await distanceFor(2400); + expect(slow).toBeGreaterThan(48); // it coasted past the finger's own travel + expect(fast).toBeGreaterThan(slow * 1.8); + }); + + test('a second finger and a pinch arrive as distinct touch pointers', async ({ + page, + }) => { + await fixture(page); + const driver = await touchDriver(page); + const first = drag( + {x: 80, y: 80}, + {x: 80, y: 200}, + {durationMs: 300, hz: 60}, + ); + const second = withFinger(tap({x: 300, y: 120}, {holdMs: 100}), 1, 120); + await driver.play(merge(first, second)); + const downs = (await readInputLog(page, {clear: true})).filter( + r => r.type === 'pointerdown', + ); + expect(downs).toHaveLength(2); + expect(new Set(downs.map(d => d.pointerId)).size).toBe(2); + + await driver.play( + pinch({ + center: {x: 200, y: 150}, + fromDistance: 60, + toDistance: 200, + durationMs: 200, + }), + ); + const log = await readInputLog(page); + expect(log.filter(r => r.type === 'pointerdown')).toHaveLength(2); + expect( + Math.max( + ...log.filter(r => r.type === 'touchmove').map(r => r.touches ?? 0), + ), + ).toBe(2); + }); +}); diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts new file mode 100644 index 0000000000000..3216b3c084322 --- /dev/null +++ b/internal/interaction-harness/src/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file index.ts + * @input Re-exports the harness modules + * @output The `@astryxdesign/interaction-harness` entry point for Playwright + * specs. Pure helpers are also reachable without Playwright through + * `@astryxdesign/interaction-harness/math` and `/scripts` (unit tests). + * @position Imported only from `*.interaction.browser.spec.ts` files (run by + * playwright.interaction.config.ts) and from this package's own tests. + * + * SYNC: When a module is added, export it here and describe it in README.md. + */ + +export * from './scripts'; +export * from './story'; +export * from './touch'; diff --git a/internal/interaction-harness/src/scripts.test.ts b/internal/interaction-harness/src/scripts.test.ts new file mode 100644 index 0000000000000..b85d4b526de55 --- /dev/null +++ b/internal/interaction-harness/src/scripts.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, expect, it} from 'vitest'; +import { + drag, + flick, + holdThenDrag, + merge, + path, + pinch, + releaseVelocity, + rotate, + tap, + toTouchEvents, + withFinger, +} from './scripts'; + +describe('touch scripts', () => { + it('tap presses and lifts in place', () => { + expect( + toTouchEvents(tap({x: 5, y: 6}, {holdMs: 80})).map(e => [e.t, e.type]), + ).toEqual([ + [0, 'touchStart'], + [80, 'touchEnd'], + ]); + }); + + it('drag moves at 60 Hz, holds, rests and lifts', () => { + const script = drag( + {x: 0, y: 0}, + {x: 0, y: 100}, + {durationMs: 100, hz: 60, holdMs: 500, restMs: 150}, + ); + const events = toTouchEvents(script); + expect(events[0]).toMatchObject({t: 0, type: 'touchStart'}); + const moves = events.filter(e => e.type === 'touchMove'); + expect(moves).toHaveLength(6); + expect(moves[0].t).toBeCloseTo(516.667, 3); + expect(moves.at(-1)).toMatchObject({ + t: 600, + fingers: [{id: 0, x: 0, y: 100}], + }); + expect(events.at(-1)).toMatchObject({ + t: 750, + type: 'touchEnd', + fingers: [], + }); + // A rest of 100 ms or more releases nothing in motion. + expect(releaseVelocity(script)).toEqual({x: 0, y: 0}); + }); + + it('flick releases at exactly the scripted velocity', () => { + const script = flick( + {x: 10, y: 10}, + {velocity: {x: 0, y: 1500}, durationMs: 80, hz: 120}, + ); + expect(releaseVelocity(script).y).toBeCloseTo(1500, 6); + const last = script.at(-2)!; + expect(last.fingers[0].y).toBeCloseTo(10 + 1500 * 0.08, 6); + // The lift lands a quarter frame after the last move, still moving. + expect(script.at(-1)!.t - last.t).toBeCloseTo(1000 / 120 / 4, 6); + }); + + it('flick follows a velocity profile', () => { + const script = flick( + {x: 0, y: 0}, + {velocity: {x: 1000, y: 0}, durationMs: 100, hz: 100, profile: p => p}, + ); + // Linear ramp: distance = v * T / 2. + expect(script.at(-2)!.fingers[0].x).toBeCloseTo(50, 6); + }); + + it('pinch and rotate keep two fingers about the centre', () => { + const spread = pinch({ + center: {x: 100, y: 100}, + fromDistance: 40, + toDistance: 200, + durationMs: 100, + }); + const end = spread.at(-2)!.fingers; + expect(Math.hypot(end[1].x - end[0].x, end[1].y - end[0].y)).toBeCloseTo( + 200, + 6, + ); + const turn = rotate({ + center: {x: 0, y: 0}, + distance: 100, + fromAngle: 0, + toAngle: 90, + durationMs: 100, + }); + expect(turn.at(-2)!.fingers[1].x).toBeCloseTo(0, 6); + expect(turn.at(-2)!.fingers[1].y).toBeCloseTo(50, 6); + expect(toTouchEvents(spread)[0]).toMatchObject({ + type: 'touchStart', + changed: [{id: 0}, {id: 1}], + }); + }); + + it('merge lands a second finger mid-drag and lifts it first', () => { + const first = drag({x: 0, y: 0}, {x: 0, y: 90}, {durationMs: 300, hz: 10}); + const second = withFinger(tap({x: 50, y: 50}, {holdMs: 100}), 1, 120); + const events = toTouchEvents(merge(first, second)); + const starts = events.filter(e => e.type === 'touchStart'); + expect( + starts.map(e => [e.t, e.changed.map(f => f.id), e.fingers.length]), + ).toEqual([ + [0, [0], 1], + [120, [1], 2], + ]); + const ends = events.filter(e => e.type === 'touchEnd'); + expect(ends[0]).toMatchObject({t: 220, changed: [{id: 1}]}); + expect(ends[0].fingers.map(f => f.id)).toEqual([0]); + expect(ends.at(-1)!.fingers).toEqual([]); + expect(() => merge(first, first)).toThrow(/two scripts/); + }); + + it('path follows any curve; holdThenDrag holds before the first move', () => { + const arc = path( + p => ({x: 100 * Math.cos(p * Math.PI), y: 100 * Math.sin(p * Math.PI)}), + {durationMs: 100, hz: 40}, + ); + const last = arc.at(-2)!.fingers[0]; + expect(last.x).toBeCloseTo(-100, 6); + expect(last.y).toBeCloseTo(0, 6); + expect(arc.at(-1)!.fingers).toEqual([]); + const held = holdThenDrag( + {x: 0, y: 0}, + {x: 30, y: 0}, + {durationMs: 50, hz: 20, holdMs: 500}, + ); + const moves = toTouchEvents(held).filter(e => e.type === 'touchMove'); + expect(moves[0].t).toBeCloseTo(550, 6); + expect(moves.at(-1)!.fingers[0].x).toBeCloseTo(30, 6); + }); + + it('refuses a script that leaves a finger down', () => { + expect(() => + toTouchEvents([{t: 0, fingers: [{id: 0, x: 0, y: 0}]}]), + ).toThrow(/lifted/); + }); +}); diff --git a/internal/interaction-harness/src/scripts.ts b/internal/interaction-harness/src/scripts.ts new file mode 100644 index 0000000000000..111d9e01234aa --- /dev/null +++ b/internal/interaction-harness/src/scripts.ts @@ -0,0 +1,395 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file scripts.ts + * @input Nothing: pure functions + * @output Touch scripts — timed lists of the fingers that are down — and the + * builders that make them: `tap`, `drag`, `flick`, `holdThenDrag`, `pinch`, + * `rotate`, `path`, `withFinger`, `merge`, plus `toTouchEvents`, which turns + * a script into the per-event sequence a touch driver dispatches. + * @position The input half of every interaction proof. A script is data, so a + * test states exactly what the finger does — positions and timestamps — and + * a unit test can check a builder without a browser. + * + * A script is a list of frames ordered by time. Each frame lists every finger + * that is down at that moment; a finger missing from a frame has lifted. The + * last frame is always empty (every finger up). Times are milliseconds from + * the gesture's start; coordinates are CSS px in the viewport. + * + * SYNC: Keep ./scripts.test.ts covering every builder. + */ + +export interface Point { + readonly x: number; + readonly y: number; +} + +export interface Finger extends Point { + /** Stable per finger for the whole gesture. */ + readonly id: number; +} + +export interface TouchFrame { + /** Milliseconds from the start of the gesture. */ + readonly t: number; + /** Every finger that is down at `t`. Empty = all lifted. */ + readonly fingers: readonly Finger[]; +} + +export type TouchScript = readonly TouchFrame[]; + +/** One dispatchable event: what changed at `t`, and the fingers down after it. */ +export interface TouchEventStep { + readonly t: number; + readonly type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel'; + /** Fingers down after this event (CDP semantics: the full active set). */ + readonly fingers: readonly Finger[]; + /** Fingers this event pressed, moved or released. */ + readonly changed: readonly Finger[]; +} + +const DEFAULT_HZ = 60; + +function frameTimes(durationMs: number, hz: number): number[] { + const step = 1000 / hz; + const count = Math.max(1, Math.round(durationMs / step)); + return Array.from( + {length: count}, + (_, i) => +(((i + 1) * durationMs) / count).toFixed(3), + ); +} + +function lerp(a: Point, b: Point, p: number): Point { + return {x: a.x + (b.x - a.x) * p, y: a.y + (b.y - a.y) * p}; +} + +/** A press and lift in place. */ +export function tap( + at: Point, + {holdMs = 60, id = 0}: {holdMs?: number; id?: number} = {}, +): TouchScript { + return [ + {t: 0, fingers: [{id, ...at}]}, + {t: holdMs, fingers: []}, + ]; +} + +export interface DragOptions { + /** Time from the first move to the last, ms. */ + readonly durationMs: number; + /** Move samples per second (iOS reports touches at 60 Hz, 120 Hz on ProMotion). */ + readonly hz?: number; + /** Rest before the first move (press-and-hold), ms. */ + readonly holdMs?: number; + /** Rest after the last move before the lift, ms: >= 100 makes the release velocity 0. */ + readonly restMs?: number; + /** Progress easing over the move, default linear (constant velocity). */ + readonly easing?: (progress: number) => number; + readonly id?: number; +} + +/** Press at `from`, move to `to`, lift. Constant velocity unless `easing` says otherwise. */ +export function drag( + from: Point, + to: Point, + options: DragOptions, +): TouchScript { + const { + durationMs, + hz = DEFAULT_HZ, + holdMs = 0, + restMs = 0, + easing = (p: number) => p, + id = 0, + } = options; + const frames: TouchFrame[] = [{t: 0, fingers: [{id, ...from}]}]; + for (const t of frameTimes(durationMs, hz)) { + frames.push({ + t: holdMs + t, + fingers: [{id, ...lerp(from, to, easing(t / durationMs))}], + }); + } + // A real finger reports no move while it rests: the lift simply comes later. + const last = frames[frames.length - 1].t; + frames.push({t: last + Math.max(restMs, 1000 / hz / 4), fingers: []}); + return frames; +} + +/** `drag` after a press-and-hold (a long press that turns into a drag). */ +export function holdThenDrag( + from: Point, + to: Point, + options: DragOptions & {readonly holdMs: number}, +): TouchScript { + return drag(from, to, options); +} + +export interface FlickOptions { + /** Velocity at the lift, px/s. */ + readonly velocity: Point; + /** How long the finger moves before the lift, ms. */ + readonly durationMs?: number; + readonly hz?: number; + /** + * Velocity over the move as a fraction of the final velocity (0..1 → 0..∞), + * default constant. `p => p` is a finger accelerating linearly into the lift. + */ + readonly profile?: (progress: number) => number; + readonly id?: number; +} + +/** + * A throw: the finger moves with the given velocity profile and lifts while + * still moving (the lift lands a quarter frame after the last move, as on a + * device), so the release velocity is exactly `velocity`. + */ +export function flick(from: Point, options: FlickOptions): TouchScript { + const { + velocity, + durationMs = 80, + hz = 120, + profile = () => 1, + id = 0, + } = options; + const step = 1000 / hz; + const frames: TouchFrame[] = [{t: 0, fingers: [{id, ...from}]}]; + let x = from.x; + let y = from.y; + let previous = 0; + for (const t of frameTimes(durationMs, hz)) { + // Integrate the profile over (previous, t] with the midpoint rule. + const scale = profile((previous + t) / 2 / durationMs); + x += (velocity.x * scale * (t - previous)) / 1000; + y += (velocity.y * scale * (t - previous)) / 1000; + previous = t; + frames.push({t, fingers: [{id, x, y}]}); + } + frames.push({t: previous + step / 4, fingers: []}); + return frames; +} + +/** Any single-finger path: `at(progress)` for progress 0..1 over `durationMs`. */ +export function path( + at: (progress: number) => Point, + { + durationMs, + hz = DEFAULT_HZ, + holdMs = 0, + restMs = 0, + id = 0, + }: Omit, +): TouchScript { + const frames: TouchFrame[] = [{t: 0, fingers: [{id, ...at(0)}]}]; + for (const t of frameTimes(durationMs, hz)) { + frames.push({t: holdMs + t, fingers: [{id, ...at(t / durationMs)}]}); + } + frames.push({ + t: frames[frames.length - 1].t + Math.max(restMs, 1000 / hz / 4), + fingers: [], + }); + return frames; +} + +export interface PinchOptions { + readonly center: Point; + /** Finger separation at the start and at the end, px. */ + readonly fromDistance: number; + readonly toDistance: number; + /** Angle of the line between the fingers, degrees (0 = horizontal). */ + readonly fromAngle?: number; + readonly toAngle?: number; + readonly durationMs: number; + readonly hz?: number; + readonly restMs?: number; +} + +/** Two fingers (ids 0 and 1) pressed together, spread or pinched and turned, lifted together. */ +export function pinch(options: PinchOptions): TouchScript { + const { + center, + fromDistance, + toDistance, + fromAngle = 0, + toAngle = fromAngle, + durationMs, + hz = DEFAULT_HZ, + restMs = 0, + } = options; + const at = (p: number): Finger[] => { + const distance = fromDistance + (toDistance - fromDistance) * p; + const angle = ((fromAngle + (toAngle - fromAngle) * p) * Math.PI) / 180; + const dx = (Math.cos(angle) * distance) / 2; + const dy = (Math.sin(angle) * distance) / 2; + return [ + {id: 0, x: center.x - dx, y: center.y - dy}, + {id: 1, x: center.x + dx, y: center.y + dy}, + ]; + }; + const frames: TouchFrame[] = [{t: 0, fingers: at(0)}]; + for (const t of frameTimes(durationMs, hz)) { + frames.push({t, fingers: at(t / durationMs)}); + } + frames.push({t: durationMs + Math.max(restMs, 1000 / hz / 4), fingers: []}); + return frames; +} + +/** Two fingers turning around `center` at a fixed separation. */ +export function rotate( + options: Omit & { + readonly distance: number; + }, +): TouchScript { + return pinch({ + ...options, + fromDistance: options.distance, + toDistance: options.distance, + }); +} + +/** Re-labels a single-finger script's finger, and shifts it in time. */ +export function withFinger( + script: TouchScript, + id: number, + delayMs = 0, +): TouchScript { + return script.map(frame => ({ + t: frame.t + delayMs, + fingers: frame.fingers.map(f => ({...f, id})), + })); +} + +/** + * Plays several scripts at once (each with its own finger ids): at every time + * any of them changes, each finger is where its own script last put it. + * Used for a second finger that lands mid-gesture. + */ +export function merge(...scripts: TouchScript[]): TouchScript { + const times = [...new Set(scripts.flatMap(s => s.map(f => f.t)))].sort( + (a, b) => a - b, + ); + const stateAt = (script: TouchScript, t: number): readonly Finger[] => { + let current: readonly Finger[] = []; + for (const frame of script) { + if (frame.t > t) { + break; + } + current = frame.fingers; + } + return current; + }; + const owner = new Map(); + scripts.forEach((script, index) => { + for (const frame of script) { + for (const f of frame.fingers) { + if ((owner.get(f.id) ?? index) !== index) { + throw new Error(`merge: finger ${f.id} is used by two scripts`); + } + owner.set(f.id, index); + } + } + }); + return times.map(t => ({t, fingers: scripts.flatMap(s => stateAt(s, t))})); +} + +/** + * Turns frames into events: a finger that appears is a `touchStart`, one that + * disappears a `touchEnd`, one that moved a `touchMove`. A frame that both + * moves some fingers and lifts others yields the move first, then the lift. + * Unchanged frames yield nothing (a resting finger reports nothing). + */ +export function toTouchEvents(script: TouchScript): TouchEventStep[] { + const events: TouchEventStep[] = []; + let down = new Map(); + for (const frame of script) { + const next = new Map(frame.fingers.map(f => [f.id, f])); + const pressed = frame.fingers.filter(f => !down.has(f.id)); + const released = [...down.values()].filter(f => !next.has(f.id)); + const moved = frame.fingers.filter(f => { + const before = down.get(f.id); + return before != null && (before.x !== f.x || before.y !== f.y); + }); + if (moved.length > 0) { + const after = [...down.values()].map(f => next.get(f.id) ?? f); + events.push({ + t: frame.t, + type: 'touchMove', + fingers: after, + changed: moved, + }); + down = new Map(after.map(f => [f.id, f])); + } + if (released.length > 0) { + const remaining = [...down.values()].filter(f => next.has(f.id)); + events.push({ + t: frame.t, + type: 'touchEnd', + fingers: remaining, + changed: released.map(f => down.get(f.id) ?? f), + }); + down = new Map(remaining.map(f => [f.id, f])); + } + if (pressed.length > 0) { + const all = [...down.values(), ...pressed]; + events.push({ + t: frame.t, + type: 'touchStart', + fingers: all, + changed: pressed, + }); + down = new Map(all.map(f => [f.id, f])); + } + } + if (down.size > 0) { + throw new Error( + 'toTouchEvents: a script must end with every finger lifted', + ); + } + return events; +} + +/** + * The velocity (px/s) of finger `id` at its lift: the slope over the last + * `windowMs` of moves, or zero when the finger rested `windowMs` or longer + * before lifting (a finger that rested released nothing in motion). + */ +export function releaseVelocity( + script: TouchScript, + id = 0, + windowMs = 100, +): Point { + const samples = script.flatMap(frame => + frame.fingers + .filter(f => f.id === id) + .map(f => ({t: frame.t, x: f.x, y: f.y})), + ); + if (samples.length < 2) { + return {x: 0, y: 0}; + } + const lift = script.find( + frame => frame.t > samples[0].t && !frame.fingers.some(f => f.id === id), + ); + // The last sample that actually moved: a resting finger reports nothing. + let lastMove = samples.length - 1; + while ( + lastMove > 0 && + samples[lastMove].x === samples[lastMove - 1].x && + samples[lastMove].y === samples[lastMove - 1].y + ) { + lastMove -= 1; + } + const last = samples[lastMove]; + if (lift != null && lift.t - last.t >= windowMs) { + return {x: 0, y: 0}; + } + const recent = samples + .slice(0, lastMove + 1) + .filter(s => s.t >= last.t - windowMs); + const first = recent[0]; + const dt = last.t - first.t; + return dt <= 0 + ? {x: 0, y: 0} + : { + x: ((last.x - first.x) / dt) * 1000, + y: ((last.y - first.y) / dt) * 1000, + }; +} diff --git a/internal/interaction-harness/src/story.ts b/internal/interaction-harness/src/story.ts new file mode 100644 index 0000000000000..9e4323436ff27 --- /dev/null +++ b/internal/interaction-harness/src/story.ts @@ -0,0 +1,103 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file story.ts + * @input Uses a Playwright `Page` and test object, and the a11y-spec package's + * static server over a built Storybook + * @output `phone` (the touch-phone browser context every interaction proof + * runs in), `withStorybook` (serve the built Storybook for a spec file), and + * `openStory` (load one story and wait until it has rendered). + * @position Stories are the reproduction path, as for every other browser + * check in this repository: a scenario used for verification is checked in, + * not built in a one-off page. + * + * The phone is an iPhone 17 Pro in Safari: 402 × 874 pt screen, 402 × 714 pt + * visible page with the toolbars shown, 3 device pixels per point, touch, + * mobile viewport rules. Measured on the iOS 26.2 simulator + * (`innerWidth`/`innerHeight`, `screen`, `devicePixelRatio`). + * + * SYNC: Keep `phone` equal to the projects in playwright.interaction.config.ts. + */ + +import type {Page, TestType} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +export const phone = { + viewport: {width: 402, height: 714}, + screen: {width: 402, height: 874}, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, +} as const; + +/** + * Serves the built Storybook for the spec file's tests (`ASTRYX_STORYBOOK_DIR` + * overrides the default `apps/storybook/dist`). Returns its origin getter. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- any fixture set +export function withStorybook(test: TestType): () => string { + let server: StaticServer | undefined; + test.beforeAll(async () => { + server = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); + }); + test.afterAll(async () => { + await server?.close(); + }); + return () => { + if (server == null) { + throw new Error( + 'withStorybook: the Storybook server is not running (call inside a test)', + ); + } + return server.origin; + }; +} + +/** Loads a story in the page and waits for its first render, its fonts and two frames. */ +export async function openStory( + page: Page, + origin: string, + storyId: string, + args?: Readonly>, +): Promise { + const params = new URLSearchParams({id: storyId, viewMode: 'story'}); + if (args != null) { + params.set( + 'args', + Object.entries(args) + .map(([k, v]) => `${k}:${v}`) + .join(';'), + ); + } + await page.goto(`${origin}/iframe.html?${params.toString()}`); + await page + .locator('#storybook-root > *') + .first() + .waitFor({state: 'attached'}); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); +} + +/** Resolves after `count` animation frames have been presented in the page. */ +export async function frames(page: Page, count = 2): Promise { + await page.evaluate( + n => + new Promise(resolve => { + let left = n; + const tick = () => + --left <= 0 ? resolve() : requestAnimationFrame(tick); + requestAnimationFrame(tick); + }), + count, + ); +} diff --git a/internal/interaction-harness/src/touch.ts b/internal/interaction-harness/src/touch.ts new file mode 100644 index 0000000000000..56cdf8aa50289 --- /dev/null +++ b/internal/interaction-harness/src/touch.ts @@ -0,0 +1,295 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touch.ts + * @input Uses a Playwright `Page` (Chromium or WebKit) and a touch script from + * ./scripts + * @output `touchDriver(page)` — plays touch scripts as REAL browser touch input + * — and `installInputLog` / `readInputLog`, the page-side record of what the + * page actually received. + * @position The input half of every interaction proof. Untrusted DOM events + * are not input: they never start native scrolling, never claim a gesture, + * and a component can tell them apart (`isTrusted`). Both drivers here go + * through the engine's own input pipeline instead. + * + * Chromium: CDP `Input.dispatchTouchEvent` with an explicit `timestamp` per + * event. The page's `event.timeStamp` IS that timestamp, whatever the + * dispatch latency, so a velocity profile reaches the page exactly. The + * events go through the compositor's gesture detection: native scrolling, + * flings (velocity from those timestamps), `touch-action`, pointer events + * and `pointercancel` all behave as for a finger. + * + * WebKit (Playwright's Linux build): the engine's own inspector command + * `Input.dispatchTouchEvent` on the page proxy. Touches and the pointer + * events derived from them are trusted and cancelable, multi-touch works. + * Limits, measured with WebKit 26.5: no timestamp parameter (the page sees + * the real dispatch time, so velocity comes from real-time pacing, ±1 ms); + * integer coordinates only; no native scrolling, fling or pinch-zoom from + * these touches; a `touchstart` lists only the fingers that landed in it + * (pointer events are exact); no click synthesised from a raw touch sequence (use + * `tap`, which dispatches WebKit's tap gesture and does produce the + * compatibility mouse events and click). The command is reached through + * Playwright's in-process server object, so it needs a locally launched + * browser (the default), not a remote `connect()`. + * + * SYNC: Keep the engine limits above equal to the README's table and to the + * harness self-test (../src/harness.interaction.browser.spec.ts). + */ + +import type {CDPSession, Page} from '@playwright/test'; +import { + toTouchEvents, + type Finger, + type Point, + type TouchEventStep, + type TouchScript, +} from './scripts'; + +export type Engine = 'chromium' | 'webkit'; + +export interface PlayOptions { + /** + * Pace dispatch on the wall clock so each event leaves at its scripted time + * (default true). Required in WebKit for a velocity to mean anything; in + * Chromium the page sees the scripted timestamps either way, but native + * behaviors that run on the compositor clock (a fling) want real pacing. + */ + readonly realtime?: boolean; + /** Called after each event is dispatched, before the next one (storyboards). */ + readonly onEvent?: ( + step: TouchEventStep, + index: number, + ) => Promise | void; +} + +export interface PlayedEvent extends TouchEventStep { + /** Wall-clock milliseconds (epoch) at which the event was dispatched. */ + readonly dispatchedAt: number; + /** The timestamp handed to the engine, epoch ms (Chromium only). */ + readonly timestamp: number | null; +} + +export interface TouchDriver { + readonly engine: Engine; + /** True when the page's `event.timeStamp` follows the script exactly. */ + readonly scriptedTimestamps: boolean; + /** True when these touches can scroll, fling and pinch-zoom natively. */ + readonly drivesNativeGestures: boolean; + /** Plays a script; resolves when its last event has been dispatched. */ + play(script: TouchScript, options?: PlayOptions): Promise; + /** A tap through the engine's tap path (WebKit synthesises click only from this). */ + tap(at: Point): Promise; +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +/** Waits until `performance.now()` reaches `deadline` with ~0.1 ms precision. */ +async function until(deadline: number): Promise { + const remaining = deadline - performance.now(); + if (remaining > 3) { + await sleep(remaining - 2); + } + while (performance.now() < deadline) { + // spin: setTimeout alone is ±1-4 ms late + } +} + +async function playSteps( + script: TouchScript, + options: PlayOptions, + dispatch: (step: TouchEventStep, timestamp: number) => Promise, + stampFor: (t: number) => number, +): Promise { + const {realtime = true, onEvent} = options; + const steps = toTouchEvents(script); + const played: PlayedEvent[] = []; + const origin = performance.now(); + for (const [index, step] of steps.entries()) { + if (realtime) { + await until(origin + step.t); + } + const timestamp = stampFor(step.t); + const dispatchedAt = Date.now(); + await dispatch(step, timestamp); + played.push({...step, dispatchedAt, timestamp}); + await onEvent?.(step, index); + } + return played; +} + +async function chromiumDriver(page: Page): Promise { + const cdp: CDPSession = await page.context().newCDPSession(page); + const touchPoints = (fingers: readonly Finger[]) => + fingers.map(f => ({ + x: f.x, + y: f.y, + id: f.id, + radiusX: 1, + radiusY: 1, + force: 1, + })); + return { + engine: 'chromium', + scriptedTimestamps: true, + drivesNativeGestures: true, + async play(script, options = {}) { + // 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(); + return playSteps( + script, + options, + async (step, timestamp) => { + // CDP diffs each event's active points against the previous event's: + // a missing point is released, a new one pressed. touchEnd must carry + // no points, so lifting one finger of several is a touchMove that + // lists the fingers still down. + const partialLift = + step.type === 'touchEnd' && step.fingers.length > 0; + await cdp.send('Input.dispatchTouchEvent', { + type: partialLift ? 'touchMove' : step.type, + touchPoints: + step.type === 'touchEnd' && !partialLift + ? [] + : touchPoints(step.fingers), + timestamp: timestamp / 1000, + }); + }, + t => base + t, + ); + }, + async tap(at) { + await page.touchscreen.tap(at.x, at.y); + }, + }; +} + +interface WebKitPageProxySession { + send(method: string, params?: Record): Promise; + on(event: string, listener: (payload: unknown) => void): unknown; + off?(event: string, listener: (payload: unknown) => void): unknown; +} + +/** + * Playwright's server-side WebKit page session, reached through the in-process + * connection. Exported for helpers that use WebKit's own inspector protocol. + */ +export function webkitPageSession(page: Page): WebKitPageProxySession { + const connection = ( + page as unknown as {_connection?: {toImpl?: (o: unknown) => unknown}} + )._connection; + const impl = connection?.toImpl?.(page) as + {delegate?: {_pageProxySession?: WebKitPageProxySession}} | undefined; + const session = impl?.delegate?._pageProxySession; + if (session == null) { + throw new Error( + "WebKit touch input needs Playwright's in-process server (a locally launched browser); this page has none.", + ); + } + return session; +} + +async function webkitDriver(page: Page): Promise { + const session = webkitPageSession(page); + // WebKit's command takes integer CSS px and applies the event's phase to + // every point it lists: a start lists only the fingers that land (listing a + // finger already down presses it again), an end only the fingers that lift + // (an empty list releases nothing), a move every finger down. + const points = (fingers: readonly Finger[]) => + fingers.map(f => ({x: Math.round(f.x), y: Math.round(f.y), id: f.id})); + return { + engine: 'webkit', + scriptedTimestamps: false, + drivesNativeGestures: false, + async play(script, options = {}) { + return playSteps( + script, + options, + async step => { + await session.send('Input.dispatchTouchEvent', { + type: step.type, + touchPoints: points( + step.type === 'touchMove' ? step.fingers : step.changed, + ), + }); + }, + () => Date.now(), + ).then(played => played.map(p => ({...p, timestamp: null}))); + }, + async tap(at) { + await page.touchscreen.tap(Math.round(at.x), Math.round(at.y)); + }, + }; +} + +/** A touch driver for the page's engine. Firefox is not supported (no touch input path). */ +export async function touchDriver(page: Page): Promise { + const name = page.context().browser()?.browserType().name(); + if (name === 'chromium') { + return chromiumDriver(page); + } + if (name === 'webkit') { + return webkitDriver(page); + } + throw new Error( + `touchDriver: no real touch input path for ${name ?? 'this browser'}`, + ); +} + +export interface InputRecord { + readonly type: string; + /** The event's own `timeStamp`, ms since the page's time origin. */ + readonly timeStamp: number; + readonly isTrusted: boolean; + readonly cancelable: boolean; + readonly defaultPrevented: boolean; + readonly pointerId?: number; + readonly pointerType?: string; + readonly x?: number; + readonly y?: number; + readonly touches?: number; + readonly target: string; +} + +const INPUT_LOG_SCRIPT = `(() => { + if (window.__harnessInput) return; + const log = []; + window.__harnessInput = log; + const describe = node => node instanceof Element + ? node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') + (node.getAttribute('aria-label') ? '[' + node.getAttribute('aria-label') + ']' : '') + : String(node && node.nodeName); + const types = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'touchstart', 'touchmove', + 'touchend', 'touchcancel', 'click', 'contextmenu', 'gotpointercapture', 'lostpointercapture']; + for (const type of types) { + // Bubble phase on window, so defaultPrevented reflects the page's own handlers. + window.addEventListener(type, event => { + const record = {type, timeStamp: event.timeStamp, isTrusted: event.isTrusted, cancelable: event.cancelable, + defaultPrevented: event.defaultPrevented, target: describe(event.target)}; + if ('pointerId' in event) Object.assign(record, {pointerId: event.pointerId, pointerType: event.pointerType, x: event.clientX, y: event.clientY}); + if (event.touches) Object.assign(record, {touches: event.touches.length, x: event.changedTouches[0] && event.changedTouches[0].clientX, y: event.changedTouches[0] && event.changedTouches[0].clientY}); + log.push(record); + }, {passive: true}); + } +})()`; + +/** Records every pointer/touch/click the page receives (install before navigation). */ +export async function installInputLog(page: Page): Promise { + await page.addInitScript(INPUT_LOG_SCRIPT); +} + +/** What the page received since navigation (or since the last `clear`). */ +export async function readInputLog( + page: Page, + {clear = false}: {clear?: boolean} = {}, +): Promise { + return page.evaluate(shouldClear => { + const log = + (window as unknown as {__harnessInput?: InputRecord[]}).__harnessInput ?? + []; + const copy = log.slice(); + if (shouldClear) { + log.length = 0; + } + return copy; + }, clear); +} diff --git a/internal/interaction-harness/tsconfig.json b/internal/interaction-harness/tsconfig.json new file mode 100644 index 0000000000000..596e2cf729a9f --- /dev/null +++ b/internal/interaction-harness/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/package.json b/package.json index 9052c34ad02d0..a4ca9f2ae830e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:coverage": "vitest --coverage", "test:a11y-contract": "playwright test --config playwright.config.ts --project chromium", "test:docsite-browser": "playwright test --config apps/docsite/playwright.config.ts", + "test:interaction": "playwright test --config playwright.interaction.config.ts", "check:package-boundaries": "node scripts/check-package-boundaries.js", "check:repo": "pnpm check:visual-owner && pnpm check:sync && pnpm check:neutral-palette-refs && pnpm check:stone-palette-refs && pnpm check:gothic-palette-refs && pnpm check:matcha-palette-refs && pnpm check:butter-palette-refs && pnpm check:y2k-palette-refs && pnpm check:data-tokens && pnpm check:token-docs && pnpm check:knowledge && pnpm check:package-boundaries && pnpm check:changesets && pnpm check:demo-media && pnpm check:executable-bits && pnpm check:cli-structure && pnpm check:use-client && pnpm check:portable-scripts && pnpm check:i18n-runtime && pnpm check:i18n-catalog && pnpm check:cldr-weekdays && pnpm check:fixtures && pnpm check:setup-contract", "check:visual-owner": "node scripts/check-visual-workflow-owner.mjs", diff --git a/playwright.interaction.config.ts b/playwright.interaction.config.ts new file mode 100644 index 0000000000000..357a5dc3b7642 --- /dev/null +++ b/playwright.interaction.config.ts @@ -0,0 +1,77 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file playwright.interaction.config.ts + * @input Uses @playwright/test + * @output Touch interaction proofs on a phone-sized touch screen, in Chromium + * and WebKit. + * @position Run with `pnpm test:interaction`. Separate from `pnpm test` (no + * browser needed there) and from playwright.config.ts (accessibility + * contracts on desktop profiles). + * + * Specs are named `*.interaction.browser.spec.ts` and read stories from a + * built Storybook, like the accessibility specs: + * + * pnpm storybook:build + * pnpm exec playwright install chromium webkit + * pnpm test:interaction --project chromium + * pnpm test:interaction harness.interaction.browser.spec.ts + * + * A spec that needs Chromium-only evidence (CDP tracing, scripted touch + * timestamps) skips itself in WebKit with the reason; see the harness README. + * Where WebKit cannot launch on the host (missing system libraries on some + * Linux distributions), internal/interaction-harness/scripts/webkit-container.mjs + * runs the WebKit project inside the official Playwright image. + * + * SYNC: Keep the phone profile here equal to `phone` in + * internal/interaction-harness/src/story.ts. + */ + +import {defineConfig} from '@playwright/test'; + +// The phone: an iPhone 17 Pro in Safari (402 × 874 pt screen, 402 × 714 pt +// page with the toolbars shown, 3 device px per point), measured on the iOS +// 26.2 simulator. Inline rather than imported: the config loads as CommonJS and +// the harness package is ESM. +const phone = { + viewport: {width: 402, height: 714}, + screen: {width: 402, height: 874}, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, +} as const; + +const isCI = (process.env.CI ?? '') !== ''; + +function workers(): number { + const raw = process.env.INTERACTION_WORKERS; + const count = raw ? Number(raw) : 1; + if (!Number.isInteger(count) || count < 1) { + throw new Error( + `INTERACTION_WORKERS must be a positive integer, got "${raw}"`, + ); + } + return count; +} + +export default defineConfig({ + testDir: '.', + testMatch: [ + 'internal/interaction-harness/src/**/*.interaction.browser.spec.ts', + 'packages/*/src/**/*.interaction.browser.spec.ts', + ], + // Playwright wipes its output directory per run; evidence (storyboards, + // curve traces) goes to test-results/interaction-evidence instead. + outputDir: 'test-results/interaction-output', + // Frame evidence measures timing: one worker by default, so a proof never + // shares the CPU with another browser. + workers: workers(), + fullyParallel: false, + timeout: 60_000, + forbidOnly: isCI, + reporter: isCI ? [['list'], ['github']] : [['list']], + projects: [ + {name: 'chromium', use: {browserName: 'chromium', ...phone}}, + {name: 'webkit', use: {browserName: 'webkit', ...phone}}, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2e4d844ccbfb..c93bc6ef3838a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -581,6 +581,21 @@ importers: specifier: ^8.62.1 version: 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + internal/interaction-harness: + devDependencies: + '@astryxdesign/a11y-spec': + specifier: workspace:* + version: link:../a11y-spec + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@25.9.4)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.1)(jsdom@27.4.0)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + internal/test-utils: dependencies: '@testing-library/jest-dom': From 75fc6cb1869b4100a9a1732f815bf49fb124932b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 23 Oct 2026 23:31:44 -0700 Subject: [PATCH 2/9] fix(interaction): release only the lifted finger on a partial lift in Chromium The Chromium driver sent a lift of one finger of several as a touchMove listing the fingers still down, assuming CDP releases a point a move omits. It does not: the lifted finger stayed down. A touchEnd that lists the lifting fingers releases exactly those while the others keep moving, so the driver sends that, and a self-test lifts one of two fingers and checks the release, the other finger's moves and the touchend's remaining touches. Playwright's WebKit sends that touchend with no remaining touches, which iOS never does. The harness does not work around it: drivers expose partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it skips; the README lists it with the other engine limits. --- internal/interaction-harness/README.md | 23 ++++++----- .../src/harness.interaction.browser.spec.ts | 40 +++++++++++++++++++ internal/interaction-harness/src/touch.ts | 37 ++++++++++++----- 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index a0b56d854c6b0..7b2ab8ec65067 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -50,17 +50,20 @@ the limits below. Read positions a frame after a move: Chromium delivers ## Engine limits (measured with Playwright 1.61) -| | Chromium 149 | WebKit 26.5 (Linux build) | -| ------------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------- | -| Trusted touch + pointer events, multi-touch | yes | yes (a `touchstart` lists only the fingers that landed in it; pointer events exact) | -| Page sees the scripted timestamps | yes, exactly | no: real dispatch time; real-time pacing keeps velocities within ~1 ms | -| Sub-pixel touch positions | yes | no: integer CSS px | -| Native scroll, fling, pinch-zoom from injected touches | yes | no | -| Click synthesised from a touch | yes | only from `driver.tap` (WebKit's tap gesture), not from a raw touch sequence | -| Input log | yes | yes | +| | Chromium 149 | WebKit 26.5 (Linux build) | +| ------------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------- | +| Trusted touch + pointer events, multi-touch | yes | yes (a `touchstart` lists only the fingers that landed in it; pointer events exact) | +| Page sees the scripted timestamps | yes, exactly | no: real dispatch time; real-time pacing keeps velocities within ~1 ms | +| Sub-pixel touch positions | yes | no: integer CSS px | +| Native scroll, fling, pinch-zoom from injected touches | yes | no | +| 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 | +| Input log | yes | yes | -What WebKit cannot show here — native scroll hand-off, flings, and anything -iOS-only — needs Mobile Safari itself, on a device or the iOS simulator. +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)`. The WebKit input command is reached through Playwright's in-process server object, so it needs a locally launched browser (the default), not `connect()`. diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index 10d41e668c9bf..38aa9c4876b1c 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -16,6 +16,7 @@ import { flick, installInputLog, merge, + PARTIAL_LIFT_SKIP, pinch, readInputLog, tap, @@ -131,6 +132,45 @@ test.describe('touch input', () => { expect(fast).toBeGreaterThan(slow * 1.8); }); + test('lifting one of two fingers releases only that finger', async ({ + page, + }) => { + await fixture(page); + const driver = await touchDriver(page); + test.skip(!driver.partialLifts, PARTIAL_LIFT_SKIP); + const first = drag( + {x: 80, y: 80}, + {x: 80, y: 240}, + {durationMs: 400, hz: 60}, + ); + const second = withFinger(tap({x: 300, y: 120}, {holdMs: 100}), 1, 100); + await driver.play(merge(first, second)); + const log = await readInputLog(page); + const [stays, lifts] = log.filter(r => r.type === 'pointerdown'); + const upOf = (down?: {pointerId?: number}) => + log.find(r => r.type === 'pointerup' && r.pointerId === down?.pointerId); + const lifted = upOf(lifts); + const last = upOf(stays); + if (stays == null || lifted == null || last == null) { + throw new Error('expected two fingers down, each released'); + } + // Released when it lifted (~200 ms in), while the first finger was down. + expect(lifted.timeStamp).toBeLessThan(last.timeStamp - 150); + expect( + log.filter( + r => + r.type === 'pointermove' && + r.pointerId === stays.pointerId && + r.timeStamp > lifted.timeStamp, + ).length, + 'the other finger keeps moving', + ).toBeGreaterThan(5); + // As iOS sends it: the lift's touchend still lists the finger down. + expect(log.filter(r => r.type === 'touchend').map(r => r.touches)).toEqual([ + 1, 0, + ]); + }); + test('a second finger and a pinch arrive as distinct touch pointers', async ({ page, }) => { diff --git a/internal/interaction-harness/src/touch.ts b/internal/interaction-harness/src/touch.ts index 56cdf8aa50289..1c16c4b1f82e0 100644 --- a/internal/interaction-harness/src/touch.ts +++ b/internal/interaction-harness/src/touch.ts @@ -75,12 +75,27 @@ export interface TouchDriver { readonly scriptedTimestamps: boolean; /** True when these touches can scroll, fling and pinch-zoom natively. */ readonly drivesNativeGestures: boolean; + /** + * True when lifting one finger while others stay down reaches the page as + * iOS sends it: a `touchend` whose `touches` still lists the fingers down. + * Skip such proofs with `PARTIAL_LIFT_SKIP` where it is false. + */ + readonly partialLifts: boolean; /** Plays a script; resolves when its last event has been dispatched. */ play(script: TouchScript, options?: PlayOptions): Promise; /** A tap through the engine's tap path (WebKit synthesises click only from this). */ tap(at: Point): Promise; } +/** + * The skip reason for a proof that lifts one finger while another stays down, + * on a driver whose `partialLifts` is false: Playwright's WebKit (Linux build) + * sends that lift's `touchend` with no remaining `touches`, an emulation quirk + * iOS does not have. Those cases need a simulator capture. + */ +export const PARTIAL_LIFT_SKIP = + 'injected WebKit touches report no remaining touches when one finger of several lifts (README: engine limits); capture it on the iOS simulator'; + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); /** Waits until `performance.now()` reaches `deadline` with ~0.1 ms precision. */ @@ -132,6 +147,7 @@ async function chromiumDriver(page: Page): Promise { engine: 'chromium', scriptedTimestamps: true, drivesNativeGestures: true, + partialLifts: true, async play(script, options = {}) { // 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. @@ -140,18 +156,18 @@ async function chromiumDriver(page: Page): Promise { script, options, async (step, timestamp) => { - // CDP diffs each event's active points against the previous event's: - // a missing point is released, a new one pressed. touchEnd must carry - // no points, so lifting one finger of several is a touchMove that - // lists the fingers still down. - const partialLift = - step.type === 'touchEnd' && step.fingers.length > 0; + // A start or move lists every finger down (CDP presses the new ones). + // An end lists the fingers that lift: that releases exactly those + // while the rest stay down and keep moving; the last lift lists none. + // (A move that omits a finger does not release it.) await cdp.send('Input.dispatchTouchEvent', { - type: partialLift ? 'touchMove' : step.type, + type: step.type, touchPoints: - step.type === 'touchEnd' && !partialLift - ? [] - : touchPoints(step.fingers), + step.type !== 'touchEnd' + ? touchPoints(step.fingers) + : step.fingers.length > 0 + ? touchPoints(step.changed) + : [], timestamp: timestamp / 1000, }); }, @@ -201,6 +217,7 @@ async function webkitDriver(page: Page): Promise { engine: 'webkit', scriptedTimestamps: false, drivesNativeGestures: false, + partialLifts: false, async play(script, options = {}) { return playSteps( script, From 0a07aa13e07b1e3b3c9017fcf0cf989c3f3a09d5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 24 Oct 2026 14:46:20 -0700 Subject: [PATCH 3/9] fix(interaction): join the proofs to test, keep their setup in scope, lift profiled flicks at their velocity - pr-interaction now always reports and the `test` join requires its success (AST-030 FR7, FR11), shaped like test-native-swift: a Linux interaction-scope job decides, with the base's scope helper and failing closed, whether the harness, a component, Storybook, internal/a11y-spec, the workflow, package.json or pnpm-lock.yaml changed. Only then do the proofs take a 4-core runner; otherwise, and in merge groups (as with pr-a11y), the job passes without work. - A profiled flick's last move runs at velocity times profile(1), so the finger lifts at the documented velocity, and releaseVelocity reads the last move instead of a 100 ms window mean. - Docs: drop the /math subpath this package does not export yet; scripted timestamps arrive to 0.1 ms; the job comment describes the self-test it runs. --- .github/scripts/ci-interaction.test.mjs | 126 ++++++++++++++++++ .github/scripts/ci-test-join.mjs | 1 + .github/scripts/ci-test-join.test.mjs | 12 +- .github/scripts/ci-test-routing.test.mjs | 17 ++- .github/scripts/ci-tooling-routing.test.mjs | 2 + .github/workflows/ci.yml | 126 +++++++++++++----- internal/interaction-harness/README.md | 2 +- internal/interaction-harness/src/index.ts | 4 +- .../interaction-harness/src/scripts.test.ts | 35 ++++- internal/interaction-harness/src/scripts.ts | 46 ++++--- 10 files changed, 312 insertions(+), 59 deletions(-) create mode 100644 .github/scripts/ci-interaction.test.mjs diff --git a/.github/scripts/ci-interaction.test.mjs b/.github/scripts/ci-interaction.test.mjs new file mode 100644 index 0000000000000..e8b393ffbf486 --- /dev/null +++ b/.github/scripts/ci-interaction.test.mjs @@ -0,0 +1,126 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Routing contract for the touch interaction proofs in the broad CI lane. + * + * The proofs are coverage inside the shared surface (AST-030 FR11), so the + * `test` join requires pr-interaction, and a failing proof fails `test` (FR7). + * A Linux job decides whether the change touches the harness, a component, + * Storybook, or the proofs' own setup; only then does pr-interaction take a + * 4-core runner, and otherwise it passes on Linux without work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import {describe, expect, it} from 'vitest'; +import yaml from 'yaml'; + +const root = path.resolve(import.meta.dirname, '../..'); +const ci = yaml.parse( + fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'), +); +const scope = ci.jobs['interaction-scope']; +const proofs = ci.jobs['pr-interaction']; +const step = (job, name) => + job.steps.find(candidate => candidate.name === name); +const decide = () => step(scope, 'Decide whether the interaction proofs apply'); +const applies = "needs.interaction-scope.outputs.applies == 'true'"; + +describe('interaction proof routing', () => { + it('is joined by test', () => { + expect(ci.jobs.test.needs).toContain('pr-interaction'); + expect( + step(ci.jobs.test, 'Require expected test-owner outcomes').env, + ).toMatchObject({ + PR_INTERACTION_RESULT: '${{ needs.pr-interaction.result }}', + }); + }); + + it('always reports, so the join never reads a missing result', () => { + expect(proofs.if).toBe(ci.jobs['test-native-swift'].if); + expect(scope.if).toBe(ci.jobs['native-swift-scope'].if); + }); + + it('decides scope on Linux and takes four cores only when the proofs apply', () => { + expect(scope['runs-on']).toBe('ubuntu-slim'); + expect(proofs.needs).toEqual([ + 'check-scope', + 'interaction-scope', + 'build-storybook', + ]); + expect(proofs['runs-on']).toBe( + "${{ needs.interaction-scope.outputs.applies == 'true' && '4-core-ubuntu' || 'ubuntu-slim' }}", + ); + const guards = new Set([ + 'Require a decided interaction scope', + "Require this pull request's Storybook build", + 'Skip the proofs outside their scope', + ]); + for (const candidate of proofs.steps) { + if (guards.has(candidate.name)) continue; + expect(candidate.if, candidate.name ?? candidate.uses).toMatch( + new RegExp( + `^(always\\(\\) && )?${applies.replace(/[.()|]/g, '\\$&')}$`, + ), + ); + } + }); + + it('fails closed when the scope cannot be decided or Storybook did not build', () => { + expect(step(scope, 'Require successful scope classification').if).toBe( + "needs.check-scope.result != 'success'", + ); + expect(step(proofs, 'Require a decided interaction scope').if).toBe( + "needs.interaction-scope.result != 'success'", + ); + expect(step(proofs, "Require this pull request's Storybook build").if).toBe( + `${applies} && needs.build-storybook.result != 'success'`, + ); + expect(decide().run).toContain( + 'git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh"', + ); + // Only "out of scope" (exit 1) skips; unknown (2) runs the proofs. + expect(decide().run).toContain('if [ "$scope" = 1 ]; then'); + }); + + it('keeps the harness, components, Storybook and its own setup in scope', () => { + const pattern = new RegExp( + decide().run.match(/bash "\$helper" "\$BASE_SHA" '([^']+)'/)[1], + ); + for (const touched of [ + 'internal/interaction-harness/src/scripts.ts', + 'internal/a11y-spec/src/storybook.ts', + 'playwright.interaction.config.ts', + 'packages/core/src/BottomSheet/BottomSheet.tsx', + 'apps/storybook/.storybook/preview.tsx', + '.github/workflows/ci.yml', + 'package.json', + 'pnpm-lock.yaml', + ]) { + expect(pattern.test(touched), touched).toBe(true); + } + for (const untouched of [ + 'docs/specs/AST-030/spec.md', + 'apps/docsite/src/app/page.tsx', + 'packages/core/package.json', + '.github/scripts/native-swift.sh', + ]) { + expect(pattern.test(untouched), untouched).toBe(false); + } + }); + + it('passes without work on docsite-only, tooling-only and merge-group runs', () => { + expect(decide().env).toMatchObject({ + EVENT_NAME: '${{ github.event_name }}', + DOCSITE_ONLY: '${{ needs.check-scope.outputs.docsite_only }}', + TOOLING_ONLY: '${{ needs.check-scope.outputs.tooling_only }}', + }); + expect(decide().run).toContain( + 'if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then', + ); + expect(decide().run).toContain( + 'if [ "$EVENT_NAME" != pull_request ]; then', + ); + }); +}); diff --git a/.github/scripts/ci-test-join.mjs b/.github/scripts/ci-test-join.mjs index c3cdf35fc16d0..61e9701c163e6 100644 --- a/.github/scripts/ci-test-join.mjs +++ b/.github/scripts/ci-test-join.mjs @@ -15,6 +15,7 @@ const requiredSuccess = [ ['test-node', process.env.TEST_NODE_RESULT], ['registry-contract', process.env.REGISTRY_CONTRACT_RESULT], ['test-native-swift', process.env.TEST_NATIVE_SWIFT_RESULT], + ['pr-interaction', process.env.PR_INTERACTION_RESULT], ]; function fail(message) { diff --git a/.github/scripts/ci-test-join.test.mjs b/.github/scripts/ci-test-join.test.mjs index 3344ba3d100a5..0786a00d86cde 100644 --- a/.github/scripts/ci-test-join.test.mjs +++ b/.github/scripts/ci-test-join.test.mjs @@ -18,13 +18,19 @@ const broad = { TEST_BUILD_RESULT: 'success', REGISTRY_CONTRACT_RESULT: 'success', TEST_NATIVE_SWIFT_RESULT: 'success', + PR_INTERACTION_RESULT: 'success', }; +// An override of `null` removes the variable, as a missing `needs` result would. function run(overrides = {}) { + const env = {...process.env, ...broad, ...overrides}; + for (const [name, value] of Object.entries(env)) { + if (value === null) delete env[name]; + } try { const stdout = execFileSync(process.execPath, [script], { encoding: 'utf8', - env: {...process.env, ...broad, ...overrides}, + env, stdio: ['ignore', 'pipe', 'pipe'], }); return {status: 0, output: stdout}; @@ -46,6 +52,10 @@ describe('pull-request test join', () => { ['REGISTRY_CONTRACT_RESULT', 'failure'], ['TEST_NATIVE_SWIFT_RESULT', 'failure'], ['TEST_NATIVE_SWIFT_RESULT', 'skipped'], + ['PR_INTERACTION_RESULT', 'failure'], + ['PR_INTERACTION_RESULT', 'skipped'], + ['PR_INTERACTION_RESULT', 'cancelled'], + ['PR_INTERACTION_RESULT', null], ]) { expect(run({[name]: value}), name).toMatchObject({status: 1}); } diff --git a/.github/scripts/ci-test-routing.test.mjs b/.github/scripts/ci-test-routing.test.mjs index 1128817685567..6908a2d8ec4d7 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -595,7 +595,13 @@ describe('ci.yml interaction proofs', () => { const step = name => job.steps.find(candidate => candidate.name === name); it('runs the interaction config in Chromium against the exact PR head', () => { - expect(KNOWN_RUNNERS.has(job['runs-on'])).toBe(true); + // Four cores when the proofs apply, a slim runner when it passes without work. + const labels = [...job['runs-on'].matchAll(/'([^']+)'/g)] + .map(match => match[1]) + .filter(label => label !== 'true'); + expect(labels).toEqual(['4-core-ubuntu', 'ubuntu-slim']); + for (const label of labels) + expect(KNOWN_RUNNERS.has(label), label).toBe(true); expect(job.needs).toEqual( expect.arrayContaining(['check-scope', 'build-storybook']), ); @@ -615,13 +621,20 @@ describe('ci.yml interaction proofs', () => { }); it('runs whenever the harness, its config, a component or a story changes', () => { - const scope = step('Check interaction scope').run; + const scope = workflow.jobs['interaction-scope'].steps.find( + candidate => + candidate.name === 'Decide whether the interaction proofs apply', + ).run; expect(scope).toContain('.github/scripts/pr-scope-touches.sh'); for (const path of [ 'internal/interaction-harness/', + 'internal/a11y-spec/', 'playwright\\.interaction\\.config\\.ts', 'packages/[^/]+/src/', 'apps/storybook/', + '\\.github/workflows/ci\\.yml$', + 'package\\.json$', + 'pnpm-lock\\.yaml$', ]) { expect(scope).toContain(path); } diff --git a/.github/scripts/ci-tooling-routing.test.mjs b/.github/scripts/ci-tooling-routing.test.mjs index 5561eb4f5add6..eb64d74f8cadf 100644 --- a/.github/scripts/ci-tooling-routing.test.mjs +++ b/.github/scripts/ci-tooling-routing.test.mjs @@ -179,6 +179,7 @@ describe('Node-tooling CI routing', () => { 'test-build', 'registry-contract', 'test-native-swift', + 'pr-interaction', ]); expect(ci.jobs.build.needs).toEqual([ 'build-storybook', @@ -198,6 +199,7 @@ describe('Node-tooling CI routing', () => { TEST_BUILD_RESULT: '${{ needs.test-build.result }}', REGISTRY_CONTRACT_RESULT: '${{ needs.registry-contract.result }}', TEST_NATIVE_SWIFT_RESULT: '${{ needs.test-native-swift.result }}', + PR_INTERACTION_RESULT: '${{ needs.pr-interaction.result }}', }); const buildJoin = step( ci.jobs.build, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc8e7d9a951e..f5ca0f3930b8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -881,9 +881,10 @@ jobs: ASTRYX_SHADCN_USE_PUBLISHED_PACKAGES: '1' run: node internal/shadcn-registry/verify-full-catalog.mjs - # Historical required check name. The Vitest lanes, Build test owner, and - # public registry contract always report or explicitly skip, so this join - # projects their combined result without creating another surface lane. + # Historical required check name. The Vitest lanes, Build test owner, + # public registry contract, native Swift and interaction proofs always report + # or explicitly skip, so this join projects their combined result without + # creating another surface lane. test: needs: [ @@ -893,6 +894,7 @@ jobs: test-build, registry-contract, test-native-swift, + pr-interaction, ] if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} runs-on: ubuntu-slim @@ -912,6 +914,7 @@ jobs: TEST_BUILD_RESULT: ${{ needs.test-build.result }} REGISTRY_CONTRACT_RESULT: ${{ needs.registry-contract.result }} TEST_NATIVE_SWIFT_RESULT: ${{ needs.test-native-swift.result }} + PR_INTERACTION_RESULT: ${{ needs.pr-interaction.result }} run: node .github/scripts/ci-test-join.mjs # Build storybook + typecheck + analyze components (parallel with test and @@ -3774,68 +3777,125 @@ jobs: # internal/interaction-harness and every *.interaction.browser.spec.ts. # Real touch input, composited frames, rendering traces and presented # animation values only exist in a real engine, and the specs read stories - # from this PR's own Storybook build. The harness's self-test runs with - # them, so a change to an instrument is proven by its negative controls - # before a component spec relies on it. Chromium only on these runners; - # see the harness README for running WebKit. - pr-interaction: - needs: [check-scope, build-storybook] - if: github.event_name == 'pull_request' && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' - # Frame evidence measures timing: four cores, one Playwright worker. - runs-on: 4-core-ubuntu + # from this PR's own Storybook build. The harness's self-test runs with them, + # so a change to an instrument is proven by its own checks before a component + # spec relies on it. Chromium only on these runners; see the harness README + # for running WebKit. + # + # The proofs are coverage inside the shared surface (AST-030 FR11), so `test` + # joins pr-interaction. A Linux job first decides whether the change touches + # the harness, a component, Storybook, or the proofs' own setup; only then + # does pr-interaction take a 4-core runner, and otherwise it passes on Linux + # without work. Like pr-a11y, the proofs run on pull requests; a merge group + # passes without them. + interaction-scope: + needs: [check-scope] + if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} + runs-on: ubuntu-slim permissions: contents: read + outputs: + applies: ${{ steps.scope.outputs.applies }} steps: - - name: Checkout - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} + - name: Require successful scope classification + if: needs.check-scope.result != 'success' + run: | + echo "Change scope could not be classified; refusing to skip required work." + exit 1 - - name: Verify evidence checkout - env: - EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" + - uses: actions/checkout@v7 + if: github.event_name == 'pull_request' && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' - # The helper comes from the base, since an older PR head lacks it. - # Exit 1 means out of scope; 0 in scope; 2 unknown, so run. - - name: Check interaction scope + # The helper comes from the base, since an older head lacks it. Exit 1 + # means out of scope; 0 in scope; 2 unknown, so the proofs run. + - name: Decide whether the interaction proofs apply id: scope env: + EVENT_NAME: ${{ github.event_name }} + DOCSITE_ONLY: ${{ needs.check-scope.outputs.docsite_only }} + TOOLING_ONLY: ${{ needs.check-scope.outputs.tooling_only }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then + echo 'Specialized lane; no interaction proofs.' + echo 'applies=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$EVENT_NAME" != pull_request ]; then + echo 'Not a pull request; the proofs ran on the pull request itself.' + echo 'applies=false' >> "$GITHUB_OUTPUT" + exit 0 + fi scope=2 helper="$(mktemp)" - if git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \ + if [ -n "$BASE_SHA" ] \ + && git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \ && git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then scope=0 - bash "$helper" "$BASE_SHA" '^(internal/interaction-harness/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|apps/storybook/)' || scope=$? + bash "$helper" "$BASE_SHA" '^(internal/interaction-harness/|internal/a11y-spec/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|apps/storybook/|\.github/workflows/ci\.yml$|package\.json$|pnpm-lock\.yaml$)' || scope=$? fi if [ "$scope" = 1 ]; then - echo "Neither the interaction harness nor a component changed; skipping the interaction proofs." - echo 'run=false' >> "$GITHUB_OUTPUT" + echo 'No harness, component, Storybook or proof-setup change; skipping the interaction proofs.' + echo 'applies=false' >> "$GITHUB_OUTPUT" else - echo 'run=true' >> "$GITHUB_OUTPUT" + echo 'applies=true' >> "$GITHUB_OUTPUT" fi + pr-interaction: + needs: [check-scope, interaction-scope, build-storybook] + if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} + # Frame evidence measures timing: four cores, one Playwright worker. + runs-on: ${{ needs.interaction-scope.outputs.applies == 'true' && '4-core-ubuntu' || 'ubuntu-slim' }} + permissions: + contents: read + steps: + - name: Require a decided interaction scope + if: needs.interaction-scope.result != 'success' + run: | + echo "Interaction scope could not be decided; refusing to skip required work." + exit 1 + + - name: Require this pull request's Storybook build + if: needs.interaction-scope.outputs.applies == 'true' && needs.build-storybook.result != 'success' + run: | + echo "The proofs read this pull request's Storybook build, which did not succeed." + exit 1 + + - name: Skip the proofs outside their scope + if: needs.interaction-scope.outputs.applies != 'true' + run: echo "No interaction proofs for this change." + + - name: Checkout + if: needs.interaction-scope.outputs.applies == 'true' + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Verify evidence checkout + if: needs.interaction-scope.outputs.applies == 'true' + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" + - name: Setup Node and pnpm - if: steps.scope.outputs.run == 'true' + if: needs.interaction-scope.outputs.applies == 'true' uses: ./.github/actions/setup with: install: 'false' - name: Download Storybook artifact - if: steps.scope.outputs.run == 'true' + if: needs.interaction-scope.outputs.applies == 'true' uses: actions/download-artifact@v8 with: name: storybook-${{ needs.build-storybook.outputs.short_hash }} path: apps/storybook/dist - name: Install Playwright - if: steps.scope.outputs.run == 'true' + if: needs.interaction-scope.outputs.applies == 'true' run: pnpm install --frozen-lockfile && npx playwright install chromium - name: Run interaction proofs - if: steps.scope.outputs.run == 'true' + if: needs.interaction-scope.outputs.applies == 'true' run: | pnpm exec playwright test \ --config playwright.interaction.config.ts \ @@ -3844,7 +3904,7 @@ jobs: # Storyboards (PNG contact sheets) and curve traces, for review. - name: Upload interaction evidence - if: always() && steps.scope.outputs.run == 'true' + if: always() && needs.interaction-scope.outputs.applies == 'true' uses: actions/upload-artifact@v7 with: name: interaction-evidence diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 7b2ab8ec65067..8139a9fa33348 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -41,7 +41,7 @@ node internal/interaction-harness/scripts/webkit-container.mjs [playwright test Untrusted DOM events are not input: they start no native scroll, claim no gesture, and read `isTrusted: false`. Chromium input goes through CDP `Input.dispatchTouchEvent` with an explicit timestamp per event: the page's -`event.timeStamp` is the scripted time to the microsecond, whatever the +`event.timeStamp` is the scripted time to 0.1 ms, whatever the dispatch latency, and the events go through the compositor's gesture detection (native scrolling, flings with velocity from those timestamps, `touch-action`, `pointercancel`). WebKit input goes through the engine's own input command; see diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts index 3216b3c084322..587b9f82d233a 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -4,8 +4,8 @@ * @file index.ts * @input Re-exports the harness modules * @output The `@astryxdesign/interaction-harness` entry point for Playwright - * specs. Pure helpers are also reachable without Playwright through - * `@astryxdesign/interaction-harness/math` and `/scripts` (unit tests). + * specs. The pure touch scripts are also reachable without Playwright + * through `@astryxdesign/interaction-harness/scripts` (unit tests). * @position Imported only from `*.interaction.browser.spec.ts` files (run by * playwright.interaction.config.ts) and from this package's own tests. * diff --git a/internal/interaction-harness/src/scripts.test.ts b/internal/interaction-harness/src/scripts.test.ts index b85d4b526de55..072adeddc0cd4 100644 --- a/internal/interaction-harness/src/scripts.test.ts +++ b/internal/interaction-harness/src/scripts.test.ts @@ -66,8 +66,39 @@ describe('touch scripts', () => { {x: 0, y: 0}, {velocity: {x: 1000, y: 0}, durationMs: 100, hz: 100, profile: p => p}, ); - // Linear ramp: distance = v * T / 2. - expect(script.at(-2)!.fingers[0].x).toBeCloseTo(50, 6); + // Linear ramp: the first nine 10 ms moves integrate it by the midpoint rule + // (40.5 px) and the last runs at the full 1,000 px/s (10 px). + expect(script.at(-2)!.fingers[0].x).toBeCloseTo(50.5, 6); + }); + + it.each([ + ['linear', (p: number) => p], + ['quadratic', (p: number) => p * p], + ])( + 'a %s-profile flick still lifts at the scripted velocity', + (_, profile) => { + const script = flick( + {x: 0, y: 0}, + {velocity: {x: 0, y: 1500}, durationMs: 80, hz: 120, profile}, + ); + const [before, last] = script.slice(-3, -1); + const lastMove = + ((last.fingers[0].y - before.fingers[0].y) / (last.t - before.t)) * + 1000; + expect(lastMove).toBeCloseTo(1500, 6); + expect(releaseVelocity(script).y).toBeCloseTo(1500, 6); + }, + ); + + it('release velocity is the last move, not a window mean', () => { + // 500 px/s for 100 ms, then 2,000 px/s for one 10 ms move; path() lifts a + // quarter frame later. + const script = path( + p => ({x: 0, y: p <= 10 / 11 ? 55 * p : 50 + 20 * (p * 11 - 10)}), + {durationMs: 110, hz: 100}, + ); + expect(script.at(-1)!.fingers).toEqual([]); + expect(releaseVelocity(script).y).toBeCloseTo(2000, 6); }); it('pinch and rotate keep two fingers about the centre', () => { diff --git a/internal/interaction-harness/src/scripts.ts b/internal/interaction-harness/src/scripts.ts index 111d9e01234aa..2fa861f665e7d 100644 --- a/internal/interaction-harness/src/scripts.ts +++ b/internal/interaction-harness/src/scripts.ts @@ -131,8 +131,10 @@ export interface FlickOptions { readonly durationMs?: number; readonly hz?: number; /** - * Velocity over the move as a fraction of the final velocity (0..1 → 0..∞), - * default constant. `p => p` is a finger accelerating linearly into the lift. + * Velocity over the move as a fraction of `velocity` (0..1 → 0..∞), default + * constant. `p => p` is a finger accelerating linearly into the lift. The + * moves before the last follow it (midpoint rule); the last move runs at + * `profile(1)`. */ readonly profile?: (progress: number) => number; readonly id?: number; @@ -141,7 +143,10 @@ export interface FlickOptions { /** * A throw: the finger moves with the given velocity profile and lifts while * still moving (the lift lands a quarter frame after the last move, as on a - * device), so the release velocity is exactly `velocity`. + * device). Its last move runs at `velocity` times `profile(1)`, so with the + * default constant profile, or any profile that ends at 1, the release + * velocity (what `releaseVelocity` and a velocity tracker read from the last + * move) is exactly `velocity`. */ export function flick(from: Point, options: FlickOptions): TouchScript { const { @@ -156,9 +161,14 @@ export function flick(from: Point, options: FlickOptions): TouchScript { let x = from.x; let y = from.y; let previous = 0; - for (const t of frameTimes(durationMs, hz)) { - // Integrate the profile over (previous, t] with the midpoint rule. - const scale = profile((previous + t) / 2 / durationMs); + const times = frameTimes(durationMs, hz); + for (const [index, t] of times.entries()) { + // Integrate the profile over (previous, t] with the midpoint rule; the last + // move runs at the release velocity itself, so the finger lifts at it. + const scale = + index === times.length - 1 + ? profile(1) + : profile((previous + t) / 2 / durationMs); x += (velocity.x * scale * (t - previous)) / 1000; y += (velocity.y * scale * (t - previous)) / 1000; previous = t; @@ -348,14 +358,14 @@ export function toTouchEvents(script: TouchScript): TouchEventStep[] { } /** - * The velocity (px/s) of finger `id` at its lift: the slope over the last - * `windowMs` of moves, or zero when the finger rested `windowMs` or longer - * before lifting (a finger that rested released nothing in motion). + * The velocity (px/s) of finger `id` as it lifts: the slope of its last move, + * or zero when it never moved or rested `restMs` or longer before lifting (a + * finger that rested released nothing in motion). */ export function releaseVelocity( script: TouchScript, id = 0, - windowMs = 100, + restMs = 100, ): Point { const samples = script.flatMap(frame => frame.fingers @@ -377,19 +387,19 @@ export function releaseVelocity( ) { lastMove -= 1; } + if (lastMove === 0) { + return {x: 0, y: 0}; + } const last = samples[lastMove]; - if (lift != null && lift.t - last.t >= windowMs) { + if (lift != null && lift.t - last.t >= restMs) { return {x: 0, y: 0}; } - const recent = samples - .slice(0, lastMove + 1) - .filter(s => s.t >= last.t - windowMs); - const first = recent[0]; - const dt = last.t - first.t; + const before = samples[lastMove - 1]; + const dt = last.t - before.t; return dt <= 0 ? {x: 0, y: 0} : { - x: ((last.x - first.x) / dt) * 1000, - y: ((last.y - first.y) / dt) * 1000, + x: ((last.x - before.x) / dt) * 1000, + y: ((last.y - before.y) / dt) * 1000, }; } From 5f2ed5076b8edc1886a68a23de9aaf8e72e737d6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 24 Oct 2026 15:26:39 -0700 Subject: [PATCH 4/9] fix(interaction): releaseVelocity measures from when the finger reached its previous position A sample that repeats a position is not a move: frames that merge() inserts for another finger, where this finger did not move, made the last move look shorter, so a 10 Hz drag merged with a tap read 375 px/s instead of 300 and a 60 Hz drag whose other finger lifted 5 ms before its last move read about 3.6x too fast. The previous sample is now the first one at the previous position, as the page sees it, with a merged case in scripts.test.ts. --- .../interaction-harness/src/scripts.test.ts | 23 +++++++++++++++++++ internal/interaction-harness/src/scripts.ts | 16 ++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/interaction-harness/src/scripts.test.ts b/internal/interaction-harness/src/scripts.test.ts index 072adeddc0cd4..321b76d1a49c5 100644 --- a/internal/interaction-harness/src/scripts.test.ts +++ b/internal/interaction-harness/src/scripts.test.ts @@ -90,6 +90,29 @@ describe('touch scripts', () => { }, ); + it('release velocity ignores frames merged in for another finger', () => { + // A 10 Hz drag with a tap from 120 to 220 ms: merge inserts frames where + // the dragging finger did not move, and the page sees no move there. + const slow = drag({x: 0, y: 0}, {x: 0, y: 90}, {durationMs: 300, hz: 10}); + const tapping = withFinger(tap({x: 50, y: 50}, {holdMs: 100}), 1, 120); + expect(releaseVelocity(merge(slow, tapping)).y).toBeCloseTo( + releaseVelocity(slow).y, + 6, + ); + // A 60 Hz drag whose second finger lifts 5 ms before the drag's last move. + const fast = drag({x: 0, y: 0}, {x: 0, y: 300}, {durationMs: 300, hz: 60}); + const lastMove = fast.at(-2)!.t; + const lifting = withFinger( + tap({x: 50, y: 50}, {holdMs: lastMove - 5 - 100}), + 1, + 100, + ); + expect(releaseVelocity(merge(fast, lifting)).y).toBeCloseTo( + releaseVelocity(fast).y, + 6, + ); + }); + it('release velocity is the last move, not a window mean', () => { // 500 px/s for 100 ms, then 2,000 px/s for one 10 ms move; path() lifts a // quarter frame later. diff --git a/internal/interaction-harness/src/scripts.ts b/internal/interaction-harness/src/scripts.ts index 2fa861f665e7d..79e8744060f78 100644 --- a/internal/interaction-harness/src/scripts.ts +++ b/internal/interaction-harness/src/scripts.ts @@ -359,8 +359,10 @@ export function toTouchEvents(script: TouchScript): TouchEventStep[] { /** * The velocity (px/s) of finger `id` as it lifts: the slope of its last move, - * or zero when it never moved or rested `restMs` or longer before lifting (a - * finger that rested released nothing in motion). + * measured from when it reached its previous position (samples that repeat a + * position, such as frames `merge` inserts for another finger, are not moves: + * the page sees none there), or zero when it never moved or rested `restMs` or + * longer before lifting (a finger that rested released nothing in motion). */ export function releaseVelocity( script: TouchScript, @@ -394,7 +396,15 @@ export function releaseVelocity( if (lift != null && lift.t - last.t >= restMs) { return {x: 0, y: 0}; } - const before = samples[lastMove - 1]; + let previous = lastMove - 1; + while ( + previous > 0 && + samples[previous - 1].x === samples[previous].x && + samples[previous - 1].y === samples[previous].y + ) { + previous -= 1; + } + const before = samples[previous]; const dt = last.t - before.t; return dt <= 0 ? {x: 0, y: 0} From 45385723a6e863b24114eeefa4feecbb8d8cad30 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 25 Oct 2026 05:43:08 -0700 Subject: [PATCH 5/9] ci: the harness's typecheck runs through pnpm typecheck, once Since main's root `pnpm typecheck` (#8390) runs every workspace package's own typecheck script, the separate "Typecheck interaction harness" steps in ci.yml and deploy.yml ran it a second time, and their comment ("the only gate over it") no longer held. Both steps go. The routing test that pinned them becomes one more directory in "keeps every typecheck gate that guarded main": internal/interaction-harness keeps the typecheck script that pnpm typecheck reaches. --- .github/scripts/ci-test-routing.test.mjs | 9 +-------- .github/workflows/ci.yml | 6 ------ .github/workflows/deploy.yml | 3 --- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/.github/scripts/ci-test-routing.test.mjs b/.github/scripts/ci-test-routing.test.mjs index 02e66cabd0df1..09c4f6252fd49 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -630,6 +630,7 @@ describe('deploy.yml push gating', () => { 'packages/cli', 'apps/storybook', 'internal/a11y-spec', + 'internal/interaction-harness', ]) { const {typecheck} = JSON.parse(read(`${dir}/package.json`)).scripts; expect(typecheck, `${dir} lost its typecheck`).toBeTruthy(); @@ -699,12 +700,4 @@ describe('ci.yml interaction proofs', () => { // Unknown scope (exit 2) runs the proofs; only "out of scope" skips them. expect(scope).toContain('if [ "$scope" = 1 ]; then'); }); - - it('typechecks the harness in both workflows', () => { - for (const file of ['ci.yml', 'deploy.yml']) { - expect(runLines(load(file).jobs.typecheck), file).toContain( - '@astryxdesign/interaction-harness typecheck', - ); - } - }); }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0cf77b251cf4..7df2ea07b73c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1216,12 +1216,6 @@ jobs: bash "$legacy" fi - # The interaction harness is consumed as TypeScript source by Vitest and - # Playwright, which do not typecheck; this is the only gate over it. - - name: Typecheck interaction harness - if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' - run: pnpm -F @astryxdesign/interaction-harness typecheck - # Sandbox preview build — the single slowest piece of PR CI (~4min), run as # its own job so it overlaps build-storybook instead of serializing after it. # PR-only: merge_group runs never deploy previews, and docsite-only PRs skip diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5aa1c2c672b48..2e92172d0185f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -100,9 +100,6 @@ jobs: - name: Typecheck every package run: pnpm typecheck - - name: Typecheck interaction harness - run: pnpm -F @astryxdesign/interaction-harness typecheck - # Stable join for the three post-merge validation lanes. test: needs: [test-ui, test-node, typecheck] From c6d91e8814d8a150237def05ae181f9b28d95252 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 25 Oct 2026 07:02:55 -0700 Subject: [PATCH 6/9] ci: pin the interaction routing's fail-closed edges by running them AST-030 FR9: removing a fail-closed edge must fail a test. The routing test only read the decide script's text, so turning its unknown default into "out of scope", or recording every helper failure as out of scope, or a guard step's `exit 1` into `exit 0`, left it green. It now runs the decide script under bash -eo pipefail with a stub git: a helper that answers in scope, out of scope or unknown, a base that cannot be fetched, a helper missing at the base, no base, a docsite-only change and a merge group, and checks the `applies` output of each. It also runs each guard step and checks that it fails. Each of those five mutations now fails it. flick's doc and the README said a velocity tracker reads exactly `velocity` for any profile ending at 1. That holds for the script's last move (what releaseVelocity reads); a tracker that blends recent intervals (INV10) reads it only when the profile is flat over them, as the default is. --- .github/scripts/ci-interaction.test.mjs | 85 +++++++++++++++++++++ internal/interaction-harness/README.md | 2 +- internal/interaction-harness/src/scripts.ts | 10 ++- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/.github/scripts/ci-interaction.test.mjs b/.github/scripts/ci-interaction.test.mjs index e8b393ffbf486..860d11c879c78 100644 --- a/.github/scripts/ci-interaction.test.mjs +++ b/.github/scripts/ci-interaction.test.mjs @@ -10,7 +10,9 @@ * 4-core runner, and otherwise it passes on Linux without work. */ +import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import {describe, expect, it} from 'vitest'; @@ -27,6 +29,54 @@ const step = (job, name) => const decide = () => step(scope, 'Decide whether the interaction proofs apply'); const applies = "needs.interaction-scope.outputs.applies == 'true'"; +// Runs a step's script as Actions runs it (bash -eo pipefail) with a stub +// `git` first on PATH: `fetch` exits FETCH, and `show` prints a helper that +// exits HELPER (or fails when HELPER is 'missing'). Returns the exit status +// and the outputs the script wrote. +function runStep(script, {env = {}, fetch = 0, helper = 0} = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-interaction-')); + try { + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, 'git'), + [ + '#!/usr/bin/env bash', + 'case "$1" in', + ` fetch) exit ${fetch} ;;`, + helper === 'missing' + ? ' show) exit 128 ;;' + : ` show) echo 'exit ${helper}' ;;`, + ' *) exit 3 ;;', + 'esac', + '', + ].join('\n'), + {mode: 0o755}, + ); + const output = path.join(dir, 'output'); + fs.writeFileSync(output, ''); + const result = spawnSync( + 'bash', + ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], + { + encoding: 'utf8', + env: { + PATH: `${bin}:${process.env.PATH}`, + GITHUB_OUTPUT: output, + EVENT_NAME: 'pull_request', + DOCSITE_ONLY: 'false', + TOOLING_ONLY: 'false', + BASE_SHA: '0123456789abcdef0123456789abcdef01234567', + ...env, + }, + }, + ); + return {status: result.status, output: fs.readFileSync(output, 'utf8')}; + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } +} + describe('interaction proof routing', () => { it('is joined by test', () => { expect(ci.jobs.test.needs).toContain('pr-interaction'); @@ -84,6 +134,41 @@ describe('interaction proof routing', () => { expect(decide().run).toContain('if [ "$scope" = 1 ]; then'); }); + // AST-030 FR9: removing a fail-closed edge fails a test. Only a helper that + // answers "out of scope" (exit 1) skips the proofs; an unknown answer, a + // failed fetch, a helper missing at the base, or no base runs them. + it('runs the proofs unless the helper answers "out of scope"', () => { + const decided = options => runStep(decide().run, options); + for (const [label, options, expected] of [ + ['in scope (helper exit 0)', {helper: 0}, 'true'], + ['out of scope (helper exit 1)', {helper: 1}, 'false'], + ['unknown (helper exit 2)', {helper: 2}, 'true'], + ['the base cannot be fetched', {fetch: 1}, 'true'], + ['the helper is missing at the base', {helper: 'missing'}, 'true'], + ['no base SHA', {env: {BASE_SHA: ''}}, 'true'], + ['docsite only', {env: {DOCSITE_ONLY: 'true'}, helper: 0}, 'false'], + [ + 'not a pull request', + {env: {EVENT_NAME: 'merge_group'}, helper: 0}, + 'false', + ], + ]) { + const {status, output} = decided(options); + expect(status, label).toBe(0); + expect(output, label).toBe(`applies=${expected}\n`); + } + }); + + it('fails each guard step when it runs', () => { + for (const [job, name] of [ + [scope, 'Require successful scope classification'], + [proofs, 'Require a decided interaction scope'], + [proofs, "Require this pull request's Storybook build"], + ]) { + expect(runStep(step(job, name).run).status, name).toBe(1); + } + }); + it('keeps the harness, components, Storybook and its own setup in scope', () => { const pattern = new RegExp( decide().run.match(/bash "\$helper" "\$BASE_SHA" '([^']+)'/)[1], diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 8139a9fa33348..fad8af855df60 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -32,7 +32,7 @@ node internal/interaction-harness/scripts/webkit-container.mjs [playwright test | Module | What it gives a spec | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact release velocity, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. | +| `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. | | `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`. | diff --git a/internal/interaction-harness/src/scripts.ts b/internal/interaction-harness/src/scripts.ts index 79e8744060f78..91d52f9cacf7b 100644 --- a/internal/interaction-harness/src/scripts.ts +++ b/internal/interaction-harness/src/scripts.ts @@ -143,10 +143,12 @@ export interface FlickOptions { /** * A throw: the finger moves with the given velocity profile and lifts while * still moving (the lift lands a quarter frame after the last move, as on a - * device). Its last move runs at `velocity` times `profile(1)`, so with the - * default constant profile, or any profile that ends at 1, the release - * velocity (what `releaseVelocity` and a velocity tracker read from the last - * move) is exactly `velocity`. + * device). Its last move runs at `velocity` times `profile(1)`, so with any + * profile that ends at 1 the last move, which is what `releaseVelocity` + * reads, runs at exactly `velocity`. A tracker that blends recent intervals + * (a Core component's, `architecture:interaction-modality` INV10) reads + * exactly `velocity` only when the profile is flat over those intervals, as + * the default constant profile is. */ export function flick(from: Point, options: FlickOptions): TouchScript { const { From eeef165d69af2adde1ac0c93a9fb537720f11290 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 25 Oct 2026 07:37:51 -0700 Subject: [PATCH 7/9] ci: a head cut before the interaction proofs passes with a notice pr-interaction runs the proofs on the pull request's head. Once the proofs land on main, an in-scope pull request whose branch predates them has no playwright.interaction.config.ts, and Playwright would fail the required `test` check until it merged main. The scope decision now passes such a head with a notice: the head has no config, and the pull request's own diff does not change it (the base-loaded scope helper says so). A pull request that deletes the config changes it, so the proofs still run and fail; a head that cannot be fetched, an unknown helper answer or an unknown scope also runs them. The routing test runs the decide script with a stub git for each of those cases, and each mutation of the new edges (the head fetch, the HEAD_SHA guard, `touches=0`, `|| touches=$?`, the `= 1` comparison, the in-scope condition) fails it. CI-Rerun: main's gate test (fixed in #8406) failed this head's test-node --- .github/scripts/ci-interaction.test.mjs | 79 ++++++++++++++++++++++--- .github/workflows/ci.yml | 20 ++++++- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/.github/scripts/ci-interaction.test.mjs b/.github/scripts/ci-interaction.test.mjs index 860d11c879c78..959a20ce6d35e 100644 --- a/.github/scripts/ci-interaction.test.mjs +++ b/.github/scripts/ci-interaction.test.mjs @@ -7,7 +7,10 @@ * `test` join requires pr-interaction, and a failing proof fails `test` (FR7). * A Linux job decides whether the change touches the harness, a component, * Storybook, or the proofs' own setup; only then does pr-interaction take a - * 4-core runner, and otherwise it passes on Linux without work. + * 4-core runner, and otherwise it passes on Linux without work. The proofs run + * on the pull request's head: a head cut before they existed (no + * playwright.interaction.config.ts) passes with a notice, and a pull request + * that deletes the config still runs them, and fails. */ import {spawnSync} from 'node:child_process'; @@ -29,11 +32,26 @@ const step = (job, name) => const decide = () => step(scope, 'Decide whether the interaction proofs apply'); const applies = "needs.interaction-scope.outputs.applies == 'true'"; +const HEAD_SHA = 'fedcba9876543210fedcba9876543210fedcba98'; +const CONFIG_PATTERN = '^playwright\\.interaction\\.config\\.ts$'; + // Runs a step's script as Actions runs it (bash -eo pipefail) with a stub -// `git` first on PATH: `fetch` exits FETCH, and `show` prints a helper that -// exits HELPER (or fails when HELPER is 'missing'). Returns the exit status -// and the outputs the script wrote. -function runStep(script, {env = {}, fetch = 0, helper = 0} = {}) { +// `git` first on PATH: fetching the base exits FETCH and fetching the head +// FETCH_HEAD; `show` prints a helper that answers HELPER, or TOUCHES for the +// config pattern (or fails when HELPER is 'missing'); `cat-file` finds the +// config in the head when HEAD_HAS_CONFIG. Returns the exit status and the +// outputs the script wrote. +function runStep( + script, + { + env = {}, + fetch = 0, + fetchHead = 0, + helper = 0, + touches = 1, + headHasConfig = true, + } = {}, +) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-interaction-')); try { const bin = path.join(dir, 'bin'); @@ -43,10 +61,11 @@ function runStep(script, {env = {}, fetch = 0, helper = 0} = {}) { [ '#!/usr/bin/env bash', 'case "$1" in', - ` fetch) exit ${fetch} ;;`, + ` fetch) case "$*" in *${HEAD_SHA}*) exit ${fetchHead} ;; *) exit ${fetch} ;; esac ;;`, helper === 'missing' ? ' show) exit 128 ;;' - : ` show) echo 'exit ${helper}' ;;`, + : ` show) printf '%s\\n' 'if [ "$2" = "${CONFIG_PATTERN}" ]; then exit ${touches}; fi' 'exit ${helper}' ;;`, + ` cat-file) exit ${headHasConfig ? 0 : 1} ;;`, ' *) exit 3 ;;', 'esac', '', @@ -67,6 +86,7 @@ function runStep(script, {env = {}, fetch = 0, helper = 0} = {}) { DOCSITE_ONLY: 'false', TOOLING_ONLY: 'false', BASE_SHA: '0123456789abcdef0123456789abcdef01234567', + HEAD_SHA, ...env, }, }, @@ -159,6 +179,50 @@ describe('interaction proof routing', () => { } }); + it('passes a head cut before the proofs existed, and still fails one that deletes their config', () => { + const decided = options => runStep(decide().run, options); + for (const [label, options, expected] of [ + ['the head has the config', {headHasConfig: true}, 'true'], + [ + 'the branch predates the proofs (no config, not changed by the PR)', + {headHasConfig: false, touches: 1}, + 'false', + ], + [ + 'the pull request deletes the config', + {headHasConfig: false, touches: 0}, + 'true', + ], + [ + 'whether the PR changes the config is unknown', + {headHasConfig: false, touches: 2}, + 'true', + ], + [ + 'the head cannot be fetched', + {headHasConfig: false, touches: 1, fetchHead: 1}, + 'true', + ], + [ + 'no head SHA', + {headHasConfig: false, touches: 1, env: {HEAD_SHA: ''}}, + 'true', + ], + [ + 'the scope itself is unknown', + {headHasConfig: false, touches: 1, helper: 2}, + 'true', + ], + ]) { + const {status, output} = decided(options); + expect(status, label).toBe(0); + expect(output, label).toBe(`applies=${expected}\n`); + } + expect(decide().run).toContain( + '::notice title=Interaction proofs predate this branch::', + ); + }); + it('fails each guard step when it runs', () => { for (const [job, name] of [ [scope, 'Require successful scope classification'], @@ -197,6 +261,7 @@ describe('interaction proof routing', () => { it('passes without work on docsite-only, tooling-only and merge-group runs', () => { expect(decide().env).toMatchObject({ + HEAD_SHA: '${{ github.event.pull_request.head.sha }}', EVENT_NAME: '${{ github.event_name }}', DOCSITE_ONLY: '${{ needs.check-scope.outputs.docsite_only }}', TOOLING_ONLY: '${{ needs.check-scope.outputs.tooling_only }}', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7df2ea07b73c9..77620b07de97e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3784,6 +3784,10 @@ jobs: # The helper comes from the base, since an older head lacks it. Exit 1 # means out of scope; 0 in scope; 2 unknown, so the proofs run. + # pr-interaction runs the proofs on the PR head. A head cut before they + # existed has no playwright.interaction.config.ts and passes with a + # notice; a PR that deletes the config (its diff changes it) still runs + # them, and fails. Anything undecided runs them. - name: Decide whether the interaction proofs apply id: scope env: @@ -3791,6 +3795,7 @@ jobs: DOCSITE_ONLY: ${{ needs.check-scope.outputs.docsite_only }} TOOLING_ONLY: ${{ needs.check-scope.outputs.tooling_only }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then echo 'Specialized lane; no interaction proofs.' @@ -3813,9 +3818,20 @@ jobs: if [ "$scope" = 1 ]; then echo 'No harness, component, Storybook or proof-setup change; skipping the interaction proofs.' echo 'applies=false' >> "$GITHUB_OUTPUT" - else - echo 'applies=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$scope" = 0 ] && [ -n "$HEAD_SHA" ] \ + && git fetch --quiet --no-tags --depth=1 origin "$HEAD_SHA" \ + && ! git cat-file -e "$HEAD_SHA:playwright.interaction.config.ts" 2>/dev/null; then + touches=0 + bash "$helper" "$BASE_SHA" '^playwright\.interaction\.config\.ts$' || touches=$? + if [ "$touches" = 1 ]; then + echo "::notice title=Interaction proofs predate this branch::This pull request's head has no playwright.interaction.config.ts; merge main to run the interaction proofs." + echo 'applies=false' >> "$GITHUB_OUTPUT" + exit 0 + fi fi + echo 'applies=true' >> "$GITHUB_OUTPUT" pr-interaction: needs: [check-scope, interaction-scope, build-storybook] From cf0ad08299c5261ebb729db218b4048cedda60b9 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 25 Oct 2026 09:07:05 -0700 Subject: [PATCH 8/9] chore(interaction): the WebKit container script moves up the stack Nothing in this change runs internal/interaction-harness/scripts/ webkit-container.mjs: CI runs the Chromium project, and the script only runs the WebKit project locally inside the Playwright image on hosts whose system libraries are too old for Playwright's WebKit build. The script, its README paragraph and the config comment that names it move to the stacked change whose WebKit evidence first runs through it, which keeps this one under the 2,000-line limit for a pull request. --- internal/interaction-harness/README.md | 8 --- .../scripts/webkit-container.mjs | 72 ------------------- playwright.interaction.config.ts | 3 - 3 files changed, 83 deletions(-) delete mode 100644 internal/interaction-harness/scripts/webkit-container.mjs diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index fad8af855df60..0edec528d0477 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -20,14 +20,6 @@ pnpm test:interaction --project chromium # everything, Chromium pnpm test:interaction # one spec, both engines ``` -On Linux distributions whose system libraries are older than Playwright's -WebKit build needs, run the WebKit project inside the official Playwright -image (podman or docker; the repository is mounted at the same path): - -```sh -node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…] -``` - ## Instruments | Module | What it gives a spec | diff --git a/internal/interaction-harness/scripts/webkit-container.mjs b/internal/interaction-harness/scripts/webkit-container.mjs deleted file mode 100644 index 32cc0452396df..0000000000000 --- a/internal/interaction-harness/scripts/webkit-container.mjs +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * Runs the interaction proofs' WebKit project inside the official Playwright - * container image, for hosts where WebKit cannot launch (Playwright's Linux - * WebKit build needs newer system libraries than some distributions ship). - * - * node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…] - * - * The repository is mounted at the same path, so node_modules, the built - * Storybook and the evidence directory are the host's own. The image tag - * matches the installed playwright, so its browsers match the client. - * Uses podman when present, else docker. `--network=host` lets the specs' - * local Storybook server and the browser reach each other. - */ -import {spawnSync} from 'node:child_process'; -import fs from 'node:fs'; -import {createRequire} from 'node:module'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -const root = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../..', -); -const require = createRequire(path.join(root, 'package.json')); -const {version} = JSON.parse( - fs.readFileSync(require.resolve('playwright/package.json'), 'utf8'), -); -const image = - process.env.PLAYWRIGHT_IMAGE ?? - `mcr.microsoft.com/playwright:v${version}-noble`; -const engine = ['podman', 'docker'].find( - cmd => spawnSync(cmd, ['--version'], {stdio: 'ignore'}).status === 0, -); -if (engine == null) { - console.error('webkit-container: neither podman nor docker is installed'); - process.exit(1); -} -const args = process.argv.slice(2); -const env = [ - 'ASTRYX_STORYBOOK_DIR', - 'ASTRYX_INTERACTION_EVIDENCE', - 'INTERACTION_WORKERS', - 'CI', -] - .filter(name => process.env[name] != null) - .flatMap(name => ['-e', `${name}=${process.env[name]}`]); -const command = [ - 'run', - '--rm', - '--network=host', - '--ipc=host', - '-v', - `${root}:${root}`, - '-w', - root, - ...env, - image, - 'node', - path.join(root, 'node_modules/@playwright/test/cli.js'), - 'test', - '--config', - 'playwright.interaction.config.ts', - '--project', - 'webkit', - ...args, -]; -console.log(`${engine} ${command.join(' ')}`); -const result = spawnSync(engine, command, {stdio: 'inherit'}); -process.exit(result.status ?? 1); diff --git a/playwright.interaction.config.ts b/playwright.interaction.config.ts index 357a5dc3b7642..b3dfd65472ea7 100644 --- a/playwright.interaction.config.ts +++ b/playwright.interaction.config.ts @@ -19,9 +19,6 @@ * * A spec that needs Chromium-only evidence (CDP tracing, scripted touch * timestamps) skips itself in WebKit with the reason; see the harness README. - * Where WebKit cannot launch on the host (missing system libraries on some - * Linux distributions), internal/interaction-harness/scripts/webkit-container.mjs - * runs the WebKit project inside the official Playwright image. * * SYNC: Keep the phone profile here equal to `phone` in * internal/interaction-harness/src/story.ts. From 37050d36b3bd4deee67b5904fc009ae692e0d729 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 25 Oct 2026 12:47:14 -0700 Subject: [PATCH 9/9] ci: the interaction jobs keep to the 15-minute budget and report their durations Since #8427 every job that can run on a pull request carries a `timeout-minutes` of at most 15, and `ci-duration` waits on every one of them to report run times. `pr-interaction` gets the budget's 15 minutes, like the other proof jobs, and `interaction-scope` 5, like the other jobs that only classify; both join `ci-duration`'s needs. `ci-duration-guard.test.mjs` failed three ways on this branch merged with main (no timeout on either job, 26 jobs expected in the report against 24 listed) and passes now. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0c2602ecd1d..a462cbf7e9aee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1720,6 +1720,7 @@ jobs: needs: [check-scope] if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} runs-on: ubuntu-slim + timeout-minutes: 5 permissions: contents: read outputs: @@ -1790,6 +1791,7 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} # Frame evidence measures timing: four cores, one Playwright worker. runs-on: ${{ needs.interaction-scope.outputs.applies == 'true' && '4-core-ubuntu' || 'ubuntu-slim' }} + timeout-minutes: 15 permissions: contents: read steps: @@ -2551,6 +2553,8 @@ jobs: build-sandbox, build, pr-a11y, + interaction-scope, + pr-interaction, test-build, fixture-contrast, visual-scout,