diff --git a/.github/scripts/ci-interaction.test.mjs b/.github/scripts/ci-interaction.test.mjs new file mode 100644 index 0000000000000..959a20ce6d35e --- /dev/null +++ b/.github/scripts/ci-interaction.test.mjs @@ -0,0 +1,276 @@ +// 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. 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'; +import fs from 'node:fs'; +import os from 'node:os'; +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'"; + +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: 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'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, 'git'), + [ + '#!/usr/bin/env bash', + 'case "$1" in', + ` fetch) case "$*" in *${HEAD_SHA}*) exit ${fetchHead} ;; *) exit ${fetch} ;; esac ;;`, + helper === 'missing' + ? ' show) exit 128 ;;' + : ` show) printf '%s\\n' 'if [ "$2" = "${CONFIG_PATTERN}" ]; then exit ${touches}; fi' 'exit ${helper}' ;;`, + ` cat-file) exit ${headHasConfig ? 0 : 1} ;;`, + ' *) 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', + HEAD_SHA, + ...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'); + 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'); + }); + + // 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('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'], + [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], + ); + 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({ + 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 }}', + }); + 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 2ea98403ea28d..85b7d7814a7fb 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -638,6 +638,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(); @@ -664,3 +665,57 @@ 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', () => { + // 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']), + ); + 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 = 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); + } + // Unknown scope (exit 2) runs the proofs; only "out of scope" skips them. + expect(scope).toContain('if [ "$scope" = 1 ]; then'); + }); +}); diff --git a/.github/scripts/ci-tooling-routing.test.mjs b/.github/scripts/ci-tooling-routing.test.mjs index d41a10962e40d..09d9dd65ee837 100644 --- a/.github/scripts/ci-tooling-routing.test.mjs +++ b/.github/scripts/ci-tooling-routing.test.mjs @@ -182,6 +182,7 @@ describe('Node-tooling CI routing', () => { 'test-build', 'registry-contract', 'test-native-swift', + 'pr-interaction', ]); expect(ci.jobs.build.needs).toEqual([ 'build-storybook', @@ -201,6 +202,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 cee2db154d91b..a462cbf7e9aee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -889,9 +889,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: [ @@ -901,6 +902,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 @@ -920,6 +922,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 @@ -1698,6 +1701,163 @@ 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 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 + timeout-minutes: 5 + permissions: + contents: read + outputs: + applies: ${{ steps.scope.outputs.applies }} + steps: + - 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 + + - 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 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: + 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 }} + HEAD_SHA: ${{ github.event.pull_request.head.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 [ -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/|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 'No harness, component, Storybook or proof-setup change; skipping the interaction proofs.' + echo 'applies=false' >> "$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] + 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: + - 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: needs.interaction-scope.outputs.applies == 'true' + uses: ./.github/actions/setup + with: + install: 'false' + + - name: Download Storybook artifact + 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: needs.interaction-scope.outputs.applies == 'true' + run: pnpm install --frozen-lockfile && npx playwright install chromium + + - name: Run interaction proofs + if: needs.interaction-scope.outputs.applies == '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() && needs.interaction-scope.outputs.applies == '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 @@ -2393,6 +2553,8 @@ jobs: build-sandbox, build, pr-a11y, + interaction-scope, + pr-interaction, test-build, fixture-contrast, visual-scout, 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..0edec528d0477 --- /dev/null +++ b/internal/interaction-harness/README.md @@ -0,0 +1,62 @@ +# @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 +``` + +## Instruments + +| Module | What it gives a spec | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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`. | + +## 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 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 +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 | +| 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, 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()`. +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/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts new file mode 100644 index 0000000000000..38aa9c4876b1c --- /dev/null +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -0,0 +1,208 @@ +// 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, + PARTIAL_LIFT_SKIP, + 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('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, + }) => { + 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..587b9f82d233a --- /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. 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. + * + * 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..321b76d1a49c5 --- /dev/null +++ b/internal/interaction-harness/src/scripts.test.ts @@ -0,0 +1,196 @@ +// 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: 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 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. + 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', () => { + 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..91d52f9cacf7b --- /dev/null +++ b/internal/interaction-harness/src/scripts.ts @@ -0,0 +1,417 @@ +// 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 `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; +} + +/** + * 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 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 { + 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; + 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; + 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` as it lifts: the slope of its last move, + * 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, + id = 0, + restMs = 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; + } + if (lastMove === 0) { + return {x: 0, y: 0}; + } + const last = samples[lastMove]; + if (lift != null && lift.t - last.t >= restMs) { + return {x: 0, y: 0}; + } + 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} + : { + x: ((last.x - before.x) / dt) * 1000, + y: ((last.y - before.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..1c16c4b1f82e0 --- /dev/null +++ b/internal/interaction-harness/src/touch.ts @@ -0,0 +1,312 @@ +// 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; + /** + * 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. */ +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, + 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. + const base = Date.now(); + return playSteps( + script, + options, + async (step, timestamp) => { + // 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: step.type, + touchPoints: + step.type !== 'touchEnd' + ? touchPoints(step.fingers) + : step.fingers.length > 0 + ? touchPoints(step.changed) + : [], + 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, + partialLifts: 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 53562fb35197c..51b433c77da51 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:a11y-contract": "playwright test --config playwright.config.ts --project chromium", "test:a11y-contract:pr": "node .github/scripts/a11y-pr-contracts.mjs", "test:docsite-browser": "playwright test --config apps/docsite/playwright.config.ts", + "test:interaction": "playwright test --config playwright.interaction.config.ts", "typecheck": "pnpm -r --no-bail --workspace-concurrency=1 --if-present --filter \"!@astryxdesign/docsite\" --filter \"!@astryxdesign/sandbox\" run typecheck", "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", diff --git a/playwright.interaction.config.ts b/playwright.interaction.config.ts new file mode 100644 index 0000000000000..b3dfd65472ea7 --- /dev/null +++ b/playwright.interaction.config.ts @@ -0,0 +1,74 @@ +// 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. + * + * 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':