From 7ba23e0232986d185ba051e45ed1f230b1c55f0f Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Mon, 2 Nov 2026 14:03:56 -0800 Subject: [PATCH 1/8] CI: pilot Win32 Storybook physical input in a separate pass Preserve the masked accessibility smoke run, select Checkbox click and Button keyboard/focus callbacks in a serial unmasked pass, and reject skipped or missing required results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .github/workflows/pr.yml | 33 +++++++++++++++++++++++++++++++ apps/storybook/agent-map.yaml | 1 + apps/storybook/desktop.config.mts | 1 + 3 files changed, 35 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3117b5b6eb..f2a9a4aa29 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -540,6 +540,39 @@ jobs: run: yarn storybook smoke --win32 --mode stories-and-tests working-directory: apps/storybook + - name: Test Win32 Storybook physical input pilot + shell: pwsh + working-directory: apps/storybook + env: + FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES: '' + run: | + $ErrorActionPreference = 'Stop' + $requiredTests = [ordered]@{ + 'components-checkbox--default' = 'toggles native checked state in both directions' + 'components-button--focus-management' = 'honors pointer focus policy and invokes exactly once per Enter and Space' + } + $env:STORYBOOK_SMOKE_STORY = "{$($requiredTests.Keys -join ',')}" + $env:STORYBOOK_SMOKE_TEST = "{$($requiredTests.Values -join ',')}" + $previousRuns = @(Get-ChildItem artifacts\desktop -Directory | Select-Object -ExpandProperty FullName) + + yarn storybook smoke --win32 --mode stories-and-tests + if ($LASTEXITCODE -ne 0) { throw 'Win32 physical input smoke failed.' } + + $runs = @(Get-ChildItem artifacts\desktop -Directory | Where-Object { $_.FullName -notin $previousRuns }) + if ($runs.Count -ne 1) { throw "Expected one new physical input report; found $($runs.Count)." } + $report = Get-Content (Join-Path $runs[0].FullName 'run.json') -Raw | ConvertFrom-Json + if ($report.schemaVersion -ne 1 -or $report.status -cne 'passed') { + throw 'The physical input run did not produce a successful canonical report.' + } + foreach ($entry in $requiredTests.GetEnumerator()) { + $title = "$($entry.Key) / $($entry.Value)" + $test = @($report.tests | Where-Object { $_.title -ceq $title }) + if ($test.Count -ne 1 -or $test[0].status -cne 'passed') { + throw "Required physical input test did not execute and pass: $title" + } + Write-Host "Verified physical input test: $title" + } + - name: Upload Win32 Storybook artifacts if: ${{ always() }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/apps/storybook/agent-map.yaml b/apps/storybook/agent-map.yaml index b90f1bd39f..92402aab97 100644 --- a/apps/storybook/agent-map.yaml +++ b/apps/storybook/agent-map.yaml @@ -36,6 +36,7 @@ services: lifecycle: attached-session-per-test-with-isolated-node-worker context_platform: macos-or-windows-or-win32-target-endpoint test_filter: --test + smoke_test_filter_env: STORYBOOK_SMOKE_TEST smoke_mode: stories-and-tests execution: navigate-and-remount-then-run-tests-grouped-by-story diff --git a/apps/storybook/desktop.config.mts b/apps/storybook/desktop.config.mts index 673ea7ac2b..26be927dfa 100644 --- a/apps/storybook/desktop.config.mts +++ b/apps/storybook/desktop.config.mts @@ -24,6 +24,7 @@ export default defineDesktopConfig({ reporting: { artifactsRoot: './artifacts/desktop' }, suites: { stories: { kind: 'csf-wdio' } }, integration: storybook({ + wdio: { test: process.env.STORYBOOK_SMOKE_TEST }, storyPackages: [ [ '@fluentui-react-native/components', From 626190c81b7270b0928ac73d6f5acd22305b3ea0 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Thu, 5 Nov 2026 16:05:05 -0800 Subject: [PATCH 2/8] CI: require complete Win32 Storybook input-mode coverage Declare exact accessibility, physical, and shared callbacks; run independent required lanes and reject missing or non-qualifying native execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .changeset/bright-input-lanes.md | 5 + .github/workflows/pr.yml | 59 ++-- apps/storybook/README.md | 42 +++ apps/storybook/agent-map.yaml | 5 + apps/storybook/desktop.config.mts | 3 +- apps/storybook/input-test-modes.mts | 230 +++++++++++++++ apps/storybook/input-test-modes.test.mts | 127 +++++++++ apps/storybook/input-tests.win32.json | 262 ++++++++++++++++++ apps/storybook/package.json | 2 + apps/storybook/tsconfig.json | 2 +- packages/agentic/storybook-desktop/README.md | 6 + .../references/integration.md | 16 ++ .../src/cli/DesktopStorybookCli.test.ts | 43 +++ .../src/cli/DesktopStorybookCli.ts | 4 +- .../storybook-desktop/src/config/wdio.test.ts | 10 + .../storybook-desktop/src/config/wdio.ts | 18 +- .../src/testing/runWdioTests.ts | 17 +- references/agents/repository/development.md | 5 +- 18 files changed, 812 insertions(+), 44 deletions(-) create mode 100644 .changeset/bright-input-lanes.md create mode 100644 apps/storybook/input-test-modes.mts create mode 100644 apps/storybook/input-test-modes.test.mts create mode 100644 apps/storybook/input-tests.win32.json diff --git a/.changeset/bright-input-lanes.md b/.changeset/bright-input-lanes.md new file mode 100644 index 0000000000..7811c19ff6 --- /dev/null +++ b/.changeset/bright-input-lanes.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +--- + +Support exact story/callback selection for separate accessibility and physical-input coverage, consistently in discovery and smoke execution. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f2a9a4aa29..ab157b7cc1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -510,9 +510,13 @@ jobs: if-no-files-found: warn win32-storybook: - name: Win32 Storybook PR + name: Win32 Storybook PR (${{ matrix.input-mode }}) runs-on: windows-latest timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + input-mode: [accessibility, physical] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -534,50 +538,34 @@ jobs: uses: ./.github/actions/setup-desktop-driver with: platform: win32 - disabled-input-features: keyboard,physicalClick,wheel + disabled-input-features: ${{ matrix.input-mode == 'accessibility' && 'keyboard,physicalClick,wheel' || '' }} - - name: Smoke test Win32 Storybook - run: yarn storybook smoke --win32 --mode stories-and-tests + - name: Validate and capture the complete Win32 input catalog + id: input-coverage working-directory: apps/storybook + env: + STORYBOOK_INPUT_MODE: ${{ matrix.input-mode }} + run: yarn input-coverage begin - - name: Test Win32 Storybook physical input pilot - shell: pwsh + - name: Smoke test Win32 Storybook (${{ matrix.input-mode }}) + id: smoke working-directory: apps/storybook env: - FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES: '' - run: | - $ErrorActionPreference = 'Stop' - $requiredTests = [ordered]@{ - 'components-checkbox--default' = 'toggles native checked state in both directions' - 'components-button--focus-management' = 'honors pointer focus policy and invokes exactly once per Enter and Space' - } - $env:STORYBOOK_SMOKE_STORY = "{$($requiredTests.Keys -join ',')}" - $env:STORYBOOK_SMOKE_TEST = "{$($requiredTests.Values -join ',')}" - $previousRuns = @(Get-ChildItem artifacts\desktop -Directory | Select-Object -ExpandProperty FullName) - - yarn storybook smoke --win32 --mode stories-and-tests - if ($LASTEXITCODE -ne 0) { throw 'Win32 physical input smoke failed.' } - - $runs = @(Get-ChildItem artifacts\desktop -Directory | Where-Object { $_.FullName -notin $previousRuns }) - if ($runs.Count -ne 1) { throw "Expected one new physical input report; found $($runs.Count)." } - $report = Get-Content (Join-Path $runs[0].FullName 'run.json') -Raw | ConvertFrom-Json - if ($report.schemaVersion -ne 1 -or $report.status -cne 'passed') { - throw 'The physical input run did not produce a successful canonical report.' - } - foreach ($entry in $requiredTests.GetEnumerator()) { - $title = "$($entry.Key) / $($entry.Value)" - $test = @($report.tests | Where-Object { $_.title -ceq $title }) - if ($test.Count -ne 1 -or $test[0].status -cne 'passed') { - throw "Required physical input test did not execute and pass: $title" - } - Write-Host "Verified physical input test: $title" - } + STORYBOOK_INPUT_MODE: ${{ matrix.input-mode }} + run: yarn storybook smoke --win32 --mode stories-and-tests + + - name: Verify every declared Win32 input callback + if: ${{ always() && !cancelled() && steps.input-coverage.outcome == 'success' }} + working-directory: apps/storybook + env: + STORYBOOK_INPUT_MODE: ${{ matrix.input-mode }} + run: yarn input-coverage verify - name: Upload Win32 Storybook artifacts if: ${{ always() }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: Storybook_win32_paper_${{ github.run_id }}_${{ github.run_attempt }} + name: Storybook_win32_paper_${{ matrix.input-mode }}_${{ github.run_id }}_${{ github.run_attempt }} path: | apps/storybook/artifacts/win32 apps/storybook/artifacts/desktop @@ -685,6 +673,7 @@ jobs: - ios - windows - win32 + - win32-storybook - check-changesets - publish-dry-run-pack - test-links diff --git a/apps/storybook/README.md b/apps/storybook/README.md index cbc89ee41d..a2581fabf3 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -42,6 +42,48 @@ See `agent-map.yaml` for the current command contract and paths. Generated projects, Pods, manifests, reports, and build products are disposable. Change the owning config or source instead of generated output. +## Win32 input coverage + +Win32/Paper PR testing has independent accessibility and physical-input jobs. +Both traverse the full platform story catalog. The app-owned +[`input-tests.win32.json`](input-tests.win32.json) declares each authored callback +as `accessibility`, `physical`, or `both`; `both` callbacks execute in each job. +Declarations use exact story ID and callback-name pairs, not name-glob +cross-products. A semantic assertion that first navigates with the keyboard +still belongs to the physical lane. + +The accessibility lane masks keyboard, physical clicks, and wheel input and +explicitly uses accessibility clicks. The physical lane enables those features +and explicitly uses physical clicks. Windows/Fabric and macOS policies are +unchanged. Source assertions and capability checks remain authoritative; this +catalog does not convert unsupported actions into passes. + +Before smoke, `yarn input-coverage begin` checks the complete statically +discovered Win32 callback catalog against the declarations and captures the +source, lane, and preexisting report directories. New, removed, or renamed +callbacks require a reviewed catalog update. After smoke, +`yarn input-coverage verify` requires one fresh successful canonical report and +one clean passing attempt for every callback assigned to that lane. Unexpected +skips, unsupported results, missing callbacks, duplicates, retries, and flaky +passes fail the job. The existing Button #4373 quarantine remains selected; +only its exact documented skip is permitted and reported as **unvalidated**, +not passed. + +From this workspace, inspect a lane without executing native input: + +```powershell +$env:STORYBOOK_INPUT_MODE = 'accessibility' # Or 'physical'. +yarn storybook test --win32 --list +Remove-Item Env:\STORYBOOK_INPUT_MODE +``` + +Actual input execution requires the matching driver capability policy and an +authorized desktop; selecting a lane alone does not grant input authority. +Each CI lane uploads its own `Storybook_win32_paper___` +artifact, including canonical native results, the classified input catalog, +and a coverage summary. The required `PR` aggregate includes both matrix jobs. +Run `yarn test` here for catalog and report-gate regression tests. + ## Package guidance - Storybook integration, CLI, discovery, and callbacks: diff --git a/apps/storybook/agent-map.yaml b/apps/storybook/agent-map.yaml index 92402aab97..fd2429932d 100644 --- a/apps/storybook/agent-map.yaml +++ b/apps/storybook/agent-map.yaml @@ -3,6 +3,8 @@ purpose: Agent-optimized map of stable Storybook structure and runtime discovery sources: config: desktop.config.mts + win32_input_catalog: input-tests.win32.json + win32_input_coverage: input-test-modes.mts config_adapter: src/main.ts app_entry: index.js app_shell: src/StorybookApp.tsx @@ -37,6 +39,7 @@ services: context_platform: macos-or-windows-or-win32-target-endpoint test_filter: --test smoke_test_filter_env: STORYBOOK_SMOKE_TEST + win32_input_mode_env: STORYBOOK_INPUT_MODE smoke_mode: stories-and-tests execution: navigate-and-remount-then-run-tests-grouped-by-story @@ -46,6 +49,8 @@ agent_commands: inspect_instance: yarn storybook instance -- list_stories: yarn desktop-driver stories list --url --target run_story_tests: yarn storybook test -- --test + capture_win32_input_catalog: yarn input-coverage begin + verify_win32_input_coverage: yarn input-coverage verify lookup_order: - Generate the exact platform manifest; never guess ID slugging. diff --git a/apps/storybook/desktop.config.mts b/apps/storybook/desktop.config.mts index 26be927dfa..a0ee2905e5 100644 --- a/apps/storybook/desktop.config.mts +++ b/apps/storybook/desktop.config.mts @@ -7,6 +7,7 @@ import { import { defineDesktopConfig } from '@fluentui-react-native/desktop-test/config'; import appManifest from './app.json' with { type: 'json' }; +import { inputModeOptions } from './input-test-modes.mts'; const win32Host = { component: 'AgenticStorybook', @@ -24,7 +25,7 @@ export default defineDesktopConfig({ reporting: { artifactsRoot: './artifacts/desktop' }, suites: { stories: { kind: 'csf-wdio' } }, integration: storybook({ - wdio: { test: process.env.STORYBOOK_SMOKE_TEST }, + wdio: { test: process.env.STORYBOOK_SMOKE_TEST, ...inputModeOptions() }, storyPackages: [ [ '@fluentui-react-native/components', diff --git a/apps/storybook/input-test-modes.mts b/apps/storybook/input-test-modes.mts new file mode 100644 index 0000000000..86e8d5c734 --- /dev/null +++ b/apps/storybook/input-test-modes.mts @@ -0,0 +1,230 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import rawCatalog from './input-tests.win32.json' with { type: 'json' }; + +export type InputMode = 'accessibility' | 'physical'; +export type InputTest = { + storyId: string; + testName: string; + mode: InputMode | 'both'; + quarantineReason?: string; +}; + +const appRoot = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(appRoot, '..', '..'); +const catalogPath = path.join(appRoot, 'input-tests.win32.json'); +const artifactsRoot = path.join(appRoot, 'artifacts', 'desktop'); +const coverageRoot = path.join(appRoot, 'artifacts', 'win32'); + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an input-coverage object.'); + return value as Record; +} + +function array(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error('Expected an input-coverage array.'); + return value; +} + +function text(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) throw new Error('Expected a non-empty input-coverage string.'); + return value; +} + +function identity(test: Pick): string { + return JSON.stringify([test.storyId, test.testName]); +} + +export function readInputMode(value = process.env.STORYBOOK_INPUT_MODE): InputMode | undefined { + if (value === undefined || value === '') return undefined; + if (value !== 'accessibility' && value !== 'physical') throw new Error('STORYBOOK_INPUT_MODE must be accessibility or physical.'); + return value; +} + +export function parseInputCatalog(value: unknown = rawCatalog): InputTest[] { + const catalog = object(value); + if (catalog.schemaVersion !== 1 || catalog.endpoint !== 'win32') throw new Error('Unsupported Win32 input-test catalog.'); + const tests = array(catalog.stories).flatMap((entry) => { + const story = object(entry); + const storyId = text(story.storyId); + if (Object.keys(story).some((key) => !['storyId', 'accessibility', 'physical', 'both'].includes(key))) { + throw new Error(`Unknown input-mode declaration for ${storyId}.`); + } + const result = (['accessibility', 'physical', 'both'] as const).flatMap((mode) => + story[mode] === undefined ? [] : array(story[mode]).map((name): InputTest => ({ storyId, testName: text(name), mode })), + ); + if (!result.length) throw new Error(`No input tests declared for ${storyId}.`); + return result; + }); + if (!tests.length || new Set(tests.map(identity)).size !== tests.length) { + throw new Error('Input-test catalog must have unique callback identities.'); + } + const quarantines = new Set(); + for (const entry of array(catalog.quarantines)) { + const quarantine = object(entry); + const key = identity({ storyId: text(quarantine.storyId), testName: text(quarantine.testName) }); + const test = tests.find((candidate) => identity(candidate) === key); + if (!test || quarantines.has(key)) throw new Error('Quarantine must identify one declared input test.'); + quarantines.add(key); + test.quarantineReason = text(quarantine.reason); + } + return tests; +} + +export function selectedInputTests(mode: InputMode, catalog = parseInputCatalog()): InputTest[] { + return catalog.filter((test) => test.mode === mode || test.mode === 'both'); +} + +export function assertCatalogMatches(discovery: unknown, catalog = parseInputCatalog()): void { + const actual = array(discovery).flatMap((entry) => { + const story = object(entry); + const storyId = text(story.id); + return array(story.tests ?? ['default']).map((name) => identity({ storyId, testName: text(name) })); + }); + const declared = new Set(catalog.map(identity)); + const actualSet = new Set(actual); + const missing = actual.filter((key) => !declared.has(key)); + const stale = [...declared].filter((key) => !actualSet.has(key)); + if (actualSet.size !== actual.length || missing.length || stale.length) { + throw new Error(`Win32 input catalog drift. Unclassified: ${missing.join('; ')}. Stale declarations: ${stale.join('; ')}.`); + } +} + +export function inputModeOptions(): { + clickMode?: InputMode; + testCases?: readonly { storyId: string; testName: string }[]; +} { + const mode = readInputMode(); + return mode ? { clickMode: mode, testCases: selectedInputTests(mode).map(({ storyId, testName }) => ({ storyId, testName })) } : {}; +} + +export function verifyInputReport(value: unknown, selected: InputTest[], startedAt: number): { passed: number; quarantined: string[] } { + const report = object(value); + const context = object(report.context); + const counts = object(report.counts); + if ( + report.schemaVersion !== 1 || + report.kind !== 'lifecycle' || + report.status !== 'passed' || + context.endpoint !== 'win32' || + context.renderer !== 'paper' || + !Number.isFinite(Date.parse(text(report.startedAt))) || + Date.parse(text(report.startedAt)) < startedAt || + !Number.isFinite(Date.parse(text(report.finishedAt))) || + Date.parse(text(report.finishedAt)) < Date.parse(text(report.startedAt)) + ) { + throw new Error('Expected a fresh, successful Win32/Paper lifecycle report.'); + } + if (['failed', 'unsupported', 'incomplete', 'retries', 'flaky'].some((key) => counts[key] !== 0) || array(report.failures).length) { + throw new Error('Input coverage cannot accept failed, unsupported, unfinished, retried, or flaky execution.'); + } + if (array(report.work).some((entry) => !['passed', 'skipped'].includes(text(object(entry).status)))) { + throw new Error('Selected input work did not finish.'); + } + const tests = array(report.tests).map(object); + if (counts.selected !== tests.length || new Set(tests.map((test) => text(test.testId))).size !== tests.length) { + throw new Error('Input report has inconsistent or duplicate test results.'); + } + const expected = new Set(selected.map((test) => `${test.storyId} / ${test.testName}`)); + if (tests.some((test) => text(test.testId).startsWith('csf-') && !expected.has(text(test.title)))) { + throw new Error('Input report executed a callback outside its declared lane.'); + } + const quarantined: string[] = []; + for (const test of selected) { + const title = `${test.storyId} / ${test.testName}`; + const matches = tests.filter((result) => result.title === title); + if (matches.length !== 1) throw new Error(`Expected one result for ${title}; found ${matches.length}.`); + const result = matches[0]; + const attempts = array(result.attempts).map(object); + if (result.flaky !== false || attempts.length !== 1 || attempts[0].attempt !== 0 || attempts[0].status !== result.status) { + throw new Error(`Input callback did not have one clean attempt: ${title}.`); + } + if (result.status === 'skipped' && test.quarantineReason && attempts[0].reason === test.quarantineReason) { + quarantined.push(title); + } else if (result.status !== 'passed') { + throw new Error(`Required input callback did not execute and pass: ${title} (${result.status}).`); + } + } + if (counts.skipped !== quarantined.length || tests.some((test) => test.status !== 'passed' && !quarantined.includes(text(test.title)))) { + throw new Error('Input report contains an unexpected skip or non-passing result.'); + } + if (counts.passed !== tests.length - quarantined.length || counts.executed !== counts.passed) { + throw new Error('Input report has inconsistent execution counts.'); + } + return { passed: selected.length - quarantined.length, quarantined }; +} + +function runDirectories(): string[] { + return existsSync(artifactsRoot) + ? readdirSync(artifactsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : []; +} + +function catalogDigest(): string { + return createHash('sha256').update(readFileSync(catalogPath)).digest('hex'); +} + +function sourceCommit(): string { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); +} + +function main(): void { + const mode = readInputMode(); + if (!mode) throw new Error('Set STORYBOOK_INPUT_MODE before capturing or verifying input coverage.'); + const snapshotPath = path.join(coverageRoot, `input-coverage-${mode}.json`); + if (process.argv[2] === 'begin') { + const discovery = JSON.parse( + execFileSync( + process.execPath, + [ + path.join(root, '.yarn', 'releases', 'yarn-4.18.0.cjs'), + 'workspace', + '@fluentui-react-native/agentic-components-storybook', + 'storybook', + 'test', + '--win32', + '--list', + ], + { cwd: root, encoding: 'utf8', env: { ...process.env, STORYBOOK_INPUT_MODE: '' }, stdio: ['ignore', 'pipe', 'inherit'] }, + ), + ); + const catalog = parseInputCatalog(); + assertCatalogMatches(discovery, catalog); + mkdirSync(coverageRoot, { recursive: true }); + writeFileSync( + snapshotPath, + `${JSON.stringify({ schemaVersion: 1, mode, sourceCommit: sourceCommit(), catalogDigest: catalogDigest(), startedAt: Date.now(), previousRuns: runDirectories(), catalog, discovery }, null, 2)}\n`, + ); + console.log(`Declared ${selectedInputTests(mode, catalog).length} ${mode} callbacks; both-mode callbacks execute in each lane.`); + } else if (process.argv[2] === 'verify') { + const snapshot = object(JSON.parse(readFileSync(snapshotPath, 'utf8'))); + if ( + snapshot.mode !== mode || + snapshot.sourceCommit !== sourceCommit() || + snapshot.catalogDigest !== catalogDigest() || + typeof snapshot.startedAt !== 'number' + ) { + throw new Error('Input coverage snapshot does not match the current source and lane.'); + } + const previous = array(snapshot.previousRuns).map(text); + const runs = runDirectories().filter((run) => !previous.includes(run)); + if (runs.length !== 1) throw new Error(`Expected one fresh input run; found ${runs.length}.`); + const report = JSON.parse(readFileSync(path.join(artifactsRoot, runs[0], 'run.json'), 'utf8')); + if (object(report).runId !== runs[0]) throw new Error('Input report identity does not match its run directory.'); + const result = verifyInputReport(report, selectedInputTests(mode), snapshot.startedAt); + const summary = `## Win32/Paper ${mode} input coverage\n\n${result.passed} authored callbacks executed and passed. ${result.quarantined.length} explicitly quarantined callbacks remain unvalidated.\n${result.quarantined.map((title) => `- ${title}: existing #4373 quarantine; not counted as passed.`).join('\n')}\n`; + writeFileSync(path.join(coverageRoot, `input-coverage-${mode}.md`), summary); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + console.log(summary); + } else { + throw new Error('Use input-coverage begin or input-coverage verify.'); + } +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) main(); diff --git a/apps/storybook/input-test-modes.test.mts b/apps/storybook/input-test-modes.test.mts new file mode 100644 index 0000000000..26eda2b513 --- /dev/null +++ b/apps/storybook/input-test-modes.test.mts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { assertCatalogMatches, parseInputCatalog, readInputMode, selectedInputTests, verifyInputReport } from './input-test-modes.mts'; + +const catalog = { + schemaVersion: 1, + endpoint: 'win32', + stories: [ + { storyId: 'fixture--default', both: ['semantics'], accessibility: ['native action'], physical: ['keyboard'] }, + { storyId: 'fixture--second', physical: ['semantics', 'quarantined'] }, + ], + quarantines: [{ storyId: 'fixture--second', testName: 'quarantined', reason: 'Existing issue #4373.' }], +}; + +function report(mode: 'accessibility' | 'physical') { + const selected = selectedInputTests(mode, parseInputCatalog(catalog)); + const tests = selected.map((entry, index) => { + const status = entry.quarantineReason ? 'skipped' : 'passed'; + return { + testId: `csf-${index}`, + title: `${entry.storyId} / ${entry.testName}`, + status, + flaky: false, + attempts: [{ attempt: 0, status, durationMs: 1, ...(entry.quarantineReason ? { reason: entry.quarantineReason } : {}) }], + }; + }); + const skipped = tests.filter((entry) => entry.status === 'skipped').length; + return { + schemaVersion: 1, + kind: 'lifecycle', + status: 'passed', + context: { endpoint: 'win32', renderer: 'paper' }, + startedAt: '2026-11-05T20:00:00.000Z', + finishedAt: '2026-11-05T20:01:00.000Z', + counts: { + selected: tests.length, + executed: tests.length - skipped, + passed: tests.length - skipped, + failed: 0, + skipped, + unsupported: 0, + incomplete: 0, + retries: 0, + flaky: 0, + }, + tests, + failures: [], + work: [], + }; +} + +test('routes exact identities into accessibility, physical, and both lanes', () => { + const parsed = parseInputCatalog(catalog); + assert.deepEqual( + selectedInputTests('accessibility', parsed).map(({ testName }) => testName), + ['native action', 'semantics'], + ); + assert.deepEqual( + selectedInputTests('physical', parsed).map(({ testName }) => testName), + ['keyboard', 'semantics', 'semantics', 'quarantined'], + ); + assert.throws(() => readInputMode('automatic'), /STORYBOOK_INPUT_MODE/); +}); + +test('rejects duplicate, unknown-mode, and unclassified or stale callback declarations', () => { + const duplicate = structuredClone(catalog); + duplicate.stories[0].physical.push('semantics'); + assert.throws(() => parseInputCatalog(duplicate), /unique callback/); + assert.throws(() => parseInputCatalog({ ...catalog, stories: [{ storyId: 'fixture', accidental: ['case'] }] }), /Unknown input-mode/); + const discovery = [ + { id: 'fixture--default', tests: ['semantics', 'native action', 'keyboard'] }, + { id: 'fixture--second', tests: ['semantics', 'quarantined'] }, + ]; + assert.doesNotThrow(() => assertCatalogMatches(discovery, parseInputCatalog(catalog))); + assert.throws( + () => assertCatalogMatches([...discovery, { id: 'new', tests: ['unclassified'] }], parseInputCatalog(catalog)), + /Unclassified/, + ); + assert.throws(() => assertCatalogMatches(discovery.slice(0, 1), parseInputCatalog(catalog)), /Stale declarations/); +}); + +test('accepts complete execution and reports the existing exact quarantine as unvalidated', () => { + const parsed = parseInputCatalog(catalog); + assert.deepEqual(verifyInputReport(report('accessibility'), selectedInputTests('accessibility', parsed), 0), { + passed: 2, + quarantined: [], + }); + assert.deepEqual(verifyInputReport(report('physical'), selectedInputTests('physical', parsed), 0), { + passed: 3, + quarantined: ['fixture--second / quarantined'], + }); +}); + +test('rejects missing, duplicate, unexpected-lane, and skipped expected results', () => { + const selected = selectedInputTests('accessibility', parseInputCatalog(catalog)); + const missing = report('accessibility'); + missing.tests.pop(); + missing.counts.selected--; + assert.throws(() => verifyInputReport(missing, selected, 0), /Expected one result/); + const duplicate = report('accessibility'); + duplicate.tests.push(duplicate.tests[0]); + duplicate.counts.selected++; + assert.throws(() => verifyInputReport(duplicate, selected, 0), /duplicate/); + const unexpected = report('accessibility'); + unexpected.tests[0].title = 'fixture--default / keyboard'; + assert.throws(() => verifyInputReport(unexpected, selected, 0), /outside its declared lane/); + const skipped = report('accessibility'); + skipped.tests[0].status = 'skipped'; + skipped.tests[0].attempts[0].status = 'skipped'; + assert.throws(() => verifyInputReport(skipped, selected, 0), /did not execute and pass/); +}); + +test('rejects changed quarantine reasons, stale/wrong-target reports, retry passes, and unfinished work', () => { + const selected = selectedInputTests('physical', parseInputCatalog(catalog)); + const changed = report('physical'); + changed.tests[3].attempts[0].reason = 'Physical input unavailable.'; + assert.throws(() => verifyInputReport(changed, selected, 0), /did not execute and pass/); + assert.throws(() => verifyInputReport(report('physical'), selected, Date.parse('2026-11-06T00:00:00Z')), /fresh/); + const wrongTarget = report('physical'); + wrongTarget.context.endpoint = 'windows'; + assert.throws(() => verifyInputReport(wrongTarget, selected, 0), /Win32\/Paper/); + const retried = report('physical'); + retried.tests[0].attempts.push(retried.tests[0].attempts[0]); + assert.throws(() => verifyInputReport(retried, selected, 0), /one clean attempt/); + assert.throws(() => verifyInputReport({ ...report('physical'), work: [{ status: 'not-run' }] }, selected, 0), /did not finish/); +}); diff --git a/apps/storybook/input-tests.win32.json b/apps/storybook/input-tests.win32.json new file mode 100644 index 0000000000..7d6859913c --- /dev/null +++ b/apps/storybook/input-tests.win32.json @@ -0,0 +1,262 @@ +{ + "schemaVersion": 1, + "endpoint": "win32", + "stories": [ + { + "storyId": "components-avatar--activity-ring", + "both": [ + "AVT-004 exposes the informative identity as a named native image", + "toggles the activity ring at every size without crashing or changing avatar bounds" + ] + }, + { + "storyId": "components-button--default", + "both": ["exposes enabled button semantics"], + "physical": ["supports native pointer activation"] + }, + { + "storyId": "components-button--externally-driven-selection", + "both": ["starts unselected", "updates and resets caller-owned selection"] + }, + { + "storyId": "components-button--focus-management", + "both": ["exposes the native disabled accessibility state"], + "physical": [ + "honors pointer focus policy and invokes exactly once per Enter and Space", + "updates same-target modality without refocusing and shares it with nested themes", + "skips disabled stops and does not activate the new owner on a stale key-up", + "suppresses repeats and activates at the native platform key phase", + "releases pressed feedback after an intervening key without a stale activation", + "releases repeated modifiers before the next Tab" + ] + }, + { + "storyId": "components-button--focus-requests", + "physical": ["confirms native requests without changing modality and rejects disabled or detached targets"] + }, + { + "storyId": "components-button--native-names", + "both": ["BTN-004 exposes plain, slot and numeric native names while preserving caller names"] + }, + { + "storyId": "components-button--native-toggle-selection", + "accessibility": ["BTN-002 BTN-005 Win32 native Toggle updates caller selection without reflow or duplicate presses"] + }, + { + "storyId": "components-button--native-toggle-semantics", + "both": [ + "BTN-004 BTN-005 exposes native button roles and enabled or disabled state", + "BTN-005 Windows native checked projection reflects caller selection including disabled" + ], + "accessibility": [ + "BTN-005 Win32 Toggle forwards once without a press or a controlled selection change", + "BTN-004 Win32 disabled public activation rejects without callbacks or a checked-state change" + ] + }, + { + "storyId": "components-card--native-structure", + "both": [ + "CRD-002 renders structured sections in reading order", + "CRD-005 keeps a nested action enabled inside a disabled selectable surface" + ] + }, + { + "storyId": "components-checkbox--constrained-text", + "both": ["CBX-003 wraps the label into a taller constrained row", "CBX-003 wraps secondary text into a taller constrained row"] + }, + { + "storyId": "components-checkbox--default", + "physical": ["toggles native checked state in both directions"] + }, + { + "storyId": "components-checkbox--hidden-label", + "both": ["CBX-004 preserves native explicit and fallback names without visible labels"] + }, + { + "storyId": "components-dropdown--appearance", + "both": ["DRP-011 exposes a nonempty selected native value without input"] + }, + { + "storyId": "components-dropdown--default", + "both": ["DRP-011 exposes a named, empty, collapsed native combobox before input"], + "physical": [ + "opens from the keyboard on the first option and commits with Enter", + "reopens on the selected option and navigates with Home and End", + "exposes a named list of selectable options", + "closes on Tab and moves focus past the trigger", + "closes on Shift+Tab and returns focus to the trigger", + "opens from the pointer on the first option and selects with a click" + ] + }, + { + "storyId": "components-dropdown--long-list", + "physical": ["enters on the controlled selection and reaches the end of a scrolling list"] + }, + { + "storyId": "components-dropdown--multiple", + "physical": ["toggles options and stays open until dismissed"] + }, + { + "storyId": "components-input--default", + "physical": ["accepts keyboard input and clears it"] + }, + { + "storyId": "components-input--focus-management", + "physical": [ + "keeps editing keys in the TextInput and skips disabled fields on Tab", + "takes native focus from a physical pointer click" + ] + }, + { + "storyId": "components-input--focus-treatment", + "physical": ["retains native editor focus and geometry through every focus-treatment variant"] + }, + { + "storyId": "components-listboxitem--native-selection", + "both": [ + "LBI-005 standalone unselected exposes an enabled native button", + "LBI-005 standalone selected exposes an enabled native button", + "LBI-005 standalone disabled selection exposes a disabled native button", + "LBI-005 Windows native checked projection reflects standalone selection including disabled" + ] + }, + { + "storyId": "components-listboxitem--native-toggle-actions", + "accessibility": [ + "LBI-005 Win32 native Toggle forwards once without a press or controlled selection change", + "LBI-003 LBI-005 Win32 disabled native activation has no callbacks or selection change" + ] + }, + { + "storyId": "components-menu--default", + "physical": [ + "opens from the keyboard on the first row and closes on activation", + "navigates with Home, End, and type-ahead", + "opens and closes two submenu levels with arrows and Escape", + "closes the whole menu on Tab from a submenu and returns to the trigger", + "exposes named menu surfaces and submenu expanded state", + "opens from the pointer without a focused row and enters on Down", + "opens submenus on hover and closes the family when a nested row runs" + ] + }, + { + "storyId": "components-menu--disabled-focusable", + "physical": ["keeps a disabled row focusable without running it"] + }, + { + "storyId": "components-menubutton--accessible-names", + "both": ["MNB-006 exposes slot, numeric and explicit native names without input"] + }, + { + "storyId": "components-menubutton--default", + "both": ["MNB-006 exposes a named, collapsed native button before input"], + "physical": [ + "opens on Down to the first row and on Up to the last row", + "closes on Tab and moves focus past the trigger", + "closes on Shift+Tab and returns focus to the trigger", + "toggles from the pointer without reopening" + ] + }, + { + "storyId": "components-menuitem--disabled-focusable", + "both": ["exposes disabled native menu-item semantics"], + "physical": ["navigates onto a disabled row without activating it", "updates native disabled state without replacing the command"] + }, + { + "storyId": "components-menuitem--native-indicator-semantics", + "both": ["MNI-003 Win32 exposes checked native indicator roles, names and availability"], + "accessibility": ["MNI-003 Win32 Toggle forwards once without changing selection or pressing and disabled public activation rejects"] + }, + { + "storyId": "components-popover--controlled", + "physical": ["returns focus to the trigger when content closes the popup"] + }, + { + "storyId": "components-popover--default", + "physical": [ + "opens below the trigger as a named group and enters its first content", + "keeps Tab inside the popup and returns focus to the trigger on Escape", + "closes on outside input and on a trigger press without reopening" + ] + }, + { + "storyId": "components-popover--static-content", + "physical": ["focuses the surface when no content can take focus"] + }, + { + "storyId": "components-radio--native-selection-semantics", + "both": ["RAD-003 RAD-005 Win32 exposes native RadioButton selection, names and disabled state"], + "accessibility": ["RAD-003 Win32 Select forwards once without self-selection or presses and disabled public activation rejects"] + }, + { + "storyId": "components-skeleton--motion", + "both": ["renders changing native pixels when gradient animation is available"] + }, + { + "storyId": "components-spinner--motion", + "both": ["renders changing native pixels unless reduced motion is enabled"] + }, + { + "storyId": "components-switch--checked", + "both": ["SWCH-001 SWCH-006 projects both initial checked values without input"] + }, + { + "storyId": "components-switch--focus-management", + "physical": ["toggles exactly once per pointer, Enter, and Space activation"] + }, + { + "storyId": "components-tablist--focus-management", + "physical": [ + "commits selection before focus, skips disabled tabs, and supports wrap, Home, and End", + "leaves modified navigation chords to their owner", + "exposes native selected and disabled accessibility states" + ] + }, + { + "storyId": "components-tablist--manual-focus-management", + "physical": ["keeps manual selection independent until Enter activates the focused tab"] + }, + { + "storyId": "components-tag--default", + "both": ["TAG-004 TAG-008 exposes the whole enabled tag as a native button"] + }, + { + "storyId": "components-tag--native-disabled", + "both": ["TAG-008 exposes disabled native button semantics"] + }, + { + "storyId": "native-callout--default", + "physical": ["opens, light-dismisses, and recreates a native popup"] + }, + { + "storyId": "native-callout--window-commands", + "physical": ["dismisses initially focused content on Escape"] + }, + { + "storyId": "native-focuszone--default", + "physical": ["uses platform directional navigation and exits the zone on Tab", "Shift+Tab exits to a preceding sibling"] + }, + { + "storyId": "native-focuszone--first-child-tab-exit", + "physical": [ + "Shift+Tab skips the first-child zone and later siblings while climbing ancestors", + "Tab exits a first-child zone to its next sibling" + ] + }, + { + "storyId": "native-focuszone--two-dimensional-navigation", + "physical": ["moves geometrically between rows"] + }, + { + "storyId": "primitives-layout-stable-text--overview", + "both": ["centers the intrinsic smaller line inside its larger reserve"] + } + ], + "quarantines": [ + { + "storyId": "components-button--focus-management", + "testName": "releases pressed feedback after an intervening key without a stale activation", + "reason": "Quarantined until #4373 is fixed: Win32 does not forward onPressOut after Space with an intervening key." + } + ] +} diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 4db10644f4..72ff5c61ef 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -14,6 +14,8 @@ "start": "rnx-cli start", "storybook": "storybook-desktop", "storybook-server": "storybook-desktop server", + "input-coverage": "node input-test-modes.mts", + "test": "node --test input-test-modes.test.mts", "desktop-driver": "desktop-driver", "desktop-test": "desktop-test", "prebuild": "sb-rn-get-stories --config-path src", diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index 71e3c3d41b..2dd82e084c 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -12,7 +12,7 @@ "composite": true, "tsBuildInfoFile": "node_modules/.cache/tsconfig.tsbuildinfo" }, - "include": ["index.js", "src", "app.json", "desktop.config.mts"], + "include": ["index.js", "src", "app.json", "desktop.config.mts", "input-test-modes.mts", "input-tests.win32.json"], "exclude": ["node_modules", "dist", ".cache"], "references": [ { diff --git a/packages/agentic/storybook-desktop/README.md b/packages/agentic/storybook-desktop/README.md index ea93fddaa9..ea48466790 100644 --- a/packages/agentic/storybook-desktop/README.md +++ b/packages/agentic/storybook-desktop/README.md @@ -25,6 +25,12 @@ The [reference index](references/README.md) routes platform, readiness, and troubleshooting tasks. Package agents should start from [AGENTS.md](AGENTS.md). +Applications can configure exact story/callback pairs with `wdio.testCases` +and an explicit click mode to partition accessibility and physical-input +coverage. See [integration](references/integration.md#exact-callback-selection) +for selection semantics; capability availability and execution verification +remain separate from selection. + ## Responsibilities - This package owns Storybook-specific configuration, story extraction, diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index 84276a2c55..703ead1de0 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -50,6 +50,22 @@ or generated story lists. The app's `src/config` import graph must load before the TypeScript build; use explicit `.ts` relative imports so `rewriteRelativeImportExtensions` emits valid `.js` paths. +### Exact callback selection + +`wdio.testCases` accepts a nonempty array of unique +`{ storyId, testName }` pairs. It intersects the existing story, tag, and test +filters and is applied consistently to static `test --list` discovery and +callback execution, including grouped smoke. Omitted selections preserve the +existing behavior; legacy single callbacks use the name `default`. Declared +pairs that do not identify an executable callback fail selection. + +An app can own a reviewed input-mode catalog and select different pairs with +explicit `wdio.clickMode: 'accessibility'` or `'physical'`. The shared runner +does not infer supported modes from callback names. Consumers must validate +catalog completeness and actual execution; declaring a test does not prove +that it ran. The Win32 Storybook app demonstrates this with separate CI lanes, +shared callbacks in both, and explicit reporting of its existing quarantine. + ## Responsibilities This package owns Storybook-specific CLI lifecycle, static story manifests, diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index 235a0c1be9..b548374e82 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -136,6 +136,49 @@ function makeConfig(platformOptions = {}) { } describe('DesktopStorybookCli', () => { + test('lists exact callback pairs without a cross-product of story IDs and test names', async () => { + const output = { write: jest.fn() }; + const config = makeDesktopStorybookConfig({ + projectRoot: storybookRoot, + wdio: { + testCases: [ + { storyId: 'button--first', testName: 'physical' }, + { storyId: 'button--second', testName: 'accessibility' }, + ], + }, + }); + const createStoryManifest = async () => ({ + endpoint: 'win32' as const, + schemaVersion: 1 as const, + platformManifestDigest: 'digest', + portablePlanDigest: 'portable', + entries: ['first', 'second'].map((name) => ({ + id: `button--${name}`, + name, + title: 'Button', + sourcePath: 'button.stories.tsx', + packageName: 'fixture', + tags: [], + wdio: { exportName: name, digest: 'callback', testNames: ['physical', 'accessibility'] }, + })), + }); + const cli = new DesktopStorybookCli(config, { createStoryManifest, output }); + await cli.test('win32', { list: true }); + expect(JSON.parse(output.write.mock.calls[0][0])).toEqual([ + { id: 'button--first', sourcePath: 'button.stories.tsx', tests: ['physical'] }, + { id: 'button--second', sourcePath: 'button.stories.tsx', tests: ['accessibility'] }, + ]); + await expect( + cli.test('win32', { + list: true, + testCases: [ + { storyId: 'button--first', testName: 'physical' }, + { storyId: 'button--second', testName: 'missing' }, + ], + }), + ).rejects.toThrow('No executable wdio callback matches declared identity'); + }); + test('discovers and filters named callbacks without connecting to an app', async () => { const output = { write: jest.fn() }; const config = makeDesktopStorybookConfig({ projectRoot: storybookRoot, wdio: { test: 'semantics' } }); diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts index 33755cd51a..1a4a1b1f3b 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -38,7 +38,7 @@ import { desktopStorybookTargetId } from '../driver/driverManifest.js'; import { NodeDesktopCommandRunner, STORYBOOK_VERBOSE } from './commandRunner.js'; import type { DesktopCommandRunner, PreparedDesktopCommand, RunningDesktopCommand } from './commandRunner.js'; import { formatDesktopStorybookSmokeTestSummary, runDesktopStorybookSmokeTests } from './smokeTests.js'; -import { matchesWdioTest, runWdioStoryTests, selectWdioStories } from '../testing/runWdioTests.js'; +import { matchesWdioCase, runWdioStoryTests, selectWdioStories } from '../testing/runWdioTests.js'; import { resolveWdioOptions, type DesktopStorybookWdioOptions } from '../config/wdio.js'; type ResolvedDesktopStorybookInstance = DesktopStorybookInstance & { @@ -230,7 +230,7 @@ export class DesktopStorybookCli { selected.map(({ id, sourcePath, wdio }) => ({ id, sourcePath, - ...(wdio?.testNames ? { tests: wdio.testNames.filter((name) => matchesWdioTest(name, settings.test)) } : {}), + ...(wdio?.testNames ? { tests: wdio.testNames.filter((name) => matchesWdioCase(id, name, settings)) } : {}), })), null, 2, diff --git a/packages/agentic/storybook-desktop/src/config/wdio.test.ts b/packages/agentic/storybook-desktop/src/config/wdio.test.ts index dd816c2bfc..8b88e2c16e 100644 --- a/packages/agentic/storybook-desktop/src/config/wdio.test.ts +++ b/packages/agentic/storybook-desktop/src/config/wdio.test.ts @@ -24,4 +24,14 @@ describe('Storybook-owned wdio configuration', () => { expect(() => resolveWdioOptions({ story: '' })).toThrow('wdio.story'); expect(() => resolveWdioOptions({ tag: ' ' })).toThrow('wdio.tag'); }); + + test('snapshots exact callback identities and rejects empty or duplicate selections', () => { + const testCases = [{ storyId: 'button--default', testName: 'activation' }]; + const settings = resolveWdioOptions({ testCases }); + testCases[0].testName = 'changed'; + expect(settings.testCases).toEqual([{ storyId: 'button--default', testName: 'activation' }]); + expect(() => resolveWdioOptions({ testCases: [] })).toThrow('wdio.testCases'); + expect(() => resolveWdioOptions({ testCases: [testCases[0], testCases[0]] })).toThrow('wdio.testCases'); + expect(() => resolveWdioOptions({ testCases: [{ storyId: '', testName: 'activation' }] })).toThrow('wdio.testCases'); + }); }); diff --git a/packages/agentic/storybook-desktop/src/config/wdio.ts b/packages/agentic/storybook-desktop/src/config/wdio.ts index 7e7b4102dd..f688418d5c 100644 --- a/packages/agentic/storybook-desktop/src/config/wdio.ts +++ b/packages/agentic/storybook-desktop/src/config/wdio.ts @@ -6,6 +6,7 @@ export type DesktopStorybookWdioOptions = { story?: string; tag?: string; test?: string; + testCases?: readonly { storyId: string; testName: string }[]; timeoutMs?: number; }; @@ -27,5 +28,20 @@ export function resolveWdioOptions( throw new TypeError(`wdio.${key} must be a non-empty string.`); } } - return Object.freeze({ ...options, timeoutMs, reporter, clickMode }); + const testCases = options.testCases?.map(({ storyId, testName }) => { + if (typeof storyId !== 'string' || !storyId.trim() || typeof testName !== 'string' || !testName.trim()) { + throw new TypeError('wdio.testCases must contain non-empty storyId and testName strings.'); + } + return Object.freeze({ storyId, testName }); + }); + if (testCases && (!testCases.length || new Set(testCases.map((entry) => JSON.stringify(entry))).size !== testCases.length)) { + throw new TypeError('wdio.testCases must be non-empty and contain unique callback identities.'); + } + return Object.freeze({ + ...options, + ...(testCases ? { testCases: Object.freeze(testCases) } : {}), + timeoutMs, + reporter, + clickMode, + }); } diff --git a/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts b/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts index 52cacc56c1..bc02972a32 100644 --- a/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts +++ b/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts @@ -57,10 +57,16 @@ export function selectWdioStories( manifest: DesktopStoryManifest, options: DesktopStorybookWdioOptions, ): readonly DesktopStoryManifestEntry[] { + for (const test of options.testCases ?? []) { + const entry = manifest.entries.find(({ id }) => id === test.storyId); + if (!entry?.wdio || !(entry.wdio.testNames ?? ['default']).includes(test.testName)) { + throw new Error(`No executable wdio callback matches declared identity: ${test.storyId} / ${test.testName}.`); + } + } return manifest.entries .filter((entry) => entry.wdio && (!options.story || path.matchesGlob(entry.id, options.story))) .filter((entry) => !options.tag || entry.tags.includes(options.tag)) - .filter((entry) => (entry.wdio?.testNames ?? ['default']).some((name) => matchesWdioTest(name, options.test))) + .filter((entry) => (entry.wdio?.testNames ?? ['default']).some((name) => matchesWdioCase(entry.id, name, options))) .sort((left, right) => left.id.localeCompare(right.id)); } @@ -80,7 +86,7 @@ export async function runWdioStoryTests( throw new Error(`Executable test source changed for ${entry.id}; regenerate the Storybook manifest.`); } return extracted.tests - .filter(({ name }) => matchesWdioTest(name ?? 'default', settings.test)) + .filter(({ name }) => matchesWdioCase(entry.id, name ?? 'default', settings)) .map(({ name, code }) => ({ entry, code, testName: name })); }); if (cases.length === 0) throw new Error('No executable wdio tests matched the requested selection.'); @@ -341,6 +347,13 @@ export function matchesWdioTest(name: string, pattern?: string): boolean { return pattern === undefined || path.matchesGlob(name, pattern); } +export function matchesWdioCase(storyId: string, testName: string, options: DesktopStorybookWdioOptions): boolean { + return ( + matchesWdioTest(testName, options.test) && + (options.testCases === undefined || options.testCases.some((entry) => entry.storyId === storyId && entry.testName === testName)) + ); +} + function csfTestId(storyId: string, testName?: string): string { return `csf-${createHash('sha256') .update(JSON.stringify([storyId, testName ?? 'default'])) diff --git a/references/agents/repository/development.md b/references/agents/repository/development.md index a68a2fd349..f5c3390321 100644 --- a/references/agents/repository/development.md +++ b/references/agents/repository/development.md @@ -79,8 +79,9 @@ through the `fluentui-scripts` CLI in [`scripts/`](../../../scripts/package.json - `yarn update-snapshots`: Jest snapshot update where declared; - `yarn format`: formats the package; `yarn format --check` only checks. -Packages differ. Check the scripts before running them; for example, `apps/storybook` has no `build` or `test` script. -Run a subset of a package's tests as described in [Focused test runs](#focused-test-runs). +Packages differ. Check the scripts before running them; for example, `apps/storybook` has no `build` script, and its +`test` script validates the input-coverage gate, not native callbacks. Run a subset of a package's tests as described +in [Focused test runs](#focused-test-runs). ## Focused test runs From 54f191e630ce001c1b462e2ba837f974b0924031 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Thu, 5 Nov 2026 16:55:04 -0800 Subject: [PATCH 3/8] Fix native input report provenance and isolate Win32 preview generations Consume canonical test reports, persist endpoint and renderer, and remount the complete Win32 preview so prior scroll position cannot obstruct physical input. Preserve chrome preferences and add focused regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .changeset/bright-input-lanes.md | 3 +- apps/storybook/README.md | 3 +- apps/storybook/input-test-modes.mts | 4 +- apps/storybook/input-test-modes.test.mts | 4 +- .../storybook-desktop-runtime/README.md | 3 + .../references/runtime-contract.md | 5 ++ .../src/StorybookUI.test.tsx | 62 +++++++++++++++++++ .../src/StorybookUI.win32.tsx | 10 ++- .../references/integration.md | 4 ++ .../src/cli/createDesktopStorybookCommand.ts | 5 ++ .../src/cli/diagnostics.test.ts | 46 +++++++++++++- 11 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx diff --git a/.changeset/bright-input-lanes.md b/.changeset/bright-input-lanes.md index 7811c19ff6..8925b3e62a 100644 --- a/.changeset/bright-input-lanes.md +++ b/.changeset/bright-input-lanes.md @@ -1,5 +1,6 @@ --- '@fluentui-react-native/storybook-desktop': patch +'@fluentui-react-native/storybook-desktop-runtime': patch --- -Support exact story/callback selection for separate accessibility and physical-input coverage, consistently in discovery and smoke execution. +Support exact story/callback selection for separate accessibility and physical-input coverage, consistently in discovery and smoke execution. Record native target provenance and reset the Win32 preview between test generations without resetting chrome preferences. diff --git a/apps/storybook/README.md b/apps/storybook/README.md index a2581fabf3..e606ae6e25 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -62,7 +62,8 @@ Before smoke, `yarn input-coverage begin` checks the complete statically discovered Win32 callback catalog against the declarations and captures the source, lane, and preexisting report directories. New, removed, or renamed callbacks require a reviewed catalog update. After smoke, -`yarn input-coverage verify` requires one fresh successful canonical report and +`yarn input-coverage verify` requires one fresh successful canonical test report, +with Win32/Paper provenance, and one clean passing attempt for every callback assigned to that lane. Unexpected skips, unsupported results, missing callbacks, duplicates, retries, and flaky passes fail the job. The existing Button #4373 quarantine remains selected; diff --git a/apps/storybook/input-test-modes.mts b/apps/storybook/input-test-modes.mts index 86e8d5c734..ef76af6b2f 100644 --- a/apps/storybook/input-test-modes.mts +++ b/apps/storybook/input-test-modes.mts @@ -108,7 +108,7 @@ export function verifyInputReport(value: unknown, selected: InputTest[], started const counts = object(report.counts); if ( report.schemaVersion !== 1 || - report.kind !== 'lifecycle' || + report.kind !== 'tests' || report.status !== 'passed' || context.endpoint !== 'win32' || context.renderer !== 'paper' || @@ -117,7 +117,7 @@ export function verifyInputReport(value: unknown, selected: InputTest[], started !Number.isFinite(Date.parse(text(report.finishedAt))) || Date.parse(text(report.finishedAt)) < Date.parse(text(report.startedAt)) ) { - throw new Error('Expected a fresh, successful Win32/Paper lifecycle report.'); + throw new Error('Expected a fresh, successful Win32/Paper test report.'); } if (['failed', 'unsupported', 'incomplete', 'retries', 'flaky'].some((key) => counts[key] !== 0) || array(report.failures).length) { throw new Error('Input coverage cannot accept failed, unsupported, unfinished, retried, or flaky execution.'); diff --git a/apps/storybook/input-test-modes.test.mts b/apps/storybook/input-test-modes.test.mts index 26eda2b513..2d988d239c 100644 --- a/apps/storybook/input-test-modes.test.mts +++ b/apps/storybook/input-test-modes.test.mts @@ -28,7 +28,7 @@ function report(mode: 'accessibility' | 'physical') { const skipped = tests.filter((entry) => entry.status === 'skipped').length; return { schemaVersion: 1, - kind: 'lifecycle', + kind: 'tests', status: 'passed', context: { endpoint: 'win32', renderer: 'paper' }, startedAt: '2026-11-05T20:00:00.000Z', @@ -120,6 +120,8 @@ test('rejects changed quarantine reasons, stale/wrong-target reports, retry pass const wrongTarget = report('physical'); wrongTarget.context.endpoint = 'windows'; assert.throws(() => verifyInputReport(wrongTarget, selected, 0), /Win32\/Paper/); + assert.throws(() => verifyInputReport({ ...report('physical'), kind: 'lifecycle' }, selected, 0), /test report/); + assert.throws(() => verifyInputReport({ ...report('physical'), context: undefined }, selected, 0), /input-coverage object/); const retried = report('physical'); retried.tests[0].attempts.push(retried.tests[0].attempts[0]); assert.throws(() => verifyInputReport(retried, selected, 0), /one clean attempt/); diff --git a/packages/agentic/storybook-desktop-runtime/README.md b/packages/agentic/storybook-desktop-runtime/README.md index e9996cbaa2..144a24a189 100644 --- a/packages/agentic/storybook-desktop-runtime/README.md +++ b/packages/agentic/storybook-desktop-runtime/README.md @@ -9,6 +9,9 @@ Consumers normally install and invoke `storybook-desktop`; this package is separate so the CLI package stays peer-free and receives the runtime workspace locator. +Win32 test generations reset the preview subtree and its scroll position while +preserving the surrounding desktop chrome preferences. + Read the [integration reference](references/integration.md) before wiring the runtime into an app, and the [runtime contract](references/runtime-contract.md) before changing bridge, Metro, theme, or chrome behavior. The diff --git a/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md b/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md index 3987305bb3..7ee5cbb6a0 100644 --- a/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md +++ b/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md @@ -15,6 +15,11 @@ ready/error events correlate instance, target, request/run IDs, and preview generation. Each named test gets a keyed preview remount. Preserve these contracts and fail closed on stale or uncorrelated messages. +Win32 remounts the entire preview subtree for each test generation, including +the story viewport, so retained scroll position cannot put the next test's +controls behind the chrome. Sidebar and addon preferences remain outside that +remount. + ## Theme and chrome One Flex `ThemedRoot` covers the app, toolbar, and chrome, so the Storybook diff --git a/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx new file mode 100644 index 0000000000..6a4d7552ef --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { Pressable, Text } from 'react-native'; +import { fireEvent, render } from '@testing-library/react-native'; +import { theme } from '@storybook/react-native-theming'; + +import { DesktopStorybookConfigProvider, useDesktopStorybookConfig } from './DesktopStorybookConfig'; +import { StorybookUIComponent } from './StorybookUI.win32'; + +jest.mock('@storybook/react-native-ui-common', () => ({ + StorageProvider: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock('@storybook/react-native-ui-lite', () => ({ Sidebar: () => null })); +jest.mock('./Win32AddonsPanel', () => ({ Win32AddonsPanel: () => null })); +jest.mock('./Win32CalloutPortal', () => ({ Win32CalloutPortal: () => null })); +jest.mock('./Win32ResizeHandle', () => ({ Win32ResizeHandle: () => null })); + +const storage = { getItem: () => null, setItem: () => undefined }; +const setStory = jest.fn(); + +function Canvas() { + const [offset, setOffset] = React.useState(0); + return ( + <> + {offset} + setOffset(99)} /> + + ); +} + +function Harness({ requestId }: { requestId: string }) { + const { prepareStory } = useDesktopStorybookConfig(); + React.useEffect(() => { + prepareStory({ requestId, runId: 'fixture-run', storyId: 'fixture--default' }); + }, [prepareStory, requestId]); + return ( + + + + ); +} + +function scene(requestId: string) { + return ( + + + + ); +} + +test('resets the whole Win32 preview for a new test generation without resetting chrome preferences', async () => { + const screen = await render(scene('first')); + await fireEvent.press(screen.getByTestId('scroll-canvas')); + expect(screen.getByTestId('canvas-offset')).toHaveTextContent('99'); + await fireEvent.press(screen.getByTestId('test-win32-hide-sidebar')); + expect(screen.getByTestId('test-win32-show-sidebar')).toBeOnTheScreen(); + expect(screen.getByTestId('canvas-offset')).toHaveTextContent('99'); + + await screen.rerender(scene('second')); + + expect(screen.getByTestId('canvas-offset')).toHaveTextContent('0'); + expect(screen.getByTestId('test-win32-show-sidebar')).toBeOnTheScreen(); +}); diff --git a/packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx index 072c72495e..2f2789cfc9 100644 --- a/packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx @@ -6,7 +6,7 @@ import { StorageProvider } from '@storybook/react-native-ui-common'; import type { SBUI, Selection } from '@storybook/react-native-ui-common'; import { Sidebar } from '@storybook/react-native-ui-lite'; -import { useDesktopStorybookTestID } from './DesktopStorybookConfig'; +import { useDesktopStorybookConfig, useDesktopStorybookTestID } from './DesktopStorybookConfig'; import { Win32AddonsPanel } from './Win32AddonsPanel'; import { Win32CalloutPortal } from './Win32CalloutPortal'; import { Win32ResizeHandle } from './Win32ResizeHandle'; @@ -15,6 +15,8 @@ const emptyRefs = {}; const emptyStatus = {}; export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, storyHash, storyBackgroundColor, theme }) => { + const { selection } = useDesktopStorybookConfig(); + const previewKey = selection ? `${story?.id ?? ''}:${selection.runId}:${selection.previewGeneration}` : story?.id; const storiesAnchorRef = React.useRef(null); const addonsAnchorRef = React.useRef(null); const [sidebarVisible, setSidebarVisible] = React.useState(true); @@ -174,7 +176,11 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, ) : null} - + {children} {addonsVisible ? ( diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index fe3280dfde..5e1f9acfb4 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -66,6 +66,10 @@ catalog completeness and actual execution; declaring a test does not prove that it ran. The Win32 Storybook app demonstrates this with separate CI lanes, shared callbacks in both, and explicit reporting of its existing quarantine. +CLI reports record the selected endpoint and configured renderer. Smoke and +callback runs use canonical `run.json` documents with `kind: 'tests'`; command +lifecycles use `kind: 'lifecycle'`. + ## Responsibilities This package owns Storybook-specific CLI lifecycle, static story manifests, diff --git a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts index 263ab8e307..3f34913b3b 100644 --- a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts +++ b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts @@ -269,10 +269,15 @@ export async function runDesktopStorybookCli(argv: readonly string[] = process.a configured: options.config.desktop?.reporting?.events, }); options.reporterMode = reporterMode; + const platformFlags = action.opts(); + const endpoint = selectedPlatform(platformFlags) ?? options.config.platform; + const renderer = + endpoint === 'macos' && platformFlags.paper ? 'paper' : endpoint && options.config.getDesktopTarget?.(endpoint)?.host.renderer; session = await createDesktopReportSession({ ...options.config.desktop?.reporting, artifactsRoot: options.config.desktop?.reporting.artifactsRoot ?? path.join(options.config.projectRoot, 'artifacts', 'desktop'), kind: ['test', 'smoke'].includes(action.name()) ? 'tests' : 'lifecycle', + context: { endpoint, renderer }, reporterMode, eventLevel, // A long-lived command's readiness record is its only output; before readiness, a failure reports its summary. diff --git a/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts b/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts index 5f35f69fc5..607116f32f 100644 --- a/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -7,6 +7,50 @@ import { formatDesktopStorybookError, writeDesktopStorybookFailure } from '../.. import { normalizeLegacyTestReporter } from './createDesktopStorybookCommand'; describe('pipeline failure diagnostics', () => { + test('persists the selected endpoint and renderer in the canonical CLI report', () => { + const root = fs.mkdtempSync(path.join(path.resolve(__dirname, '../..'), '.cli-report-context-')); + const configPath = path.join(root, 'desktop.config.mts'); + fs.writeFileSync( + configPath, + ` + import { defineDesktopConfig } from '@fluentui-react-native/desktop-test/config'; + import { storybook } from '@fluentui-react-native/storybook-desktop/config'; + export default defineDesktopConfig({ + schemaVersion: 1, + projectRoot: new URL('.', import.meta.url), + targets: { win32: { host: { kind: 'rex', renderer: 'paper', component: 'Fixture', windowTitle: 'Fixture' } } }, + suites: { stories: { kind: 'csf-wdio' } }, + integration: storybook({ + storyPackages: [], + platformOptions: { win32: { bundle: { command: process.execPath, args: ['-e', ''] } } }, + }), + }); + `, + ); + try { + const response = spawnSync( + process.execPath, + [path.resolve(__dirname, '../../config/cli.cjs'), '--config', configPath, 'bundle', '--win32'], + { + cwd: root, + encoding: 'utf8', + timeout: 20_000, + }, + ); + if (response.status !== 0) throw new Error(response.stderr || response.stdout || String(response.error)); + const artifacts = path.join(root, 'artifacts', 'desktop'); + const runs = fs.readdirSync(artifacts); + expect(runs).toHaveLength(1); + expect(JSON.parse(fs.readFileSync(path.join(artifacts, runs[0], 'run.json'), 'utf8'))).toMatchObject({ + kind: 'lifecycle', + status: 'passed', + context: { endpoint: 'win32', renderer: 'paper' }, + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + test.each(['spec', 'tap', 'dot'])('normalizes the legacy test reporter alias: %s', (reporter) => { expect(normalizeLegacyTestReporter(['node', 'storybook', 'test', '--reporter', reporter])).toEqual({ argv: ['node', 'storybook', 'test', '--test-reporter', reporter], From ae925172319cbe5fa7a7e829623edd26ab52a8f4 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Thu, 5 Nov 2026 17:00:31 -0800 Subject: [PATCH 4/8] Preserve renderer type safety when no platform is selected Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .../src/cli/createDesktopStorybookCommand.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts index 3f34913b3b..41e521553b 100644 --- a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts +++ b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts @@ -272,7 +272,11 @@ export async function runDesktopStorybookCli(argv: readonly string[] = process.a const platformFlags = action.opts(); const endpoint = selectedPlatform(platformFlags) ?? options.config.platform; const renderer = - endpoint === 'macos' && platformFlags.paper ? 'paper' : endpoint && options.config.getDesktopTarget?.(endpoint)?.host.renderer; + endpoint === 'macos' && platformFlags.paper + ? 'paper' + : endpoint + ? options.config.getDesktopTarget?.(endpoint)?.host.renderer + : undefined; session = await createDesktopReportSession({ ...options.config.desktop?.reporting, artifactsRoot: options.config.desktop?.reporting.artifactsRoot ?? path.join(options.config.projectRoot, 'artifacts', 'desktop'), From 517070ee4c62c49768595193c021d22c713cf2df Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Thu, 5 Nov 2026 17:17:39 -0800 Subject: [PATCH 5/8] Fix Win32 story overflow and CLI provenance fixture Give the Win32 decorator a naturally sized scrollable canvas so oversized stories remain physically reachable. Preserve other platform previews and native readiness markers. Supply the required app manifest in the spawned-CLI report regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .../storybook-desktop-runtime/README.md | 2 + .../references/runtime-contract.md | 4 ++ .../src/StorybookUI.test.tsx | 26 ++++++++++-- .../createDesktopStorybookPreview.win32.tsx | 42 +++++++++++++++++++ .../src/cli/diagnostics.test.ts | 1 + 5 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookPreview.win32.tsx diff --git a/packages/agentic/storybook-desktop-runtime/README.md b/packages/agentic/storybook-desktop-runtime/README.md index 144a24a189..454629bf3d 100644 --- a/packages/agentic/storybook-desktop-runtime/README.md +++ b/packages/agentic/storybook-desktop-runtime/README.md @@ -11,6 +11,8 @@ locator. Win32 test generations reset the preview subtree and its scroll position while preserving the surrounding desktop chrome preferences. +Its scrollable story viewport centers small stories without clipping the start +of oversized stories behind the chrome. Read the [integration reference](references/integration.md) before wiring the runtime into an app, and the [runtime contract](references/runtime-contract.md) diff --git a/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md b/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md index 7ee5cbb6a0..2161c4db45 100644 --- a/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md +++ b/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md @@ -20,6 +20,10 @@ the story viewport, so retained scroll position cannot put the next test's controls behind the chrome. Sidebar and addon preferences remain outside that remount. +The Win32 story decorator uses a scrollable viewport with naturally sized +content. Small stories remain centered; oversized stories start within the +viewport and can scroll instead of overflowing behind the chrome. + ## Theme and chrome One Flex `ThemedRoot` covers the app, toolbar, and chrome, so the Storybook diff --git a/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx index 6a4d7552ef..ba93bc0eea 100644 --- a/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { Pressable, Text } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; import { theme } from '@storybook/react-native-theming'; import { DesktopStorybookConfigProvider, useDesktopStorybookConfig } from './DesktopStorybookConfig'; +import { Win32StoryViewport } from './createDesktopStorybookPreview.win32'; import { StorybookUIComponent } from './StorybookUI.win32'; jest.mock('@storybook/react-native-ui-common', () => ({ @@ -13,6 +14,7 @@ jest.mock('@storybook/react-native-ui-lite', () => ({ Sidebar: () => null })); jest.mock('./Win32AddonsPanel', () => ({ Win32AddonsPanel: () => null })); jest.mock('./Win32CalloutPortal', () => ({ Win32CalloutPortal: () => null })); jest.mock('./Win32ResizeHandle', () => ({ Win32ResizeHandle: () => null })); +jest.mock('./DesktopStoryRoot', () => ({ DesktopStoryRoot: ({ children }: { children: React.ReactNode }) => children })); const storage = { getItem: () => null, setItem: () => undefined }; const setStory = jest.fn(); @@ -20,10 +22,10 @@ const setStory = jest.fn(); function Canvas() { const [offset, setOffset] = React.useState(0); return ( - <> + {offset} setOffset(99)} /> - + ); } @@ -60,3 +62,21 @@ test('resets the whole Win32 preview for a new test generation without resetting expect(screen.getByTestId('canvas-offset')).toHaveTextContent('0'); expect(screen.getByTestId('test-win32-show-sidebar')).toBeOnTheScreen(); }); + +test('allows oversized Win32 stories to grow inside the scrollable preview rather than overflow a fixed-height centered view', async () => { + const screen = await render( + + + + Last control + + + , + ); + const viewport = screen.getByTestId('test-story-viewport'); + const contentStyle = StyleSheet.flatten(viewport.props.contentContainerStyle); + expect(contentStyle).toMatchObject({ flexGrow: 1, justifyContent: 'center' }); + expect(contentStyle.flex).toBeUndefined(); + expect(viewport.props.keyboardShouldPersistTaps).toBe('handled'); + expect(screen.getByTestId('last-control')).toBeOnTheScreen(); +}); diff --git a/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookPreview.win32.tsx b/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookPreview.win32.tsx new file mode 100644 index 0000000000..63da2281e2 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookPreview.win32.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react'; +import { ScrollView, StyleSheet } from 'react-native'; +import type { Preview } from '@storybook/react-native'; + +import { DesktopStoryRoot } from './DesktopStoryRoot'; +import { useDesktopStorybookTestID } from './DesktopStorybookConfig'; + +export function Win32StoryViewport({ children }: { children: ReactNode }) { + const testID = useDesktopStorybookTestID('story-viewport'); + return ( + + {children} + + ); +} + +export function createDesktopStorybookPreview(): Preview { + return { + decorators: [ + (Story, context) => ( + + + + + + ), + ], + parameters: {}, + }; +} + +const styles = StyleSheet.create({ + story: { + alignItems: 'center', + flexGrow: 1, + justifyContent: 'center', + padding: 16, + }, + viewport: { + flex: 1, + }, +}); diff --git a/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts b/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts index 607116f32f..af549c5a1a 100644 --- a/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/diagnostics.test.ts @@ -28,6 +28,7 @@ describe('pipeline failure diagnostics', () => { `, ); try { + fs.writeFileSync(path.join(root, 'app.json'), JSON.stringify({ name: 'Fixture' })); const response = spawnSync( process.execPath, [path.resolve(__dirname, '../../config/cli.cjs'), '--config', configPath, 'bundle', '--win32'], From bc7f24574e940fb2af964eaef0131e1b0425b655 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Thu, 5 Nov 2026 17:30:18 -0800 Subject: [PATCH 6/8] Allow bounded cold initialization in Win32 chrome regression The real ScrollView initialization exceeded the five-second default on cold local and hosted workers, while warm and isolated runs passed. Give only this JavaScript regression a bounded fifteen-second budget; retain every assertion and all native timeouts. The complete uncached runtime suite passes 21/21. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .../agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx index ba93bc0eea..16bd0caa85 100644 --- a/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx @@ -49,6 +49,7 @@ function scene(requestId: string) { ); } +// Cold CI workers can take more than five seconds to initialize the real ScrollView. test('resets the whole Win32 preview for a new test generation without resetting chrome preferences', async () => { const screen = await render(scene('first')); await fireEvent.press(screen.getByTestId('scroll-canvas')); @@ -61,7 +62,7 @@ test('resets the whole Win32 preview for a new test generation without resetting expect(screen.getByTestId('canvas-offset')).toHaveTextContent('0'); expect(screen.getByTestId('test-win32-show-sidebar')).toBeOnTheScreen(); -}); +}, 15_000); test('allows oversized Win32 stories to grow inside the scrollable preview rather than overflow a fixed-height centered view', async () => { const screen = await render( From da4f49656cbd8cb7344d1b331957203b2479f028 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Thu, 5 Nov 2026 18:52:34 -0800 Subject: [PATCH 7/8] Colocate Storybook input classifications and generate the Win32 catalog Keep endpoint-specific input metadata beside each named callback, discover lane selections statically, and retain only the exact app-owned quarantine. Preserve all 90 compiled callback bodies and existing platform policies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .changeset/bright-input-lanes.md | 5 +- apps/storybook/README.md | 23 +- apps/storybook/agent-map.yaml | 10 +- apps/storybook/input-test-modes.mts | 161 ++-- apps/storybook/input-test-modes.test.mts | 78 +- .../input-test-quarantines.win32.json | 11 + apps/storybook/input-tests.win32.json | 262 ------- .../references/tests-and-stories.md | 9 + .../src/components/avatar/avatar.stories.tsx | 48 +- .../src/components/button/button.stories.tsx | 737 +++++++++--------- .../src/components/card/card.stories.tsx | 154 ++-- .../components/checkbox/checkbox.stories.tsx | 90 ++- .../components/dropdown/dropdown.stories.tsx | 338 ++++---- .../src/components/input/input.stories.tsx | 134 ++-- .../listbox-item/listbox-item.stories.tsx | 163 ++-- .../menu-button/menu-button.stories.tsx | 156 ++-- .../menu-item/menu-item.stories.tsx | 184 ++--- .../src/components/menu/menu.stories.tsx | 414 +++++----- .../components/popover/popover.stories.tsx | 163 ++-- .../src/components/radio/radio.stories.tsx | 93 +-- .../components/skeleton/skeleton.stories.tsx | 55 +- .../components/spinner/spinner.stories.tsx | 47 +- .../src/components/switch/switch.stories.tsx | 62 +- .../components/tablist/tablist.stories.tsx | 132 ++-- .../src/components/tag/tag.stories.tsx | 22 +- .../focus-zone/focus-zone.stories.tsx | 127 +-- .../layout-stable-text.stories.tsx | 69 +- .../references/test-integration.md | 6 + packages/agentic/desktop-driver/src/index.ts | 2 + .../agentic/desktop-driver/src/storybook.ts | 10 +- .../native-core/references/contributing.md | 7 + .../src/legacy/callout/Callout.stories.tsx | 68 +- packages/agentic/storybook-desktop/README.md | 12 +- .../config/transform-story-tests.cjs | 38 +- .../config/transform-story-tests.d.cts | 2 + .../references/integration.md | 16 +- .../references/story-tests.md | 27 + .../src/cli/DesktopStorybookCli.test.ts | 39 + .../src/cli/DesktopStorybookCli.ts | 18 +- .../src/config/transformStoryTests.test.ts | 12 + .../storybook-desktop/src/config/wdio.test.ts | 6 + .../storybook-desktop/src/config/wdio.ts | 4 + .../src/driver/storyManifest.test.ts | 12 + .../src/driver/storyManifest.ts | 12 +- .../src/testing/extractWdioTests.test.ts | 31 +- .../src/testing/extractWdioTests.ts | 6 +- .../storybook-desktop/src/testing/index.ts | 2 +- .../src/testing/runWdioTests.ts | 31 +- .../storybook-desktop/src/testing/types.ts | 8 +- .../src/testing/wdio.types.test.ts | 20 + 50 files changed, 2227 insertions(+), 1909 deletions(-) create mode 100644 apps/storybook/input-test-quarantines.win32.json delete mode 100644 apps/storybook/input-tests.win32.json diff --git a/.changeset/bright-input-lanes.md b/.changeset/bright-input-lanes.md index 8925b3e62a..f97821d5c3 100644 --- a/.changeset/bright-input-lanes.md +++ b/.changeset/bright-input-lanes.md @@ -1,6 +1,9 @@ --- +'@fluentui-react-native/components': patch +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/native-core': patch '@fluentui-react-native/storybook-desktop': patch '@fluentui-react-native/storybook-desktop-runtime': patch --- -Support exact story/callback selection for separate accessibility and physical-input coverage, consistently in discovery and smoke execution. Record native target provenance and reset the Win32 preview between test generations without resetting chrome preferences. +Support statically extracted, endpoint-specific input classifications beside named story tests and select accessibility or physical coverage consistently in discovery and smoke execution. Classify existing Win32 callbacks without changing their assertions. Record native target provenance and reset the Win32 preview between test generations without resetting chrome preferences. diff --git a/apps/storybook/README.md b/apps/storybook/README.md index e606ae6e25..2453402823 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -45,12 +45,15 @@ Change the owning config or source instead of generated output. ## Win32 input coverage Win32/Paper PR testing has independent accessibility and physical-input jobs. -Both traverse the full platform story catalog. The app-owned -[`input-tests.win32.json`](input-tests.win32.json) declares each authored callback -as `accessibility`, `physical`, or `both`; `both` callbacks execute in each job. -Declarations use exact story ID and callback-name pairs, not name-glob -cross-products. A semantic assertion that first navigates with the keyboard -still belongs to the physical lane. +Both traverse the full platform story catalog. Each named test declares +`inputModes: { win32: 'accessibility' | 'physical' | 'both' }` beside its +`run` callback; `both` tests execute in each job. A semantic assertion that first +navigates with the keyboard still belongs to the physical lane. + +`yarn input-coverage generate` discovers these declarations and writes +`artifacts/win32/input-tests.win32.json`. This is generated output, not a second +test inventory to maintain. The app owns only the exact exception registry, +[`input-test-quarantines.win32.json`](input-test-quarantines.win32.json). The accessibility lane masks keyboard, physical clicks, and wheel input and explicitly uses accessibility clicks. The physical lane enables those features @@ -58,10 +61,10 @@ and explicitly uses physical clicks. Windows/Fabric and macOS policies are unchanged. Source assertions and capability checks remain authoritative; this catalog does not convert unsupported actions into passes. -Before smoke, `yarn input-coverage begin` checks the complete statically -discovered Win32 callback catalog against the declarations and captures the -source, lane, and preexisting report directories. New, removed, or renamed -callbacks require a reviewed catalog update. After smoke, +Before smoke, `yarn input-coverage begin` generates the complete statically +discovered Win32 callback catalog and captures the source, lane, and preexisting +report directories. Missing or invalid classifications fail; additions, removals, +and renames come directly from story source. After smoke, `yarn input-coverage verify` requires one fresh successful canonical test report, with Win32/Paper provenance, and one clean passing attempt for every callback assigned to that lane. Unexpected diff --git a/apps/storybook/agent-map.yaml b/apps/storybook/agent-map.yaml index fd2429932d..a40c2db977 100644 --- a/apps/storybook/agent-map.yaml +++ b/apps/storybook/agent-map.yaml @@ -3,7 +3,7 @@ purpose: Agent-optimized map of stable Storybook structure and runtime discovery sources: config: desktop.config.mts - win32_input_catalog: input-tests.win32.json + win32_input_quarantines: input-test-quarantines.win32.json win32_input_coverage: input-test-modes.mts config_adapter: src/main.ts app_entry: index.js @@ -20,6 +20,11 @@ sources: - '@fluentui-react-native/components' - '@fluentui-react-native/native-core' +outputs: + win32_input_catalog: + path: artifacts/win32/input-tests.win32.json + policy: generated-from-story-input-metadata-do-not-edit + services: supervisor: command: yarn storybook driver -- @@ -33,7 +38,7 @@ services: executable_tests: command: yarn storybook test -- discovery: yarn storybook test -- --list - authoring: top-level wdio callbacks or named callback collections in CSF3 stories + authoring: top-level wdio callbacks or named callback collections in CSF3 stories; Win32 input modes beside each named run callback config: desktop.config.mts Storybook integration wdio settings lifecycle: attached-session-per-test-with-isolated-node-worker context_platform: macos-or-windows-or-win32-target-endpoint @@ -50,6 +55,7 @@ agent_commands: list_stories: yarn desktop-driver stories list --url --target run_story_tests: yarn storybook test -- --test capture_win32_input_catalog: yarn input-coverage begin + generate_win32_input_catalog: yarn input-coverage generate verify_win32_input_coverage: yarn input-coverage verify lookup_order: diff --git a/apps/storybook/input-test-modes.mts b/apps/storybook/input-test-modes.mts index ef76af6b2f..0b77ab1754 100644 --- a/apps/storybook/input-test-modes.mts +++ b/apps/storybook/input-test-modes.mts @@ -4,7 +4,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, write import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import rawCatalog from './input-tests.win32.json' with { type: 'json' }; +import quarantineCatalog from './input-test-quarantines.win32.json' with { type: 'json' }; export type InputMode = 'accessibility' | 'physical'; export type InputTest = { @@ -16,10 +16,13 @@ export type InputTest = { const appRoot = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(appRoot, '..', '..'); -const catalogPath = path.join(appRoot, 'input-tests.win32.json'); const artifactsRoot = path.join(appRoot, 'artifacts', 'desktop'); const coverageRoot = path.join(appRoot, 'artifacts', 'win32'); +if (quarantineCatalog.schemaVersion !== 1 || quarantineCatalog.endpoint !== 'win32') { + throw new Error('Unsupported Win32 quarantine catalog.'); +} + function object(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an input-coverage object.'); return value as Record; @@ -45,26 +48,33 @@ export function readInputMode(value = process.env.STORYBOOK_INPUT_MODE): InputMo return value; } -export function parseInputCatalog(value: unknown = rawCatalog): InputTest[] { - const catalog = object(value); - if (catalog.schemaVersion !== 1 || catalog.endpoint !== 'win32') throw new Error('Unsupported Win32 input-test catalog.'); - const tests = array(catalog.stories).flatMap((entry) => { +export function parseInputCatalog(value: unknown, quarantineDeclarations: unknown = quarantineCatalog.quarantines): InputTest[] { + const storyIds = new Set(); + const tests = array(value).flatMap((entry) => { const story = object(entry); - const storyId = text(story.storyId); - if (Object.keys(story).some((key) => !['storyId', 'accessibility', 'physical', 'both'].includes(key))) { - throw new Error(`Unknown input-mode declaration for ${storyId}.`); + const storyId = text(story.id); + if (storyIds.has(storyId)) throw new Error(`Duplicate discovered story: ${storyId}.`); + storyIds.add(storyId); + const modes = story.inputModes === undefined ? {} : object(story.inputModes); + const names = array(story.tests ?? ['default']).map(text); + if (!names.length) throw new Error(`No input tests discovered for ${storyId}.`); + if (Object.keys(modes).some((name) => !names.includes(name))) { + throw new Error(`Classified callback was not discovered for ${storyId}.`); } - const result = (['accessibility', 'physical', 'both'] as const).flatMap((mode) => - story[mode] === undefined ? [] : array(story[mode]).map((name): InputTest => ({ storyId, testName: text(name), mode })), - ); - if (!result.length) throw new Error(`No input tests declared for ${storyId}.`); - return result; + return names.map((testName): InputTest => { + const declaration = modes[testName]; + const mode = declaration === undefined ? undefined : object(declaration).win32; + if (mode !== 'accessibility' && mode !== 'physical' && mode !== 'both') { + throw new Error(`Unclassified or invalid Win32 input callback: ${storyId} / ${testName}.`); + } + return { storyId, testName, mode }; + }); }); if (!tests.length || new Set(tests.map(identity)).size !== tests.length) { throw new Error('Input-test catalog must have unique callback identities.'); } const quarantines = new Set(); - for (const entry of array(catalog.quarantines)) { + for (const entry of array(quarantineDeclarations)) { const quarantine = object(entry); const key = identity({ storyId: text(quarantine.storyId), testName: text(quarantine.testName) }); const test = tests.find((candidate) => identity(candidate) === key); @@ -75,31 +85,16 @@ export function parseInputCatalog(value: unknown = rawCatalog): InputTest[] { return tests; } -export function selectedInputTests(mode: InputMode, catalog = parseInputCatalog()): InputTest[] { +export function selectedInputTests(mode: InputMode, catalog: readonly InputTest[]): InputTest[] { return catalog.filter((test) => test.mode === mode || test.mode === 'both'); } -export function assertCatalogMatches(discovery: unknown, catalog = parseInputCatalog()): void { - const actual = array(discovery).flatMap((entry) => { - const story = object(entry); - const storyId = text(story.id); - return array(story.tests ?? ['default']).map((name) => identity({ storyId, testName: text(name) })); - }); - const declared = new Set(catalog.map(identity)); - const actualSet = new Set(actual); - const missing = actual.filter((key) => !declared.has(key)); - const stale = [...declared].filter((key) => !actualSet.has(key)); - if (actualSet.size !== actual.length || missing.length || stale.length) { - throw new Error(`Win32 input catalog drift. Unclassified: ${missing.join('; ')}. Stale declarations: ${stale.join('; ')}.`); - } -} - export function inputModeOptions(): { clickMode?: InputMode; - testCases?: readonly { storyId: string; testName: string }[]; + inputMode?: InputMode; } { const mode = readInputMode(); - return mode ? { clickMode: mode, testCases: selectedInputTests(mode).map(({ storyId, testName }) => ({ storyId, testName })) } : {}; + return mode ? { clickMode: mode, inputMode: mode } : {}; } export function verifyInputReport(value: unknown, selected: InputTest[], startedAt: number): { passed: number; quarantined: string[] } { @@ -166,48 +161,96 @@ function runDirectories(): string[] { : []; } -function catalogDigest(): string { - return createHash('sha256').update(readFileSync(catalogPath)).digest('hex'); +function catalogDigest(catalog: readonly InputTest[]): string { + return createHash('sha256').update(JSON.stringify(catalog)).digest('hex'); } function sourceCommit(): string { return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); } +function discoverInputTests(): unknown { + const env: NodeJS.ProcessEnv = { ...process.env, STORYBOOK_INPUT_MODE: '' }; + delete env.STORYBOOK_SMOKE_TEST; + delete env.STORYBOOK_SMOKE_STORY; + delete env.STORYBOOK_SMOKE_TAG; + return JSON.parse( + execFileSync( + process.execPath, + [ + path.join(root, '.yarn', 'releases', 'yarn-4.18.0.cjs'), + 'workspace', + '@fluentui-react-native/agentic-components-storybook', + 'storybook', + 'test', + '--win32', + '--list', + ], + { + cwd: root, + encoding: 'utf8', + timeout: 120_000, + env, + stdio: ['ignore', 'pipe', 'inherit'], + }, + ), + ); +} + +function writeGeneratedCatalog(catalog: readonly InputTest[]): void { + const stories = new Map(); + for (const test of catalog) { + let story = stories.get(test.storyId); + if (!story) { + story = { storyId: test.storyId, accessibility: [], physical: [], both: [] }; + stories.set(test.storyId, story); + } + story[test.mode].push(test.testName); + } + mkdirSync(coverageRoot, { recursive: true }); + writeFileSync( + path.join(coverageRoot, 'input-tests.win32.json'), + `${JSON.stringify( + { + schemaVersion: 1, + endpoint: 'win32', + stories: [...stories.values()], + quarantines: quarantineCatalog.quarantines, + }, + null, + 2, + )}\n`, + ); +} + function main(): void { + const action = process.argv[2]; + if (!['generate', 'begin', 'verify'].includes(action ?? '')) throw new Error('Use input-coverage generate, begin, or verify.'); const mode = readInputMode(); - if (!mode) throw new Error('Set STORYBOOK_INPUT_MODE before capturing or verifying input coverage.'); + if (action !== 'generate' && !mode) throw new Error('Set STORYBOOK_INPUT_MODE before capturing or verifying input coverage.'); + const discovery = discoverInputTests(); + const catalog = parseInputCatalog(discovery); + if (action === 'generate') { + writeGeneratedCatalog(catalog); + console.log(`Generated ${catalog.length} classified Win32 callbacks from story source.`); + return; + } + if (!mode) throw new Error('Input coverage requires an explicit lane.'); const snapshotPath = path.join(coverageRoot, `input-coverage-${mode}.json`); - if (process.argv[2] === 'begin') { - const discovery = JSON.parse( - execFileSync( - process.execPath, - [ - path.join(root, '.yarn', 'releases', 'yarn-4.18.0.cjs'), - 'workspace', - '@fluentui-react-native/agentic-components-storybook', - 'storybook', - 'test', - '--win32', - '--list', - ], - { cwd: root, encoding: 'utf8', env: { ...process.env, STORYBOOK_INPUT_MODE: '' }, stdio: ['ignore', 'pipe', 'inherit'] }, - ), - ); - const catalog = parseInputCatalog(); - assertCatalogMatches(discovery, catalog); + if (action === 'begin') { + writeGeneratedCatalog(catalog); mkdirSync(coverageRoot, { recursive: true }); writeFileSync( snapshotPath, - `${JSON.stringify({ schemaVersion: 1, mode, sourceCommit: sourceCommit(), catalogDigest: catalogDigest(), startedAt: Date.now(), previousRuns: runDirectories(), catalog, discovery }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: 1, mode, sourceCommit: sourceCommit(), catalogDigest: catalogDigest(catalog), startedAt: Date.now(), previousRuns: runDirectories(), catalog, discovery }, null, 2)}\n`, ); console.log(`Declared ${selectedInputTests(mode, catalog).length} ${mode} callbacks; both-mode callbacks execute in each lane.`); - } else if (process.argv[2] === 'verify') { + } else if (action === 'verify') { const snapshot = object(JSON.parse(readFileSync(snapshotPath, 'utf8'))); if ( snapshot.mode !== mode || snapshot.sourceCommit !== sourceCommit() || - snapshot.catalogDigest !== catalogDigest() || + snapshot.catalogDigest !== catalogDigest(catalog) || typeof snapshot.startedAt !== 'number' ) { throw new Error('Input coverage snapshot does not match the current source and lane.'); @@ -217,13 +260,11 @@ function main(): void { if (runs.length !== 1) throw new Error(`Expected one fresh input run; found ${runs.length}.`); const report = JSON.parse(readFileSync(path.join(artifactsRoot, runs[0], 'run.json'), 'utf8')); if (object(report).runId !== runs[0]) throw new Error('Input report identity does not match its run directory.'); - const result = verifyInputReport(report, selectedInputTests(mode), snapshot.startedAt); + const result = verifyInputReport(report, selectedInputTests(mode, catalog), snapshot.startedAt); const summary = `## Win32/Paper ${mode} input coverage\n\n${result.passed} authored callbacks executed and passed. ${result.quarantined.length} explicitly quarantined callbacks remain unvalidated.\n${result.quarantined.map((title) => `- ${title}: existing #4373 quarantine; not counted as passed.`).join('\n')}\n`; writeFileSync(path.join(coverageRoot, `input-coverage-${mode}.md`), summary); if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); console.log(summary); - } else { - throw new Error('Use input-coverage begin or input-coverage verify.'); } } diff --git a/apps/storybook/input-test-modes.test.mts b/apps/storybook/input-test-modes.test.mts index 2d988d239c..53b256d39a 100644 --- a/apps/storybook/input-test-modes.test.mts +++ b/apps/storybook/input-test-modes.test.mts @@ -1,20 +1,24 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { assertCatalogMatches, parseInputCatalog, readInputMode, selectedInputTests, verifyInputReport } from './input-test-modes.mts'; +import { parseInputCatalog, readInputMode, selectedInputTests, verifyInputReport } from './input-test-modes.mts'; -const catalog = { - schemaVersion: 1, - endpoint: 'win32', - stories: [ - { storyId: 'fixture--default', both: ['semantics'], accessibility: ['native action'], physical: ['keyboard'] }, - { storyId: 'fixture--second', physical: ['semantics', 'quarantined'] }, - ], - quarantines: [{ storyId: 'fixture--second', testName: 'quarantined', reason: 'Existing issue #4373.' }], -}; +const discovery: { id: string; tests: string[]; inputModes: Record }[] = [ + { + id: 'fixture--default', + tests: ['semantics', 'native action', 'keyboard'], + inputModes: { semantics: { win32: 'both' }, 'native action': { win32: 'accessibility' }, keyboard: { win32: 'physical' } }, + }, + { + id: 'fixture--second', + tests: ['semantics', 'quarantined'], + inputModes: { semantics: { win32: 'physical' }, quarantined: { win32: 'physical' } }, + }, +]; +const quarantines = [{ storyId: 'fixture--second', testName: 'quarantined', reason: 'Existing issue #4373.' }]; function report(mode: 'accessibility' | 'physical') { - const selected = selectedInputTests(mode, parseInputCatalog(catalog)); + const selected = selectedInputTests(mode, parseInputCatalog(discovery, quarantines)); const tests = selected.map((entry, index) => { const status = entry.quarantineReason ? 'skipped' : 'passed'; return { @@ -51,37 +55,49 @@ function report(mode: 'accessibility' | 'physical') { } test('routes exact identities into accessibility, physical, and both lanes', () => { - const parsed = parseInputCatalog(catalog); + const parsed = parseInputCatalog(discovery, quarantines); assert.deepEqual( selectedInputTests('accessibility', parsed).map(({ testName }) => testName), - ['native action', 'semantics'], + ['semantics', 'native action'], ); assert.deepEqual( selectedInputTests('physical', parsed).map(({ testName }) => testName), - ['keyboard', 'semantics', 'semantics', 'quarantined'], + ['semantics', 'keyboard', 'semantics', 'quarantined'], ); assert.throws(() => readInputMode('automatic'), /STORYBOOK_INPUT_MODE/); }); -test('rejects duplicate, unknown-mode, and unclassified or stale callback declarations', () => { - const duplicate = structuredClone(catalog); - duplicate.stories[0].physical.push('semantics'); - assert.throws(() => parseInputCatalog(duplicate), /unique callback/); - assert.throws(() => parseInputCatalog({ ...catalog, stories: [{ storyId: 'fixture', accidental: ['case'] }] }), /Unknown input-mode/); - const discovery = [ - { id: 'fixture--default', tests: ['semantics', 'native action', 'keyboard'] }, - { id: 'fixture--second', tests: ['semantics', 'quarantined'] }, - ]; - assert.doesNotThrow(() => assertCatalogMatches(discovery, parseInputCatalog(catalog))); - assert.throws( - () => assertCatalogMatches([...discovery, { id: 'new', tests: ['unclassified'] }], parseInputCatalog(catalog)), - /Unclassified/, +test('rejects duplicate, unknown-mode, missing and stale discovered classifications', () => { + const duplicate = structuredClone(discovery); + duplicate[0].tests.push('semantics'); + assert.throws(() => parseInputCatalog(duplicate, quarantines), /unique callback/); + assert.throws(() => parseInputCatalog([...discovery, discovery[0]], quarantines), /Duplicate discovered story/); + const invalid = structuredClone(discovery); + invalid[0].inputModes.semantics.win32 = 'automatic'; + assert.throws(() => parseInputCatalog(invalid, quarantines), /Unclassified or invalid/); + const missing = structuredClone(discovery); + delete missing[0].inputModes.semantics; + assert.throws(() => parseInputCatalog(missing, quarantines), /Unclassified/); + const stale = structuredClone(discovery); + stale[0].inputModes.removed = { win32: 'both' }; + assert.throws(() => parseInputCatalog(stale, quarantines), /not discovered/); +}); + +test('derives renamed and removed tests from discovery without a second inventory', () => { + const changed = structuredClone(discovery); + changed[0].tests[0] = 'renamed semantics'; + changed[0].inputModes['renamed semantics'] = changed[0].inputModes.semantics; + delete changed[0].inputModes.semantics; + changed[0].tests.splice(1, 1); + delete changed[0].inputModes['native action']; + assert.deepEqual( + selectedInputTests('accessibility', parseInputCatalog(changed, quarantines)).map(({ testName }) => testName), + ['renamed semantics'], ); - assert.throws(() => assertCatalogMatches(discovery.slice(0, 1), parseInputCatalog(catalog)), /Stale declarations/); }); test('accepts complete execution and reports the existing exact quarantine as unvalidated', () => { - const parsed = parseInputCatalog(catalog); + const parsed = parseInputCatalog(discovery, quarantines); assert.deepEqual(verifyInputReport(report('accessibility'), selectedInputTests('accessibility', parsed), 0), { passed: 2, quarantined: [], @@ -93,7 +109,7 @@ test('accepts complete execution and reports the existing exact quarantine as un }); test('rejects missing, duplicate, unexpected-lane, and skipped expected results', () => { - const selected = selectedInputTests('accessibility', parseInputCatalog(catalog)); + const selected = selectedInputTests('accessibility', parseInputCatalog(discovery, quarantines)); const missing = report('accessibility'); missing.tests.pop(); missing.counts.selected--; @@ -112,7 +128,7 @@ test('rejects missing, duplicate, unexpected-lane, and skipped expected results' }); test('rejects changed quarantine reasons, stale/wrong-target reports, retry passes, and unfinished work', () => { - const selected = selectedInputTests('physical', parseInputCatalog(catalog)); + const selected = selectedInputTests('physical', parseInputCatalog(discovery, quarantines)); const changed = report('physical'); changed.tests[3].attempts[0].reason = 'Physical input unavailable.'; assert.throws(() => verifyInputReport(changed, selected, 0), /did not execute and pass/); diff --git a/apps/storybook/input-test-quarantines.win32.json b/apps/storybook/input-test-quarantines.win32.json new file mode 100644 index 0000000000..5af7503dd0 --- /dev/null +++ b/apps/storybook/input-test-quarantines.win32.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "endpoint": "win32", + "quarantines": [ + { + "storyId": "components-button--focus-management", + "testName": "releases pressed feedback after an intervening key without a stale activation", + "reason": "Quarantined until #4373 is fixed: Win32 does not forward onPressOut after Space with an intervening key." + } + ] +} diff --git a/apps/storybook/input-tests.win32.json b/apps/storybook/input-tests.win32.json deleted file mode 100644 index 7d6859913c..0000000000 --- a/apps/storybook/input-tests.win32.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "schemaVersion": 1, - "endpoint": "win32", - "stories": [ - { - "storyId": "components-avatar--activity-ring", - "both": [ - "AVT-004 exposes the informative identity as a named native image", - "toggles the activity ring at every size without crashing or changing avatar bounds" - ] - }, - { - "storyId": "components-button--default", - "both": ["exposes enabled button semantics"], - "physical": ["supports native pointer activation"] - }, - { - "storyId": "components-button--externally-driven-selection", - "both": ["starts unselected", "updates and resets caller-owned selection"] - }, - { - "storyId": "components-button--focus-management", - "both": ["exposes the native disabled accessibility state"], - "physical": [ - "honors pointer focus policy and invokes exactly once per Enter and Space", - "updates same-target modality without refocusing and shares it with nested themes", - "skips disabled stops and does not activate the new owner on a stale key-up", - "suppresses repeats and activates at the native platform key phase", - "releases pressed feedback after an intervening key without a stale activation", - "releases repeated modifiers before the next Tab" - ] - }, - { - "storyId": "components-button--focus-requests", - "physical": ["confirms native requests without changing modality and rejects disabled or detached targets"] - }, - { - "storyId": "components-button--native-names", - "both": ["BTN-004 exposes plain, slot and numeric native names while preserving caller names"] - }, - { - "storyId": "components-button--native-toggle-selection", - "accessibility": ["BTN-002 BTN-005 Win32 native Toggle updates caller selection without reflow or duplicate presses"] - }, - { - "storyId": "components-button--native-toggle-semantics", - "both": [ - "BTN-004 BTN-005 exposes native button roles and enabled or disabled state", - "BTN-005 Windows native checked projection reflects caller selection including disabled" - ], - "accessibility": [ - "BTN-005 Win32 Toggle forwards once without a press or a controlled selection change", - "BTN-004 Win32 disabled public activation rejects without callbacks or a checked-state change" - ] - }, - { - "storyId": "components-card--native-structure", - "both": [ - "CRD-002 renders structured sections in reading order", - "CRD-005 keeps a nested action enabled inside a disabled selectable surface" - ] - }, - { - "storyId": "components-checkbox--constrained-text", - "both": ["CBX-003 wraps the label into a taller constrained row", "CBX-003 wraps secondary text into a taller constrained row"] - }, - { - "storyId": "components-checkbox--default", - "physical": ["toggles native checked state in both directions"] - }, - { - "storyId": "components-checkbox--hidden-label", - "both": ["CBX-004 preserves native explicit and fallback names without visible labels"] - }, - { - "storyId": "components-dropdown--appearance", - "both": ["DRP-011 exposes a nonempty selected native value without input"] - }, - { - "storyId": "components-dropdown--default", - "both": ["DRP-011 exposes a named, empty, collapsed native combobox before input"], - "physical": [ - "opens from the keyboard on the first option and commits with Enter", - "reopens on the selected option and navigates with Home and End", - "exposes a named list of selectable options", - "closes on Tab and moves focus past the trigger", - "closes on Shift+Tab and returns focus to the trigger", - "opens from the pointer on the first option and selects with a click" - ] - }, - { - "storyId": "components-dropdown--long-list", - "physical": ["enters on the controlled selection and reaches the end of a scrolling list"] - }, - { - "storyId": "components-dropdown--multiple", - "physical": ["toggles options and stays open until dismissed"] - }, - { - "storyId": "components-input--default", - "physical": ["accepts keyboard input and clears it"] - }, - { - "storyId": "components-input--focus-management", - "physical": [ - "keeps editing keys in the TextInput and skips disabled fields on Tab", - "takes native focus from a physical pointer click" - ] - }, - { - "storyId": "components-input--focus-treatment", - "physical": ["retains native editor focus and geometry through every focus-treatment variant"] - }, - { - "storyId": "components-listboxitem--native-selection", - "both": [ - "LBI-005 standalone unselected exposes an enabled native button", - "LBI-005 standalone selected exposes an enabled native button", - "LBI-005 standalone disabled selection exposes a disabled native button", - "LBI-005 Windows native checked projection reflects standalone selection including disabled" - ] - }, - { - "storyId": "components-listboxitem--native-toggle-actions", - "accessibility": [ - "LBI-005 Win32 native Toggle forwards once without a press or controlled selection change", - "LBI-003 LBI-005 Win32 disabled native activation has no callbacks or selection change" - ] - }, - { - "storyId": "components-menu--default", - "physical": [ - "opens from the keyboard on the first row and closes on activation", - "navigates with Home, End, and type-ahead", - "opens and closes two submenu levels with arrows and Escape", - "closes the whole menu on Tab from a submenu and returns to the trigger", - "exposes named menu surfaces and submenu expanded state", - "opens from the pointer without a focused row and enters on Down", - "opens submenus on hover and closes the family when a nested row runs" - ] - }, - { - "storyId": "components-menu--disabled-focusable", - "physical": ["keeps a disabled row focusable without running it"] - }, - { - "storyId": "components-menubutton--accessible-names", - "both": ["MNB-006 exposes slot, numeric and explicit native names without input"] - }, - { - "storyId": "components-menubutton--default", - "both": ["MNB-006 exposes a named, collapsed native button before input"], - "physical": [ - "opens on Down to the first row and on Up to the last row", - "closes on Tab and moves focus past the trigger", - "closes on Shift+Tab and returns focus to the trigger", - "toggles from the pointer without reopening" - ] - }, - { - "storyId": "components-menuitem--disabled-focusable", - "both": ["exposes disabled native menu-item semantics"], - "physical": ["navigates onto a disabled row without activating it", "updates native disabled state without replacing the command"] - }, - { - "storyId": "components-menuitem--native-indicator-semantics", - "both": ["MNI-003 Win32 exposes checked native indicator roles, names and availability"], - "accessibility": ["MNI-003 Win32 Toggle forwards once without changing selection or pressing and disabled public activation rejects"] - }, - { - "storyId": "components-popover--controlled", - "physical": ["returns focus to the trigger when content closes the popup"] - }, - { - "storyId": "components-popover--default", - "physical": [ - "opens below the trigger as a named group and enters its first content", - "keeps Tab inside the popup and returns focus to the trigger on Escape", - "closes on outside input and on a trigger press without reopening" - ] - }, - { - "storyId": "components-popover--static-content", - "physical": ["focuses the surface when no content can take focus"] - }, - { - "storyId": "components-radio--native-selection-semantics", - "both": ["RAD-003 RAD-005 Win32 exposes native RadioButton selection, names and disabled state"], - "accessibility": ["RAD-003 Win32 Select forwards once without self-selection or presses and disabled public activation rejects"] - }, - { - "storyId": "components-skeleton--motion", - "both": ["renders changing native pixels when gradient animation is available"] - }, - { - "storyId": "components-spinner--motion", - "both": ["renders changing native pixels unless reduced motion is enabled"] - }, - { - "storyId": "components-switch--checked", - "both": ["SWCH-001 SWCH-006 projects both initial checked values without input"] - }, - { - "storyId": "components-switch--focus-management", - "physical": ["toggles exactly once per pointer, Enter, and Space activation"] - }, - { - "storyId": "components-tablist--focus-management", - "physical": [ - "commits selection before focus, skips disabled tabs, and supports wrap, Home, and End", - "leaves modified navigation chords to their owner", - "exposes native selected and disabled accessibility states" - ] - }, - { - "storyId": "components-tablist--manual-focus-management", - "physical": ["keeps manual selection independent until Enter activates the focused tab"] - }, - { - "storyId": "components-tag--default", - "both": ["TAG-004 TAG-008 exposes the whole enabled tag as a native button"] - }, - { - "storyId": "components-tag--native-disabled", - "both": ["TAG-008 exposes disabled native button semantics"] - }, - { - "storyId": "native-callout--default", - "physical": ["opens, light-dismisses, and recreates a native popup"] - }, - { - "storyId": "native-callout--window-commands", - "physical": ["dismisses initially focused content on Escape"] - }, - { - "storyId": "native-focuszone--default", - "physical": ["uses platform directional navigation and exits the zone on Tab", "Shift+Tab exits to a preceding sibling"] - }, - { - "storyId": "native-focuszone--first-child-tab-exit", - "physical": [ - "Shift+Tab skips the first-child zone and later siblings while climbing ancestors", - "Tab exits a first-child zone to its next sibling" - ] - }, - { - "storyId": "native-focuszone--two-dimensional-navigation", - "physical": ["moves geometrically between rows"] - }, - { - "storyId": "primitives-layout-stable-text--overview", - "both": ["centers the intrinsic smaller line inside its larger reserve"] - } - ], - "quarantines": [ - { - "storyId": "components-button--focus-management", - "testName": "releases pressed feedback after an intervening key without a stale activation", - "reason": "Quarantined until #4373 is fixed: Win32 does not forward onPressOut after Space with an intervening key." - } - ] -} diff --git a/packages/agentic/components/references/tests-and-stories.md b/packages/agentic/components/references/tests-and-stories.md index edc3276894..25fc81e176 100644 --- a/packages/agentic/components/references/tests-and-stories.md +++ b/packages/agentic/components/references/tests-and-stories.md @@ -89,6 +89,15 @@ Adapt the structure to React Native. Do not copy DOM props, CSS class hooks, or Colocate `.stories.tsx`; the Storybook source glob discovers it automatically. +For Win32 input coverage, classify each named `wdio` test beside its callback: +`{ inputModes: { win32: 'physical' }, run: async (context) => { ... } }`. +Use `accessibility` for accessibility-only actions, `physical` when keyboard, +pointer, or wheel input is required, and `both` when the same assertions work +with either click policy. Keep capability guards and assertions in `run`. +The app generates its catalog from source; do not maintain a second test list. +See `packages/agentic/storybook-desktop/references/story-tests.md` for static +constraints and other endpoint annotations. + Declare module-scoped styles and other values referenced by metadata before the `Meta` object. Storybook evaluates metadata while importing the module, so referencing a later `const` drops the complete story module with an initialization error. diff --git a/packages/agentic/components/src/components/avatar/avatar.stories.tsx b/packages/agentic/components/src/components/avatar/avatar.stories.tsx index f27248cc3d..f3eda8f185 100644 --- a/packages/agentic/components/src/components/avatar/avatar.stories.tsx +++ b/packages/agentic/components/src/components/avatar/avatar.stories.tsx @@ -109,29 +109,35 @@ export const ActivityRing: Story = { }, }, wdio: { - 'AVT-004 exposes the informative identity as a named native image': async ({ browser, expect }) => { - const avatar = await browser.$('~agentic-storybook-avatar-activity-ring'); - await expect(avatar).toExist(); - expect(await avatar.getTagName()).toBe('image'); - expect(await avatar.getProperty('name')).toBe('Lydia Mitchelson'); + 'AVT-004 exposes the informative identity as a named native image': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const avatar = await browser.$('~agentic-storybook-avatar-activity-ring'); + await expect(avatar).toExist(); + expect(await avatar.getTagName()).toBe('image'); + expect(await avatar.getProperty('name')).toBe('Lydia Mitchelson'); + }, }, - 'toggles the activity ring at every size without crashing or changing avatar bounds': async ({ browser, desktop, expect }) => { - const assert: typeof import('node:assert') = (await import('node:assert')).default; - for (const size of [16, 20, 24, 28, 32, 40, 56, 120]) { - for (const activityRing of [false, true]) { - await desktop.session.updateStoryArgs('components-avatar--activity-ring', { activityRing, size }); - await expect(await browser.$('~agentic-storybook-avatar-ring-state')).toHaveText( - `Activity ring ${activityRing ? 'on' : 'off'}, size ${size}`, - ); - const avatar = await browser.$('~agentic-storybook-avatar-activity-ring'); - await expect(avatar).toExist(); - const bounds = await browser.getElementRect(await avatar.elementId); - assert( - Math.abs(bounds.width - size) <= 1 && Math.abs(bounds.height - size) <= 1, - 'The activity ring must not resize the avatar.', - ); + 'toggles the activity ring at every size without crashing or changing avatar bounds': { + inputModes: { win32: 'both' }, + run: async ({ browser, desktop, expect }) => { + const assert: typeof import('node:assert') = (await import('node:assert')).default; + for (const size of [16, 20, 24, 28, 32, 40, 56, 120]) { + for (const activityRing of [false, true]) { + await desktop.session.updateStoryArgs('components-avatar--activity-ring', { activityRing, size }); + await expect(await browser.$('~agentic-storybook-avatar-ring-state')).toHaveText( + `Activity ring ${activityRing ? 'on' : 'off'}, size ${size}`, + ); + const avatar = await browser.$('~agentic-storybook-avatar-activity-ring'); + await expect(avatar).toExist(); + const bounds = await browser.getElementRect(await avatar.elementId); + assert( + Math.abs(bounds.width - size) <= 1 && Math.abs(bounds.height - size) <= 1, + 'The activity ring must not resize the avatar.', + ); + } } - } + }, }, }, }; diff --git a/packages/agentic/components/src/components/button/button.stories.tsx b/packages/agentic/components/src/components/button/button.stories.tsx index a178b57d9a..86d005ae03 100644 --- a/packages/agentic/components/src/components/button/button.stories.tsx +++ b/packages/agentic/components/src/components/button/button.stories.tsx @@ -97,25 +97,31 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-e2e'], wdio: { - 'exposes enabled button semantics': async ({ browser, expect }) => { - const assert: typeof import('node:assert') = (await import('node:assert')).default; - const button = await browser.$('~agentic-storybook-button'); - await expect(button).toExist(); - await expect(button).toBeEnabled(); - assert.strictEqual(await button.getTagName(), 'button'); + 'exposes enabled button semantics': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const assert: typeof import('node:assert') = (await import('node:assert')).default; + const button = await browser.$('~agentic-storybook-button'); + await expect(button).toExist(); + await expect(button).toBeEnabled(); + assert.strictEqual(await button.getTagName(), 'button'); + }, }, - 'supports native pointer activation': async ({ browser, expect, platform, skip }) => { - const { expectWindowsPointerFocus } = await import('../../common/desktopFocus.wdio.ts'); - const features = browser.capabilities['furn:features']; - if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); - if (!features.physicalClick || !features.elementScreenshot || (platform !== 'macos' && !features.focus)) { - skip('This test requires physical clicks, element screenshots, and focus on Windows/Win32.'); - return; - } - const button = await browser.$('~agentic-storybook-button'); - await button.click(); - await expectWindowsPointerFocus({ browser, platform }, 'agentic-storybook-button'); - expect(await browser.takeElementScreenshot(await button.elementId)).toMatch(/^iVBORw0KGgo/); + 'supports native pointer activation': { + inputModes: { win32: 'physical' }, + run: async ({ browser, expect, platform, skip }) => { + const { expectWindowsPointerFocus } = await import('../../common/desktopFocus.wdio.ts'); + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (!features.physicalClick || !features.elementScreenshot || (platform !== 'macos' && !features.focus)) { + skip('This test requires physical clicks, element screenshots, and focus on Windows/Win32.'); + return; + } + const button = await browser.$('~agentic-storybook-button'); + await button.click(); + await expectWindowsPointerFocus({ browser, platform }, 'agentic-storybook-button'); + expect(await browser.takeElementScreenshot(await button.elementId)).toMatch(/^iVBORw0KGgo/); + }, }, }, }; @@ -169,168 +175,189 @@ export const FocusManagement: Story = { render: () => , tags: ['desktop-focus'], wdio: { - 'honors pointer focus policy and invokes exactly once per Enter and Space': async (context) => { - const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus, focusByTab } = - await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - const button = await browser.$('~focus-probe-button'); - const count = await browser.$('~focus-probe-count'); - await (await browser.$('~focus-probe-entry')).click(); - await expectNativeState(browser, 'focus-probe-entry', 'focused', true); - await button.click(); - await expectWindowsPointerFocus(context, 'focus-probe-button'); - await expect(count).toHaveText('1'); - await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); - await expect(await browser.$('~focus-probe-state')).toHaveText('focused'); - await browser.keys('\uE007'); - await expect(count).toHaveText('2'); - await expect(await browser.$('~focus-input-modality')).toHaveText('keyboard'); - await browser.keys('\uE00D'); - await expect(count).toHaveText('3'); - await button.click(); - await expect(count).toHaveText('4'); - await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); - }, - 'updates same-target modality without refocusing and shares it with nested themes': async (context) => { - const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus, focusByTab } = - await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - const button = await browser.$('~focus-probe-button'); - await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); - await button.click(); - await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); - await expect(await browser.$('~focus-probe-focus-count')).toHaveText('1'); - await browser.keys('x'); - await expect(await browser.$('~focus-input-modality')).toHaveText('keyboard'); - await expect(await browser.$('~focus-nested-modality')).toHaveText('keyboard'); - await expect(await browser.$('~focus-probe-count')).toHaveText('1'); - await button.click(); - await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); - await expect(await browser.$('~focus-probe-focus-count')).toHaveText('1'); - await (await browser.$('~focus-probe-nested')).click(); - await expectWindowsPointerFocus(context, 'focus-probe-nested'); - await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); - await browser.keys('\uE004'); - await expectNativeState(browser, 'focus-probe-next', 'focused', true); - await browser.keys('\uE004'); - await expectNativeState(browser, 'focus-probe-nested', 'focused', true); - await browser.keys('x'); - await expect(await browser.$('~focus-input-modality')).toHaveText('keyboard'); - await expect(await browser.$('~focus-nested-modality')).toHaveText('keyboard'); - await expect(await browser.$('~focus-probe-state')).toHaveText('blurred'); - }, - 'skips disabled stops and does not activate the new owner on a stale key-up': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect, platform } = context; - await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); - try { - await browser.performActions([ - { - type: 'key', - id: 'focus-keyboard', - actions: [ - { type: 'keyDown', value: '\uE007' }, - { type: 'keyDown', value: '\uE004' }, - { type: 'keyUp', value: '\uE004' }, - { type: 'keyUp', value: '\uE007' }, - ], - }, - ]); - } finally { - await browser.releaseActions(); - } - await expectNativeState(browser, 'focus-probe-next', 'focused', true); - await expectNativeState(browser, 'focus-probe-disabled', 'focused', false); - await expect(await browser.$('~focus-probe-state')).toHaveText('blurred'); - await expect(await browser.$('~focus-probe-count')).toHaveText(platform === 'macos' ? '1' : '0'); - await expect(await browser.$('~focus-probe-pressed')).toHaveText('false'); - await expect(await browser.$('~focus-probe-next-count')).toHaveText('0'); - await browser.keys(['\uE008', '\uE004']); - await expectNativeState(browser, 'focus-probe-button', 'focused', true); + 'honors pointer focus policy and invokes exactly once per Enter and Space': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus, focusByTab } = + await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + const button = await browser.$('~focus-probe-button'); + const count = await browser.$('~focus-probe-count'); + await (await browser.$('~focus-probe-entry')).click(); + await expectNativeState(browser, 'focus-probe-entry', 'focused', true); + await button.click(); + await expectWindowsPointerFocus(context, 'focus-probe-button'); + await expect(count).toHaveText('1'); + await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); + await expect(await browser.$('~focus-probe-state')).toHaveText('focused'); + await browser.keys('\uE007'); + await expect(count).toHaveText('2'); + await expect(await browser.$('~focus-input-modality')).toHaveText('keyboard'); + await browser.keys('\uE00D'); + await expect(count).toHaveText('3'); + await button.click(); + await expect(count).toHaveText('4'); + await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); + }, }, - 'suppresses repeats and activates at the native platform key phase': async (context) => { - const { requireDesktopFocus, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect, platform } = context; - await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); - try { - await browser.performActions([ - { - type: 'key', - id: 'focus-keyboard', - actions: [ - { type: 'keyDown', value: '\uE00D' }, - { type: 'keyDown', value: '\uE00D' }, - { type: 'keyDown', value: '\uE00D' }, - ], - }, - ]); - await expect(await browser.$('~focus-probe-count')).toHaveText(platform === 'macos' ? '1' : '0'); - await browser.performActions([{ type: 'key', id: 'focus-keyboard', actions: [{ type: 'keyUp', value: '\uE00D' }] }]); + 'updates same-target modality without refocusing and shares it with nested themes': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus, focusByTab } = + await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + const button = await browser.$('~focus-probe-button'); + await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); + await button.click(); + await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); + await expect(await browser.$('~focus-probe-focus-count')).toHaveText('1'); + await browser.keys('x'); + await expect(await browser.$('~focus-input-modality')).toHaveText('keyboard'); + await expect(await browser.$('~focus-nested-modality')).toHaveText('keyboard'); await expect(await browser.$('~focus-probe-count')).toHaveText('1'); - await expect(await browser.$('~focus-probe-pressed')).toHaveText('false'); - } finally { - await browser.releaseActions(); - } + await button.click(); + await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); + await expect(await browser.$('~focus-probe-focus-count')).toHaveText('1'); + await (await browser.$('~focus-probe-nested')).click(); + await expectWindowsPointerFocus(context, 'focus-probe-nested'); + await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); + await browser.keys('\uE004'); + await expectNativeState(browser, 'focus-probe-next', 'focused', true); + await browser.keys('\uE004'); + await expectNativeState(browser, 'focus-probe-nested', 'focused', true); + await browser.keys('x'); + await expect(await browser.$('~focus-input-modality')).toHaveText('keyboard'); + await expect(await browser.$('~focus-nested-modality')).toHaveText('keyboard'); + await expect(await browser.$('~focus-probe-state')).toHaveText('blurred'); + }, }, - 'releases pressed feedback after an intervening key without a stale activation': async (context) => { - if (context.platform === 'win32') { - context.skip('Quarantined until #4373 is fixed: Win32 does not forward onPressOut after Space with an intervening key.'); - return; - } - const { requireDesktopFocus, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect, platform } = context; - await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); - try { - await browser.performActions([ - { - type: 'key', - id: 'focus-keyboard', - actions: [ - { type: 'keyDown', value: '\uE00D' }, - { type: 'keyDown', value: 'x' }, - { type: 'keyUp', value: 'x' }, - { type: 'keyUp', value: '\uE00D' }, - ], - }, - ]); + 'skips disabled stops and does not activate the new owner on a stale key-up': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect, platform } = context; + await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); + try { + await browser.performActions([ + { + type: 'key', + id: 'focus-keyboard', + actions: [ + { type: 'keyDown', value: '\uE007' }, + { type: 'keyDown', value: '\uE004' }, + { type: 'keyUp', value: '\uE004' }, + { type: 'keyUp', value: '\uE007' }, + ], + }, + ]); + } finally { + await browser.releaseActions(); + } + await expectNativeState(browser, 'focus-probe-next', 'focused', true); + await expectNativeState(browser, 'focus-probe-disabled', 'focused', false); + await expect(await browser.$('~focus-probe-state')).toHaveText('blurred'); await expect(await browser.$('~focus-probe-count')).toHaveText(platform === 'macos' ? '1' : '0'); await expect(await browser.$('~focus-probe-pressed')).toHaveText('false'); - await expect(await browser.$('~focus-probe-release-count')).toHaveText('1'); - } finally { - await browser.releaseActions(); - } + await expect(await browser.$('~focus-probe-next-count')).toHaveText('0'); + await browser.keys(['\uE008', '\uE004']); + await expectNativeState(browser, 'focus-probe-button', 'focused', true); + }, }, - 'exposes the native disabled accessibility state': async ({ browser, expect }) => { - await expect(await browser.$('~focus-probe-disabled')).not.toBeEnabled(); + 'suppresses repeats and activates at the native platform key phase': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect, platform } = context; + await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); + try { + await browser.performActions([ + { + type: 'key', + id: 'focus-keyboard', + actions: [ + { type: 'keyDown', value: '\uE00D' }, + { type: 'keyDown', value: '\uE00D' }, + { type: 'keyDown', value: '\uE00D' }, + ], + }, + ]); + await expect(await browser.$('~focus-probe-count')).toHaveText(platform === 'macos' ? '1' : '0'); + await browser.performActions([{ type: 'key', id: 'focus-keyboard', actions: [{ type: 'keyUp', value: '\uE00D' }] }]); + await expect(await browser.$('~focus-probe-count')).toHaveText('1'); + await expect(await browser.$('~focus-probe-pressed')).toHaveText('false'); + } finally { + await browser.releaseActions(); + } + }, }, - 'releases repeated modifiers before the next Tab': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser } = context; - await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); - try { - await browser.performActions([ - { - type: 'key', - id: 'focus-keyboard', - actions: [ - { type: 'keyDown', value: '\uE008' }, - { type: 'keyDown', value: '\uE008' }, - { type: 'keyUp', value: '\uE008' }, - { type: 'keyDown', value: '\uE004' }, - { type: 'keyUp', value: '\uE004' }, - ], - }, - ]); - await expectNativeState(browser, 'focus-probe-next', 'focused', true); - } finally { - await browser.releaseActions(); - } + 'releases pressed feedback after an intervening key without a stale activation': { + inputModes: { win32: 'physical' }, + run: async (context) => { + if (context.platform === 'win32') { + context.skip('Quarantined until #4373 is fixed: Win32 does not forward onPressOut after Space with an intervening key.'); + return; + } + const { requireDesktopFocus, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect, platform } = context; + await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); + try { + await browser.performActions([ + { + type: 'key', + id: 'focus-keyboard', + actions: [ + { type: 'keyDown', value: '\uE00D' }, + { type: 'keyDown', value: 'x' }, + { type: 'keyUp', value: 'x' }, + { type: 'keyUp', value: '\uE00D' }, + ], + }, + ]); + await expect(await browser.$('~focus-probe-count')).toHaveText(platform === 'macos' ? '1' : '0'); + await expect(await browser.$('~focus-probe-pressed')).toHaveText('false'); + await expect(await browser.$('~focus-probe-release-count')).toHaveText('1'); + } finally { + await browser.releaseActions(); + } + }, + }, + 'exposes the native disabled accessibility state': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + await expect(await browser.$('~focus-probe-disabled')).not.toBeEnabled(); + }, + }, + 'releases repeated modifiers before the next Tab': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); + try { + await browser.performActions([ + { + type: 'key', + id: 'focus-keyboard', + actions: [ + { type: 'keyDown', value: '\uE008' }, + { type: 'keyDown', value: '\uE008' }, + { type: 'keyUp', value: '\uE008' }, + { type: 'keyDown', value: '\uE004' }, + { type: 'keyUp', value: '\uE004' }, + ], + }, + ]); + await expectNativeState(browser, 'focus-probe-next', 'focused', true); + } finally { + await browser.releaseActions(); + } + }, }, }, }; @@ -362,29 +389,32 @@ export const FocusRequests: Story = { render: () => , tags: ['desktop-focus'], wdio: { - 'confirms native requests without changing modality and rejects disabled or detached targets': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect, platform } = context; - const command = await browser.$('~focus-request-command'); - const status = await browser.$('~focus-request-status'); - await command.click(); - await expectNativeState(browser, 'focus-request-target', 'focused', true); - await expect(status).toHaveText('confirmed'); - await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); - await (await browser.$('~focus-request-entry')).click(); - await expectNativeState(browser, 'focus-request-entry', 'focused', true); - await (await browser.$('~focus-request-disable')).click(); - await expectNativeState(browser, 'focus-request-target', 'focused', false); - if (platform !== 'macos') { - await expect(await browser.$('~focus-request-target')).not.toBeEnabled(); - } - await command.click(); - await expect(status).toHaveText('not-focusable'); - await (await browser.$('~focus-request-mount')).click(); - await expect(await browser.$('~focus-request-target')).not.toExist(); - await command.click(); - await expect(status).toHaveText('not-mounted'); + 'confirms native requests without changing modality and rejects disabled or detached targets': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect, platform } = context; + const command = await browser.$('~focus-request-command'); + const status = await browser.$('~focus-request-status'); + await command.click(); + await expectNativeState(browser, 'focus-request-target', 'focused', true); + await expect(status).toHaveText('confirmed'); + await expect(await browser.$('~focus-input-modality')).toHaveText('pointer'); + await (await browser.$('~focus-request-entry')).click(); + await expectNativeState(browser, 'focus-request-entry', 'focused', true); + await (await browser.$('~focus-request-disable')).click(); + await expectNativeState(browser, 'focus-request-target', 'focused', false); + if (platform !== 'macos') { + await expect(await browser.$('~focus-request-target')).not.toBeEnabled(); + } + await command.click(); + await expect(status).toHaveText('not-focusable'); + await (await browser.$('~focus-request-mount')).click(); + await expect(await browser.$('~focus-request-target')).not.toExist(); + await command.click(); + await expect(status).toHaveText('not-mounted'); + }, }, }, }; @@ -519,38 +549,44 @@ export const Selected: Story = { export const ExternallyDrivenSelection: Story = { wdio: { - 'starts unselected': async ({ browser, expect }) => { - await expect(await browser.$('~agentic-storybook-button-selection-state')).toHaveText('Not selected'); + 'starts unselected': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + await expect(await browser.$('~agentic-storybook-button-selection-state')).toHaveText('Not selected'); + }, }, - 'updates and resets caller-owned selection': async ({ browser, expect, platform, skip }) => { - const features = browser.capabilities['furn:features']; - if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); - if (platform === 'macos' && !features.physicalClick) { - skip('macOS caller-owned selection requires physical pointer input; the custom Toggle action is not an AXPress route.'); - return; - } - if (!features.physicalClick && !features.accessibilityClick) { - skip('This test requires native accessibility activation or physical pointer input.'); - return; - } - const { clickPhysical } = await import('../../common/desktopFocus.wdio.ts'); - const favorite = await browser.$('~agentic-storybook-button-favorite'); - const reset = await browser.$('~agentic-storybook-button-reset'); - const status = await browser.$('~agentic-storybook-button-selection-state'); - await expect(favorite).toBeEnabled(); - await expect(status).toHaveText('Not selected'); - if (platform === 'macos') { - await clickPhysical(browser, 'agentic-storybook-button-favorite'); - } else { - await favorite.click(); - } - await expect(status).toHaveText('Selected'); - if (platform === 'macos') { - await clickPhysical(browser, 'agentic-storybook-button-reset'); - } else { - await reset.click(); - } - await expect(status).toHaveText('Not selected'); + 'updates and resets caller-owned selection': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, platform, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (platform === 'macos' && !features.physicalClick) { + skip('macOS caller-owned selection requires physical pointer input; the custom Toggle action is not an AXPress route.'); + return; + } + if (!features.physicalClick && !features.accessibilityClick) { + skip('This test requires native accessibility activation or physical pointer input.'); + return; + } + const { clickPhysical } = await import('../../common/desktopFocus.wdio.ts'); + const favorite = await browser.$('~agentic-storybook-button-favorite'); + const reset = await browser.$('~agentic-storybook-button-reset'); + const status = await browser.$('~agentic-storybook-button-selection-state'); + await expect(favorite).toBeEnabled(); + await expect(status).toHaveText('Not selected'); + if (platform === 'macos') { + await clickPhysical(browser, 'agentic-storybook-button-favorite'); + } else { + await favorite.click(); + } + await expect(status).toHaveText('Selected'); + if (platform === 'macos') { + await clickPhysical(browser, 'agentic-storybook-button-reset'); + } else { + await reset.click(); + } + await expect(status).toHaveText('Not selected'); + }, }, }, render: () => { @@ -625,18 +661,21 @@ export const NativeNames: Story = { ), wdio: { - 'BTN-004 exposes plain, slot and numeric native names while preserving caller names': async ({ browser, expect }) => { - for (const [id, name] of [ - ['button-name-plain', 'Save'], - ['button-name-slot', 'Slot label'], - ['button-name-number', '0'], - ['button-name-explicit', 'Create document'], - ['button-name-aria', 'ARIA action'], - ]) { - const button = await browser.$(`~${id}`); - expect(await button.getTagName()).toBe('button'); - expect(await button.getProperty('name')).toBe(name); - } + 'BTN-004 exposes plain, slot and numeric native names while preserving caller names': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + for (const [id, name] of [ + ['button-name-plain', 'Save'], + ['button-name-slot', 'Slot label'], + ['button-name-number', '0'], + ['button-name-explicit', 'Create document'], + ['button-name-aria', 'ARIA action'], + ]) { + const button = await browser.$(`~${id}`); + expect(await button.getTagName()).toBe('button'); + expect(await button.getProperty('name')).toBe(name); + } + }, }, }, }; @@ -678,79 +717,81 @@ export const NativeToggleSemantics: Story = { return ; }, wdio: { - 'BTN-004 BTN-005 exposes native button roles and enabled or disabled state': async ({ browser, expect }) => { - const off = await browser.$('~button-native-toggle-off'); - const on = await browser.$('~button-native-toggle-on'); - const disabled = await browser.$('~button-native-toggle-disabled'); - expect(await off.getTagName()).toBe('button'); - expect(await on.getTagName()).toBe('button'); - expect(await disabled.getTagName()).toBe('button'); - await expect(off).toBeEnabled(); - await expect(on).toBeEnabled(); - await expect(disabled).toBeDisabled(); + 'BTN-004 BTN-005 exposes native button roles and enabled or disabled state': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const off = await browser.$('~button-native-toggle-off'); + const on = await browser.$('~button-native-toggle-on'); + const disabled = await browser.$('~button-native-toggle-disabled'); + expect(await off.getTagName()).toBe('button'); + expect(await on.getTagName()).toBe('button'); + expect(await disabled.getTagName()).toBe('button'); + await expect(off).toBeEnabled(); + await expect(on).toBeEnabled(); + await expect(disabled).toBeDisabled(); + }, }, - 'BTN-005 Windows native checked projection reflects caller selection including disabled': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform === 'macos') { - skip( - 'Native checked projection is unsupported for the retained macOS AXButton role; shared role and enabled-state checks run separately.', - ); - return; - } - const off = await browser.$('~button-native-toggle-off'); - const on = await browser.$('~button-native-toggle-on'); - const disabled = await browser.$('~button-native-toggle-disabled'); - expect(await off.getProperty('checked')).toBe(false); - expect(await on.getProperty('checked')).toBe(true); - expect(await disabled.getProperty('checked')).toBe(true); + 'BTN-005 Windows native checked projection reflects caller selection including disabled': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform === 'macos') { + skip( + 'Native checked projection is unsupported for the retained macOS AXButton role; shared role and enabled-state checks run separately.', + ); + return; + } + const off = await browser.$('~button-native-toggle-off'); + const on = await browser.$('~button-native-toggle-on'); + const disabled = await browser.$('~button-native-toggle-disabled'); + expect(await off.getProperty('checked')).toBe(false); + expect(await on.getProperty('checked')).toBe(true); + expect(await disabled.getProperty('checked')).toBe(true); + }, }, - 'BTN-005 Win32 Toggle forwards once without a press or a controlled selection change': async ({ browser, expect, platform, skip }) => { - if (platform !== 'win32') { - skip('This case qualifies Win32 native Toggle transport.'); - return; - } - if (!browser.capabilities['furn:features']?.accessibilityClick) { - skip('Native accessibility click is unavailable.'); - return; - } - expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); - const button = await browser.$('~button-native-toggle-on'); - const actions = await browser.$('~button-native-toggle-actions'); - const presses = await browser.$('~button-native-toggle-presses'); - await expect(actions).toHaveAttribute('name', '0:none'); - await expect(presses).toHaveAttribute('name', '0'); - expect(await button.getProperty('checked')).toBe(true); - await button.click(); - await expect(actions).toHaveAttribute('name', '1:Toggle'); - await expect(presses).toHaveAttribute('name', '0'); - expect(await button.getProperty('checked')).toBe(true); + 'BTN-005 Win32 Toggle forwards once without a press or a controlled selection change': { + inputModes: { win32: 'accessibility' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies Win32 native Toggle transport.'); + return; + } + if (!browser.capabilities['furn:features']?.accessibilityClick) { + skip('Native accessibility click is unavailable.'); + return; + } + expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); + const button = await browser.$('~button-native-toggle-on'); + const actions = await browser.$('~button-native-toggle-actions'); + const presses = await browser.$('~button-native-toggle-presses'); + await expect(actions).toHaveAttribute('name', '0:none'); + await expect(presses).toHaveAttribute('name', '0'); + expect(await button.getProperty('checked')).toBe(true); + await button.click(); + await expect(actions).toHaveAttribute('name', '1:Toggle'); + await expect(presses).toHaveAttribute('name', '0'); + expect(await button.getProperty('checked')).toBe(true); + }, }, - 'BTN-004 Win32 disabled public activation rejects without callbacks or a checked-state change': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform !== 'win32') { - skip('This case qualifies the Win32 disabled accessibility-click route, not disabled-provider dispatch.'); - return; - } - if (!browser.capabilities['furn:features']?.accessibilityClick) { - skip('Native accessibility click is unavailable.'); - return; - } - expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); - const button = await browser.$('~button-native-toggle-disabled'); - await expect(button).toBeDisabled(); - expect(await button.getProperty('checked')).toBe(true); - await expect(button.click()).rejects.toThrow(); - await expect(await browser.$('~button-native-toggle-actions')).toHaveAttribute('name', '0:none'); - await expect(await browser.$('~button-native-toggle-presses')).toHaveAttribute('name', '0'); - expect(await button.getProperty('checked')).toBe(true); + 'BTN-004 Win32 disabled public activation rejects without callbacks or a checked-state change': { + inputModes: { win32: 'accessibility' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies the Win32 disabled accessibility-click route, not disabled-provider dispatch.'); + return; + } + if (!browser.capabilities['furn:features']?.accessibilityClick) { + skip('Native accessibility click is unavailable.'); + return; + } + expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); + const button = await browser.$('~button-native-toggle-disabled'); + await expect(button).toBeDisabled(); + expect(await button.getProperty('checked')).toBe(true); + await expect(button.click()).rejects.toThrow(); + await expect(await browser.$('~button-native-toggle-actions')).toHaveAttribute('name', '0:none'); + await expect(await browser.$('~button-native-toggle-presses')).toHaveAttribute('name', '0'); + expect(await button.getProperty('checked')).toBe(true); + }, }, }, parameters: { @@ -796,46 +837,44 @@ export const NativeToggleSelection: Story = { return ; }, wdio: { - 'BTN-002 BTN-005 Win32 native Toggle updates caller selection without reflow or duplicate presses': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform !== 'win32') { - skip('This case qualifies caller handling of Win32 native Toggle events.'); - return; - } - if (!browser.capabilities['furn:features']?.accessibilityClick) { - skip('Native accessibility click is unavailable.'); - return; - } - expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); - const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - const button = await browser.$('~button-native-selection'); - const state = await browser.$('~button-native-selection-state'); - const actions = await browser.$('~button-native-selection-actions'); - const presses = await browser.$('~button-native-selection-presses'); - await expect(state).toHaveAttribute('name', 'false'); - await expect(actions).toHaveAttribute('name', '0'); - await expect(presses).toHaveAttribute('name', '0'); - await expectNativeState(browser, 'button-native-selection', 'checked', false); - const before = await browser.getElementRect(await button.elementId); - await button.click(); - await expect(state).toHaveAttribute('name', 'true'); - await expect(actions).toHaveAttribute('name', '1'); - await expectNativeState(browser, 'button-native-selection', 'checked', true); - const selected = await browser.getElementRect(await button.elementId); - expect(selected.width).toBe(before.width); - expect(selected.height).toBe(before.height); - await button.click(); - await expect(state).toHaveAttribute('name', 'false'); - await expect(actions).toHaveAttribute('name', '2'); - await expect(presses).toHaveAttribute('name', '0'); - await expectNativeState(browser, 'button-native-selection', 'checked', false); - const reset = await browser.getElementRect(await button.elementId); - expect(reset.width).toBe(before.width); - expect(reset.height).toBe(before.height); + 'BTN-002 BTN-005 Win32 native Toggle updates caller selection without reflow or duplicate presses': { + inputModes: { win32: 'accessibility' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies caller handling of Win32 native Toggle events.'); + return; + } + if (!browser.capabilities['furn:features']?.accessibilityClick) { + skip('Native accessibility click is unavailable.'); + return; + } + expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); + const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + const button = await browser.$('~button-native-selection'); + const state = await browser.$('~button-native-selection-state'); + const actions = await browser.$('~button-native-selection-actions'); + const presses = await browser.$('~button-native-selection-presses'); + await expect(state).toHaveAttribute('name', 'false'); + await expect(actions).toHaveAttribute('name', '0'); + await expect(presses).toHaveAttribute('name', '0'); + await expectNativeState(browser, 'button-native-selection', 'checked', false); + const before = await browser.getElementRect(await button.elementId); + await button.click(); + await expect(state).toHaveAttribute('name', 'true'); + await expect(actions).toHaveAttribute('name', '1'); + await expectNativeState(browser, 'button-native-selection', 'checked', true); + const selected = await browser.getElementRect(await button.elementId); + expect(selected.width).toBe(before.width); + expect(selected.height).toBe(before.height); + await button.click(); + await expect(state).toHaveAttribute('name', 'false'); + await expect(actions).toHaveAttribute('name', '2'); + await expect(presses).toHaveAttribute('name', '0'); + await expectNativeState(browser, 'button-native-selection', 'checked', false); + const reset = await browser.getElementRect(await button.elementId); + expect(reset.width).toBe(before.width); + expect(reset.height).toBe(before.height); + }, }, }, parameters: { diff --git a/packages/agentic/components/src/components/card/card.stories.tsx b/packages/agentic/components/src/components/card/card.stories.tsx index abb2cc2960..a7cb3b91e3 100644 --- a/packages/agentic/components/src/components/card/card.stories.tsx +++ b/packages/agentic/components/src/components/card/card.stories.tsx @@ -176,84 +176,90 @@ export const NativeStructure: Story = { }, }, wdio: { - 'CRD-002 renders structured sections in reading order': async ({ browser, expect }) => { - expect(browser.capabilities.setWindowRect).toBe(true); - const originalWindow = await browser.getWindowRect(); - const requestedWindow = { - x: 0, - y: 0, - width: Math.max(originalWindow.width, 1024), - height: Math.max(originalWindow.height, 768), - }; - let returnedWindow: typeof originalWindow | undefined; - let readBackWindow: typeof originalWindow | undefined; - try { - // The native setter positions before resizing; the old offset left too little room above the addons panel. - returnedWindow = await browser.setWindowRect(requestedWindow.x, requestedWindow.y, requestedWindow.width, requestedWindow.height); - readBackWindow = await browser.getWindowRect(); - const header = await browser.$('~card-spec-header'); - const content = await browser.$('~card-spec-content'); - const details = await browser.$('~card-spec-details'); - const help = await browser.$('~card-spec-help'); - await browser.waitUntil(async () => (await browser.getElementRect(await header.elementId)).height > 0); - const headerRect = await browser.getElementRect(await header.elementId); - const contentRect = await browser.getElementRect(await content.elementId); - const detailsRect = await browser.getElementRect(await details.elementId); - const helpRect = await browser.getElementRect(await help.elementId); - expect(headerRect.height).toBeGreaterThan(0); - expect(contentRect.height).toBeGreaterThan(0); - expect(detailsRect.height).toBeGreaterThan(0); - expect(helpRect.height).toBeGreaterThan(0); - expect(contentRect.y).toBeGreaterThanOrEqual(headerRect.y + headerRect.height); - expect(detailsRect.y).toBeGreaterThanOrEqual(contentRect.y + contentRect.height); - expect(helpRect.y).toBeGreaterThanOrEqual(detailsRect.y + detailsRect.height); - } catch (cause) { - const observe = async (read: () => Promise) => { - const observedAt = new Date().toISOString(); - try { - return { status: 'observed', observedAt, rect: await read() }; - } catch (error) { - return { - status: 'error', - observedAt, - message: (error instanceof Error ? error.message : String(error)).slice(0, 500), - }; - } + 'CRD-002 renders structured sections in reading order': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + expect(browser.capabilities.setWindowRect).toBe(true); + const originalWindow = await browser.getWindowRect(); + const requestedWindow = { + x: 0, + y: 0, + width: Math.max(originalWindow.width, 1024), + height: Math.max(originalWindow.height, 768), }; - const windowAtFailure = await observe(() => browser.getWindowRect()); - const elements: Record = {}; - for (const [name, selector] of [ - ['preview', '~agentic-storybook-story-root'], - ['header', '~card-spec-header'], - ['content', '~card-spec-content'], - ['details', '~card-spec-details'], - ['help', '~card-spec-help'], - ]) { - elements[name] = await observe(async () => { - const element = await browser.$(selector); - return browser.getElementRect(await element.elementId); + let returnedWindow: typeof originalWindow | undefined; + let readBackWindow: typeof originalWindow | undefined; + try { + // The native setter positions before resizing; the old offset left too little room above the addons panel. + returnedWindow = await browser.setWindowRect(requestedWindow.x, requestedWindow.y, requestedWindow.width, requestedWindow.height); + readBackWindow = await browser.getWindowRect(); + const header = await browser.$('~card-spec-header'); + const content = await browser.$('~card-spec-content'); + const details = await browser.$('~card-spec-details'); + const help = await browser.$('~card-spec-help'); + await browser.waitUntil(async () => (await browser.getElementRect(await header.elementId)).height > 0); + const headerRect = await browser.getElementRect(await header.elementId); + const contentRect = await browser.getElementRect(await content.elementId); + const detailsRect = await browser.getElementRect(await details.elementId); + const helpRect = await browser.getElementRect(await help.elementId); + expect(headerRect.height).toBeGreaterThan(0); + expect(contentRect.height).toBeGreaterThan(0); + expect(detailsRect.height).toBeGreaterThan(0); + expect(helpRect.height).toBeGreaterThan(0); + expect(contentRect.y).toBeGreaterThanOrEqual(headerRect.y + headerRect.height); + expect(detailsRect.y).toBeGreaterThanOrEqual(contentRect.y + contentRect.height); + expect(helpRect.y).toBeGreaterThanOrEqual(detailsRect.y + detailsRect.height); + } catch (cause) { + const observe = async (read: () => Promise) => { + const observedAt = new Date().toISOString(); + try { + return { status: 'observed', observedAt, rect: await read() }; + } catch (error) { + return { + status: 'error', + observedAt, + message: (error instanceof Error ? error.message : String(error)).slice(0, 500), + }; + } + }; + const windowAtFailure = await observe(() => browser.getWindowRect()); + const elements: Record = {}; + for (const [name, selector] of [ + ['preview', '~agentic-storybook-story-root'], + ['header', '~card-spec-header'], + ['content', '~card-spec-content'], + ['details', '~card-spec-details'], + ['help', '~card-spec-help'], + ]) { + elements[name] = await observe(async () => { + const element = await browser.$(selector); + return browser.getElementRect(await element.elementId); + }); + } + const diagnostics = { + phase: 'before-window-restoration', + originalWindow, + requestedWindow, + returnedWindow: returnedWindow === undefined ? { status: 'not-returned' } : returnedWindow, + readBackWindow: readBackWindow === undefined ? { status: 'not-read' } : readBackWindow, + windowAtFailure, + elements, + }; + throw new Error(`${cause instanceof Error ? cause.message : String(cause)}\nCRD-002 geometry: ${JSON.stringify(diagnostics)}`, { + cause, }); + } finally { + await browser.setWindowRect(originalWindow.x, originalWindow.y, originalWindow.width, originalWindow.height); } - const diagnostics = { - phase: 'before-window-restoration', - originalWindow, - requestedWindow, - returnedWindow: returnedWindow === undefined ? { status: 'not-returned' } : returnedWindow, - readBackWindow: readBackWindow === undefined ? { status: 'not-read' } : readBackWindow, - windowAtFailure, - elements, - }; - throw new Error(`${cause instanceof Error ? cause.message : String(cause)}\nCRD-002 geometry: ${JSON.stringify(diagnostics)}`, { - cause, - }); - } finally { - await browser.setWindowRect(originalWindow.x, originalWindow.y, originalWindow.width, originalWindow.height); - } + }, }, - 'CRD-005 keeps a nested action enabled inside a disabled selectable surface': async ({ browser, expect }) => { - const help = await browser.$('~card-spec-help'); - expect(await help.getTagName()).toBe('button'); - await expect(help).toBeEnabled(); + 'CRD-005 keeps a nested action enabled inside a disabled selectable surface': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const help = await browser.$('~card-spec-help'); + expect(await help.getTagName()).toBe('button'); + await expect(help).toBeEnabled(); + }, }, }, }; diff --git a/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx b/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx index 58abff9734..acc021d00d 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx +++ b/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx @@ -67,21 +67,24 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-e2e'], wdio: { - 'toggles native checked state in both directions': async ({ browser, expect, skip }) => { - const features = browser.capabilities['furn:features']; - if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); - if (!features.physicalClick) { - skip('This test requires physical pointer input.'); - return; - } - const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - const checkbox = await browser.$('~agentic-storybook-checkbox'); - expect(await checkbox.getTagName()).toBe('checkbox'); - await expectNativeState(browser, 'agentic-storybook-checkbox', 'checked', false); - await checkbox.click(); - await expectNativeState(browser, 'agentic-storybook-checkbox', 'checked', true); - await checkbox.click(); - await expectNativeState(browser, 'agentic-storybook-checkbox', 'checked', false); + 'toggles native checked state in both directions': { + inputModes: { win32: 'physical' }, + run: async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (!features.physicalClick) { + skip('This test requires physical pointer input.'); + return; + } + const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + const checkbox = await browser.$('~agentic-storybook-checkbox'); + expect(await checkbox.getTagName()).toBe('checkbox'); + await expectNativeState(browser, 'agentic-storybook-checkbox', 'checked', false); + await checkbox.click(); + await expectNativeState(browser, 'agentic-storybook-checkbox', 'checked', true); + await checkbox.click(); + await expectNativeState(browser, 'agentic-storybook-checkbox', 'checked', false); + }, }, }, }; @@ -171,16 +174,19 @@ export const HiddenLabel: Story = { ), wdio: { - 'CBX-004 preserves native explicit and fallback names without visible labels': async ({ browser, expect }) => { - const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - const explicit = await browser.$('~checkbox-explicit-name'); - const fallback = await browser.$('~checkbox-fallback-name'); - expect(await explicit.getTagName()).toBe('checkbox'); - expect(await fallback.getTagName()).toBe('checkbox'); - expect(await explicit.getProperty('name')).toBe('Enable notifications'); - expect(await fallback.getProperty('name')).toBe('Save drafts'); - await expectNativeState(browser, 'checkbox-explicit-name', 'checked', false); - await expectNativeState(browser, 'checkbox-fallback-name', 'checked', false); + 'CBX-004 preserves native explicit and fallback names without visible labels': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + const explicit = await browser.$('~checkbox-explicit-name'); + const fallback = await browser.$('~checkbox-fallback-name'); + expect(await explicit.getTagName()).toBe('checkbox'); + expect(await fallback.getTagName()).toBe('checkbox'); + expect(await explicit.getProperty('name')).toBe('Enable notifications'); + expect(await fallback.getProperty('name')).toBe('Save drafts'); + await expectNativeState(browser, 'checkbox-explicit-name', 'checked', false); + await expectNativeState(browser, 'checkbox-fallback-name', 'checked', false); + }, }, }, parameters: { @@ -246,21 +252,27 @@ export const ConstrainedText: Story = { }, }, wdio: { - 'CBX-003 wraps the label into a taller constrained row': async ({ browser, expect }) => { - const narrow = await browser.$('~checkbox-narrow-label'); - const wide = await browser.$('~checkbox-wide-label'); - const narrowRect = await browser.getElementRect(await narrow.elementId); - const wideRect = await browser.getElementRect(await wide.elementId); - expect(narrowRect.width).toBeLessThan(wideRect.width); - expect(narrowRect.height).toBeGreaterThan(wideRect.height); + 'CBX-003 wraps the label into a taller constrained row': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const narrow = await browser.$('~checkbox-narrow-label'); + const wide = await browser.$('~checkbox-wide-label'); + const narrowRect = await browser.getElementRect(await narrow.elementId); + const wideRect = await browser.getElementRect(await wide.elementId); + expect(narrowRect.width).toBeLessThan(wideRect.width); + expect(narrowRect.height).toBeGreaterThan(wideRect.height); + }, }, - 'CBX-003 wraps secondary text into a taller constrained row': async ({ browser, expect }) => { - const narrow = await browser.$('~checkbox-narrow-secondary'); - const wide = await browser.$('~checkbox-wide-secondary'); - const narrowRect = await browser.getElementRect(await narrow.elementId); - const wideRect = await browser.getElementRect(await wide.elementId); - expect(narrowRect.width).toBeLessThan(wideRect.width); - expect(narrowRect.height).toBeGreaterThan(wideRect.height); + 'CBX-003 wraps secondary text into a taller constrained row': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const narrow = await browser.$('~checkbox-narrow-secondary'); + const wide = await browser.$('~checkbox-wide-secondary'); + const narrowRect = await browser.getElementRect(await narrow.elementId); + const wideRect = await browser.getElementRect(await wide.elementId); + expect(narrowRect.width).toBeLessThan(wideRect.width); + expect(narrowRect.height).toBeGreaterThan(wideRect.height); + }, }, }, }; diff --git a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx index af904d28d0..19b04e6691 100644 --- a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx +++ b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx @@ -120,122 +120,143 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-focus'], wdio: { - 'DRP-011 exposes a named, empty, collapsed native combobox before input': async ({ browser, expect, platform }) => { - const trigger = await browser.$('~dropdown-trigger'); - expect(await trigger.getProperty('role')).toBe('combobox'); - expect(await trigger.getProperty('name')).toBe('Fruit'); - expect(await trigger.getProperty('expanded')).toBe(false); - const renderer = 'furn:renderer' in browser.capabilities ? browser.capabilities['furn:renderer'] : undefined; - if (platform === 'macos') { - expect(['fabric', 'paper']).toContain(renderer); - } - // macOS Fabric converts empty accessibilityValue.text to nil; Paper and UIA retain the empty string. - expect(await trigger.getProperty('value')).toBe(platform === 'macos' && renderer === 'fabric' ? null : ''); + 'DRP-011 exposes a named, empty, collapsed native combobox before input': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, platform }) => { + const trigger = await browser.$('~dropdown-trigger'); + expect(await trigger.getProperty('role')).toBe('combobox'); + expect(await trigger.getProperty('name')).toBe('Fruit'); + expect(await trigger.getProperty('expanded')).toBe(false); + const renderer = 'furn:renderer' in browser.capabilities ? browser.capabilities['furn:renderer'] : undefined; + if (platform === 'macos') { + expect(['fabric', 'paper']).toContain(renderer); + } + // macOS Fabric converts empty accessibilityValue.text to nil; Paper and UIA retain the empty string. + expect(await trigger.getProperty('value')).toBe(platform === 'macos' && renderer === 'fabric' ? null : ''); + }, }, - 'opens from the keyboard on the first option and commits with Enter': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); - expect(await (await browser.$('~dropdown-trigger')).getProperty('expanded')).toBe(true); - await browser.keys('\uE015'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); - // The disabled Cherry option is never a focus stop. - await browser.keys('\uE015'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Grape'); - await browser.keys('\uE013'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Banana'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); - await expectNativeState(browser, 'dropdown-trigger', 'focused', true); - expect(await (await browser.$('~dropdown-trigger')).getProperty('value')).toBe('Banana'); + 'opens from the keyboard on the first option and commits with Enter': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); + expect(await (await browser.$('~dropdown-trigger')).getProperty('expanded')).toBe(true); + await browser.keys('\uE015'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); + // The disabled Cherry option is never a focus stop. + await browser.keys('\uE015'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Grape'); + await browser.keys('\uE013'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Banana'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); + await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + expect(await (await browser.$('~dropdown-trigger')).getProperty('value')).toBe('Banana'); + }, }, - 'reopens on the selected option and navigates with Home and End': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - const { openDropdownFromKeyboard } = await import('./dropdown.wdio.ts'); - await openDropdownFromKeyboard(context, 'Apple'); - await browser.keys('\uE010'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Mango'); - await browser.keys('\uE00D'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Mango'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); - await browser.keys('\uE015'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Mango'); - await browser.keys('\uE011'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Apple'); - await browser.keys('\uE00C'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'dropdown-trigger', 'focused', true); - expect(await (await browser.$('~dropdown-selection')).getText()).toBe('Mango'); + 'reopens on the selected option and navigates with Home and End': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + const { openDropdownFromKeyboard } = await import('./dropdown.wdio.ts'); + await openDropdownFromKeyboard(context, 'Apple'); + await browser.keys('\uE010'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Mango'); + await browser.keys('\uE00D'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Mango'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); + await browser.keys('\uE015'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Mango'); + await browser.keys('\uE011'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Apple'); + await browser.keys('\uE00C'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + expect(await (await browser.$('~dropdown-selection')).getText()).toBe('Mango'); + }, }, - 'exposes a named list of selectable options': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed, inWindowWith } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); - const trigger = await browser.$('~dropdown-trigger'); - expect(await trigger.getProperty('expanded')).toBe(true); - const triggerWidth = (await browser.getElementRect(await trigger.elementId)).width; - await inWindowWith(context, 'dropdown-list', async () => { - const list = await browser.$('~dropdown-list'); - // The list is at least as wide as the trigger and sized to its short options. - const { width } = await browser.getElementRect(await list.elementId); - expect(width).toBeGreaterThanOrEqual(triggerWidth - 1); - expect(width).toBeLessThan(triggerWidth * 1.5); - expect(await list.getProperty('name')).toBe('Fruit'); - expect(await (await browser.$('~dropdown-cherry')).getProperty('enabled')).toBe(false); - expect(await (await browser.$('~dropdown-apple')).getProperty('name')).toBe('Apple'); - }); - // A macOS window switch makes the owner window key, so close with the trigger rather than a key. - await (await browser.$('~dropdown-trigger')).click(); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: trigger'); - await expectMenuClosed(context, 'dropdown-trigger'); + 'exposes a named list of selectable options': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed, inWindowWith } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); + const trigger = await browser.$('~dropdown-trigger'); + expect(await trigger.getProperty('expanded')).toBe(true); + const triggerWidth = (await browser.getElementRect(await trigger.elementId)).width; + await inWindowWith(context, 'dropdown-list', async () => { + const list = await browser.$('~dropdown-list'); + // The list is at least as wide as the trigger and sized to its short options. + const { width } = await browser.getElementRect(await list.elementId); + expect(width).toBeGreaterThanOrEqual(triggerWidth - 1); + expect(width).toBeLessThan(triggerWidth * 1.5); + expect(await list.getProperty('name')).toBe('Fruit'); + expect(await (await browser.$('~dropdown-cherry')).getProperty('enabled')).toBe(false); + expect(await (await browser.$('~dropdown-apple')).getProperty('name')).toBe('Apple'); + }); + // A macOS window switch makes the owner window key, so close with the trigger rather than a key. + await (await browser.$('~dropdown-trigger')).click(); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: trigger'); + await expectMenuClosed(context, 'dropdown-trigger'); + }, }, - 'closes on Tab and moves focus past the trigger': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); - await browser.keys('\uE004'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); - // The Win32 host cannot continue Tab past its owner, so focus returns to the trigger there. - await expectNativeState(browser, context.platform === 'win32' ? 'dropdown-trigger' : 'dropdown-after', 'focused', true); - await expectMenuClosed(context, 'dropdown-trigger'); + 'closes on Tab and moves focus past the trigger': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); + await browser.keys('\uE004'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); + // The Win32 host cannot continue Tab past its owner, so focus returns to the trigger there. + await expectNativeState(browser, context.platform === 'win32' ? 'dropdown-trigger' : 'dropdown-after', 'focused', true); + await expectMenuClosed(context, 'dropdown-trigger'); + }, }, - 'closes on Shift+Tab and returns focus to the trigger': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); - await browser.keys(['\uE008', '\uE004']); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + 'closes on Shift+Tab and returns focus to the trigger': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); + await browser.keys(['\uE008', '\uE004']); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + }, }, - 'opens from the pointer on the first option and selects with a click': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed, inWindowWith } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await browser.$('~dropdown-trigger')).click(); - await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Apple'); - await inWindowWith(context, 'dropdown-grape', async () => { - await (await browser.$('~dropdown-grape')).click(); - }); - await expect(await browser.$('~dropdown-selection')).toHaveText('Grape'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); - await expectMenuClosed(context, 'dropdown-trigger'); - await (await browser.$('~dropdown-trigger')).click(); - await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); - await (await browser.$('~dropdown-trigger')).click(); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: trigger'); - await expectMenuClosed(context, 'dropdown-trigger'); + 'opens from the pointer on the first option and selects with a click': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed, inWindowWith } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await browser.$('~dropdown-trigger')).click(); + await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Apple'); + await inWindowWith(context, 'dropdown-grape', async () => { + await (await browser.$('~dropdown-grape')).click(); + }); + await expect(await browser.$('~dropdown-selection')).toHaveText('Grape'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); + await expectMenuClosed(context, 'dropdown-trigger'); + await (await browser.$('~dropdown-trigger')).click(); + await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); + await (await browser.$('~dropdown-trigger')).click(); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: trigger'); + await expectMenuClosed(context, 'dropdown-trigger'); + }, }, }, }; @@ -247,24 +268,27 @@ export const Multiple: Story = { docs: { description: { story: 'Activation toggles options and keeps the list open; the trigger summarizes long selections.' } }, }, wdio: { - 'toggles options and stays open until dismissed': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); - await browser.keys('\uE015'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); - await browser.keys('\uE00D'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Apple, Banana'); - await browser.keys('\uE011'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Banana'); - expect(await (await browser.$('~dropdown-reason')).getText()).toBe('Opened: trigger'); - await browser.keys('\uE00C'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + 'toggles options and stays open until dismissed': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); + await browser.keys('\uE015'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); + await browser.keys('\uE00D'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Apple, Banana'); + await browser.keys('\uE011'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Banana'); + expect(await (await browser.$('~dropdown-reason')).getText()).toBe('Opened: trigger'); + await browser.keys('\uE00C'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + }, }, }, }; @@ -314,26 +338,29 @@ export const LongList: Story = { docs: { description: { story: 'A controlled selection in a list taller than the popup, which scrolls the focused option into view.' } }, }, wdio: { - 'enters on the controlled selection and reaches the end of a scrolling list': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { inWindowWith } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Kyoto'); - await browser.keys('\uE010'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Tokyo'); - await inWindowWith(context, 'dropdown-tokyo', async () => { - const rect = async (id: string) => browser.getElementRect(await (await browser.$(`~${id}`)).elementId); - const list = await rect('dropdown-list'); - const option = await rect('dropdown-tokyo'); - // The focused last option is scrolled within the list's visible bounds. - expect(option.y).toBeGreaterThanOrEqual(list.y - 1); - expect(option.y + option.height).toBeLessThanOrEqual(list.y + list.height + 1); - // A macOS window switch makes the owner window key, so commit with the pointer rather than a key. - await (await browser.$('~dropdown-tokyo')).click(); - }); - await expect(await browser.$('~dropdown-selection')).toHaveText('Tokyo'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); + 'enters on the controlled selection and reaches the end of a scrolling list': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { inWindowWith } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Kyoto'); + await browser.keys('\uE010'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Tokyo'); + await inWindowWith(context, 'dropdown-tokyo', async () => { + const rect = async (id: string) => browser.getElementRect(await (await browser.$(`~${id}`)).elementId); + const list = await rect('dropdown-list'); + const option = await rect('dropdown-tokyo'); + // The focused last option is scrolled within the list's visible bounds. + expect(option.y).toBeGreaterThanOrEqual(list.y - 1); + expect(option.y + option.height).toBeLessThanOrEqual(list.y + list.height + 1); + // A macOS window switch makes the owner window key, so commit with the pointer rather than a key. + await (await browser.$('~dropdown-tokyo')).click(); + }); + await expect(await browser.$('~dropdown-selection')).toHaveText('Tokyo'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: select'); + }, }, }, }; @@ -371,12 +398,15 @@ function GalleryScene() { export const Appearance: Story = { render: () => , wdio: { - 'DRP-011 exposes a nonempty selected native value without input': async ({ browser, expect }) => { - const trigger = await browser.$('~dropdown-selected-trigger'); - expect(await trigger.getProperty('role')).toBe('combobox'); - expect(await trigger.getProperty('name')).toBe('Selected'); - expect(await trigger.getProperty('expanded')).toBe(false); - expect(await trigger.getProperty('value')).toBe('Banana'); + 'DRP-011 exposes a nonempty selected native value without input': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const trigger = await browser.$('~dropdown-selected-trigger'); + expect(await trigger.getProperty('role')).toBe('combobox'); + expect(await trigger.getProperty('name')).toBe('Selected'); + expect(await trigger.getProperty('expanded')).toBe(false); + expect(await trigger.getProperty('value')).toBe('Banana'); + }, }, }, parameters: { diff --git a/packages/agentic/components/src/components/input/input.stories.tsx b/packages/agentic/components/src/components/input/input.stories.tsx index f229d3118d..a1a67bcb69 100644 --- a/packages/agentic/components/src/components/input/input.stories.tsx +++ b/packages/agentic/components/src/components/input/input.stories.tsx @@ -78,19 +78,22 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-e2e'], wdio: { - 'accepts keyboard input and clears it': async ({ browser, expect, skip }) => { - const features = browser.capabilities['furn:features']; - if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); - if (!features.keyboard) { - skip('This test requires keyboard input.'); - return; - } - const input = await browser.$('~agentic-storybook-input'); - expect(await input.getTagName()).toBe('textbox'); - await input.addValue('Ada'); - await expect(input).toHaveValue('Ada'); - await input.clearValue(); - await expect(input).toHaveValue(''); + 'accepts keyboard input and clears it': { + inputModes: { win32: 'physical' }, + run: async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (!features.keyboard) { + skip('This test requires keyboard input.'); + return; + } + const input = await browser.$('~agentic-storybook-input'); + expect(await input.getTagName()).toBe('textbox'); + await input.addValue('Ada'); + await expect(input).toHaveValue('Ada'); + await input.clearValue(); + await expect(input).toHaveValue(''); + }, }, }, }; @@ -105,30 +108,36 @@ export const FocusManagement: Story = { ), wdio: { - 'keeps editing keys in the TextInput and skips disabled fields on Tab': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - const input = await browser.$('~focus-input'); - await input.setValue('Ada'); - await expectNativeState(browser, 'focus-input', 'focused', true); - await browser.keys('\uE012'); - await browser.keys('x'); - await expect(input).toHaveValue('Adxa'); - await expectNativeState(browser, 'focus-input', 'focused', true); - await browser.keys('\uE004'); - await expectNativeState(browser, 'focus-input-after', 'focused', true); - await expectNativeState(browser, 'focus-input-disabled', 'focused', false); + 'keeps editing keys in the TextInput and skips disabled fields on Tab': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + const input = await browser.$('~focus-input'); + await input.setValue('Ada'); + await expectNativeState(browser, 'focus-input', 'focused', true); + await browser.keys('\uE012'); + await browser.keys('x'); + await expect(input).toHaveValue('Adxa'); + await expectNativeState(browser, 'focus-input', 'focused', true); + await browser.keys('\uE004'); + await expectNativeState(browser, 'focus-input-after', 'focused', true); + await expectNativeState(browser, 'focus-input-disabled', 'focused', false); + }, }, - 'takes native focus from a physical pointer click': async (context) => { - const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser } = context; - await (await browser.$('~focus-input-after')).click(); - await expectWindowsPointerFocus(context, 'focus-input-after'); - await (await browser.$('~focus-input')).click(); - await expectWindowsPointerFocus(context, 'focus-input'); - await expectNativeState(browser, 'focus-input-after', 'focused', false); + 'takes native focus from a physical pointer click': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + await (await browser.$('~focus-input-after')).click(); + await expectWindowsPointerFocus(context, 'focus-input-after'); + await (await browser.$('~focus-input')).click(); + await expectWindowsPointerFocus(context, 'focus-input'); + await expectNativeState(browser, 'focus-input-after', 'focused', false); + }, }, }, }; @@ -174,34 +183,37 @@ export const FocusTreatment: Story = { }, }, wdio: { - 'retains native editor focus and geometry through every focus-treatment variant': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await (await browser.$('~focus-field-entry')).click(); - await expectNativeState(browser, 'focus-field-entry', 'focused', true); - for (const variant of ['outline', 'underline']) { - for (const suffix of ['', '-error', '-readonly']) { - const id = `focus-field-${variant}${suffix}`; - const field = await browser.$(`~${id}`); - const before = await browser.getElementRect(await field.elementId); - await browser.keys('\uE004'); - await expectNativeState(browser, id, 'focused', true); - await expect(await browser.$(`~${id}-indicator`)).toHaveText('true'); - if (suffix === '-readonly') { - expect(await field.getTagName()).toBe('textbox'); - await expect(field).toBeEnabled(); - await browser.keys('x'); - await expect(field).toHaveValue('Read only'); + 'retains native editor focus and geometry through every focus-treatment variant': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await (await browser.$('~focus-field-entry')).click(); + await expectNativeState(browser, 'focus-field-entry', 'focused', true); + for (const variant of ['outline', 'underline']) { + for (const suffix of ['', '-error', '-readonly']) { + const id = `focus-field-${variant}${suffix}`; + const field = await browser.$(`~${id}`); + const before = await browser.getElementRect(await field.elementId); + await browser.keys('\uE004'); await expectNativeState(browser, id, 'focused', true); + await expect(await browser.$(`~${id}-indicator`)).toHaveText('true'); + if (suffix === '-readonly') { + expect(await field.getTagName()).toBe('textbox'); + await expect(field).toBeEnabled(); + await browser.keys('x'); + await expect(field).toHaveValue('Read only'); + await expectNativeState(browser, id, 'focused', true); + } + const after = await browser.getElementRect(await field.elementId); + expect({ width: after.width, height: after.height }).toEqual({ width: before.width, height: before.height }); } - const after = await browser.getElementRect(await field.elementId); - expect({ width: after.width, height: after.height }).toEqual({ width: before.width, height: before.height }); } - } - await browser.keys('\uE004'); - await expectNativeState(browser, 'focus-field-after', 'focused', true); - await expectNativeState(browser, 'focus-field-disabled', 'focused', false); + await browser.keys('\uE004'); + await expectNativeState(browser, 'focus-field-after', 'focused', true); + await expectNativeState(browser, 'focus-field-disabled', 'focused', false); + }, }, }, }; diff --git a/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx b/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx index c5cad07c83..addea7f75c 100644 --- a/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx +++ b/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx @@ -93,36 +93,43 @@ export const NativeSelection: Story = { ), wdio: { - 'LBI-005 standalone unselected exposes an enabled native button': async ({ browser, expect }) => { - const option = await browser.$('~listbox-fix-unselected'); - expect(await option.getTagName()).toBe('button'); - await expect(option).toBeEnabled(); + 'LBI-005 standalone unselected exposes an enabled native button': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const option = await browser.$('~listbox-fix-unselected'); + expect(await option.getTagName()).toBe('button'); + await expect(option).toBeEnabled(); + }, }, - 'LBI-005 standalone selected exposes an enabled native button': async ({ browser, expect }) => { - const option = await browser.$('~listbox-fix-selected'); - expect(await option.getTagName()).toBe('button'); - await expect(option).toBeEnabled(); + 'LBI-005 standalone selected exposes an enabled native button': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const option = await browser.$('~listbox-fix-selected'); + expect(await option.getTagName()).toBe('button'); + await expect(option).toBeEnabled(); + }, }, - 'LBI-005 standalone disabled selection exposes a disabled native button': async ({ browser, expect }) => { - const option = await browser.$('~listbox-fix-disabled'); - expect(await option.getTagName()).toBe('button'); - await expect(option).toBeDisabled(); + 'LBI-005 standalone disabled selection exposes a disabled native button': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const option = await browser.$('~listbox-fix-disabled'); + expect(await option.getTagName()).toBe('button'); + await expect(option).toBeDisabled(); + }, }, - 'LBI-005 Windows native checked projection reflects standalone selection including disabled': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform === 'macos') { - skip( - 'Native checked projection is unsupported for the retained macOS AXButton role; shared role and enabled-state checks run separately.', - ); - return; - } - expect(await (await browser.$('~listbox-fix-unselected')).getProperty('checked')).toBe(false); - expect(await (await browser.$('~listbox-fix-selected')).getProperty('checked')).toBe(true); - expect(await (await browser.$('~listbox-fix-disabled')).getProperty('checked')).toBe(true); + 'LBI-005 Windows native checked projection reflects standalone selection including disabled': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform === 'macos') { + skip( + 'Native checked projection is unsupported for the retained macOS AXButton role; shared role and enabled-state checks run separately.', + ); + return; + } + expect(await (await browser.$('~listbox-fix-unselected')).getProperty('checked')).toBe(false); + expect(await (await browser.$('~listbox-fix-selected')).getProperty('checked')).toBe(true); + expect(await (await browser.$('~listbox-fix-disabled')).getProperty('checked')).toBe(true); + }, }, }, }; @@ -138,60 +145,56 @@ export const NativeToggleActions: Story = { }, }, wdio: { - 'LBI-005 Win32 native Toggle forwards once without a press or controlled selection change': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform !== 'win32') { - skip('This case qualifies the Win32 Toggle action transport; other hosts require their own action invocation.'); - return; - } - if (!browser.capabilities['furn:features']?.accessibilityClick) { - skip('Native accessibility click is unavailable.'); - return; - } - expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); - const option = await browser.$('~listbox-toggle-option'); - const actions = await browser.$('~listbox-toggle-actions'); - const presses = await browser.$('~listbox-toggle-presses'); - expect(await option.getProperty('checked')).toBe(true); - await expect(actions).toHaveAttribute('name', '0:none'); - await expect(presses).toHaveAttribute('name', '0'); - await option.click(); - await expect(actions).toHaveAttribute('name', '1:Toggle'); - expect(await option.getProperty('checked')).toBe(true); - await expect(presses).toHaveAttribute('name', '0'); - await expect(actions).toHaveAttribute('name', '1:Toggle'); + 'LBI-005 Win32 native Toggle forwards once without a press or controlled selection change': { + inputModes: { win32: 'accessibility' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies the Win32 Toggle action transport; other hosts require their own action invocation.'); + return; + } + if (!browser.capabilities['furn:features']?.accessibilityClick) { + skip('Native accessibility click is unavailable.'); + return; + } + expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); + const option = await browser.$('~listbox-toggle-option'); + const actions = await browser.$('~listbox-toggle-actions'); + const presses = await browser.$('~listbox-toggle-presses'); + expect(await option.getProperty('checked')).toBe(true); + await expect(actions).toHaveAttribute('name', '0:none'); + await expect(presses).toHaveAttribute('name', '0'); + await option.click(); + await expect(actions).toHaveAttribute('name', '1:Toggle'); + expect(await option.getProperty('checked')).toBe(true); + await expect(presses).toHaveAttribute('name', '0'); + await expect(actions).toHaveAttribute('name', '1:Toggle'); + }, }, - 'LBI-003 LBI-005 Win32 disabled native activation has no callbacks or selection change': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform !== 'win32') { - skip('This case qualifies the Win32 disabled accessibility-click route.'); - return; - } - if (!browser.capabilities['furn:features']?.accessibilityClick) { - skip('Native accessibility click is unavailable.'); - return; - } - expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); - const option = await browser.$('~listbox-toggle-disabled'); - const actions = await browser.$('~listbox-toggle-disabled-actions'); - const presses = await browser.$('~listbox-toggle-disabled-presses'); - await expect(option).toBeDisabled(); - expect(await option.getProperty('checked')).toBe(true); - await expect(actions).toHaveAttribute('name', '0'); - await expect(presses).toHaveAttribute('name', '0'); - // The public protocol command avoids WDIO's DOM-based element-error recovery. - await expect(browser.elementClick(await option.elementId)).rejects.toThrow('cannot be clicked'); - expect(await option.getProperty('checked')).toBe(true); - await expect(actions).toHaveAttribute('name', '0'); - await expect(presses).toHaveAttribute('name', '0'); + 'LBI-003 LBI-005 Win32 disabled native activation has no callbacks or selection change': { + inputModes: { win32: 'accessibility' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies the Win32 disabled accessibility-click route.'); + return; + } + if (!browser.capabilities['furn:features']?.accessibilityClick) { + skip('Native accessibility click is unavailable.'); + return; + } + expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); + const option = await browser.$('~listbox-toggle-disabled'); + const actions = await browser.$('~listbox-toggle-disabled-actions'); + const presses = await browser.$('~listbox-toggle-disabled-presses'); + await expect(option).toBeDisabled(); + expect(await option.getProperty('checked')).toBe(true); + await expect(actions).toHaveAttribute('name', '0'); + await expect(presses).toHaveAttribute('name', '0'); + // The public protocol command avoids WDIO's DOM-based element-error recovery. + await expect(browser.elementClick(await option.elementId)).rejects.toThrow('cannot be clicked'); + expect(await option.getProperty('checked')).toBe(true); + await expect(actions).toHaveAttribute('name', '0'); + await expect(presses).toHaveAttribute('name', '0'); + }, }, }, }; diff --git a/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx b/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx index f83a332e00..f508d35237 100644 --- a/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx +++ b/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx @@ -103,74 +103,89 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-focus'], wdio: { - 'MNB-006 exposes a named, collapsed native button before input': async ({ browser, expect }) => { - const trigger = await browser.$('~menu-button-trigger'); - expect(await trigger.getProperty('role')).toBe('button'); - expect(await trigger.getProperty('name')).toBe('New'); - expect(await trigger.getProperty('expanded')).toBe(false); - await expect(trigger).toBeEnabled(); + 'MNB-006 exposes a named, collapsed native button before input': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const trigger = await browser.$('~menu-button-trigger'); + expect(await trigger.getProperty('role')).toBe('button'); + expect(await trigger.getProperty('name')).toBe('New'); + expect(await trigger.getProperty('expanded')).toBe(false); + await expect(trigger).toBeEnabled(); + }, }, - 'opens on Down to the first row and on Up to the last row': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { inWindowWith } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await import('../../common/desktopFocus.wdio.ts')).focusByTab(context.browser, 'menu-button-entry', 'menu-button-trigger'); - expect(await (await browser.$('~menu-button-trigger')).getProperty('role')).toBe('button'); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); - expect(await (await browser.$('~menu-button-trigger')).getProperty('expanded')).toBe(true); - await browser.keys('\uE00C'); - await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'menu-button-trigger', 'focused', true); - await browser.keys('\uE013'); - await expect(await browser.$('~menu-button-focus')).toHaveText('Folder'); - await browser.keys('\uE007'); - await expect(await browser.$('~menu-button-action')).toHaveText('Folder'); - await expectNativeState(browser, 'menu-button-trigger', 'focused', true); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); - await inWindowWith(context, 'menu-button-menu', async () => { - expect(await (await browser.$('~menu-button-menu')).getProperty('role')).toBe('menu'); - expect(await (await browser.$('~menu-button-menu')).getProperty('name')).toBe('New'); - }); + 'opens on Down to the first row and on Up to the last row': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { inWindowWith } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await import('../../common/desktopFocus.wdio.ts')).focusByTab(context.browser, 'menu-button-entry', 'menu-button-trigger'); + expect(await (await browser.$('~menu-button-trigger')).getProperty('role')).toBe('button'); + await browser.keys('\uE015'); + await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); + expect(await (await browser.$('~menu-button-trigger')).getProperty('expanded')).toBe(true); + await browser.keys('\uE00C'); + await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'menu-button-trigger', 'focused', true); + await browser.keys('\uE013'); + await expect(await browser.$('~menu-button-focus')).toHaveText('Folder'); + await browser.keys('\uE007'); + await expect(await browser.$('~menu-button-action')).toHaveText('Folder'); + await expectNativeState(browser, 'menu-button-trigger', 'focused', true); + await browser.keys('\uE015'); + await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); + await inWindowWith(context, 'menu-button-menu', async () => { + expect(await (await browser.$('~menu-button-menu')).getProperty('role')).toBe('menu'); + expect(await (await browser.$('~menu-button-menu')).getProperty('name')).toBe('New'); + }); + }, }, - 'closes on Tab and moves focus past the trigger': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await import('../../common/desktopFocus.wdio.ts')).focusByTab(context.browser, 'menu-button-entry', 'menu-button-trigger'); - await browser.keys('\uE007'); - await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); - await browser.keys('\uE004'); - await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: dismiss'); - // The Win32 host cannot continue Tab past its owner, so focus returns to the trigger there. - await expectNativeState(browser, context.platform === 'win32' ? 'menu-button-trigger' : 'menu-button-after', 'focused', true); - await expectMenuClosed(context, 'menu-button-trigger'); + 'closes on Tab and moves focus past the trigger': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await import('../../common/desktopFocus.wdio.ts')).focusByTab(context.browser, 'menu-button-entry', 'menu-button-trigger'); + await browser.keys('\uE007'); + await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); + await browser.keys('\uE004'); + await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: dismiss'); + // The Win32 host cannot continue Tab past its owner, so focus returns to the trigger there. + await expectNativeState(browser, context.platform === 'win32' ? 'menu-button-trigger' : 'menu-button-after', 'focused', true); + await expectMenuClosed(context, 'menu-button-trigger'); + }, }, - 'closes on Shift+Tab and returns focus to the trigger': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await (await import('../../common/desktopFocus.wdio.ts')).focusByTab(context.browser, 'menu-button-entry', 'menu-button-trigger'); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); - await browser.keys(['\uE008', '\uE004']); - await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'menu-button-trigger', 'focused', true); + 'closes on Shift+Tab and returns focus to the trigger': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await (await import('../../common/desktopFocus.wdio.ts')).focusByTab(context.browser, 'menu-button-entry', 'menu-button-trigger'); + await browser.keys('\uE015'); + await expect(await browser.$('~menu-button-focus')).toHaveText('Message'); + await browser.keys(['\uE008', '\uE004']); + await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'menu-button-trigger', 'focused', true); + }, }, - 'toggles from the pointer without reopening': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - const trigger = await browser.$('~menu-button-trigger'); - await trigger.click(); - await expect(await browser.$('~menu-button-reason')).toHaveText('Opened: trigger'); - await trigger.click(); - await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: trigger'); - await expectMenuClosed(context, 'menu-button-trigger'); + 'toggles from the pointer without reopening': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + const trigger = await browser.$('~menu-button-trigger'); + await trigger.click(); + await expect(await browser.$('~menu-button-reason')).toHaveText('Opened: trigger'); + await trigger.click(); + await expect(await browser.$('~menu-button-reason')).toHaveText('Closed: trigger'); + await expectMenuClosed(context, 'menu-button-trigger'); + }, }, }, }; @@ -200,11 +215,14 @@ export const AccessibleNames: Story = { ), wdio: { - 'MNB-006 exposes slot, numeric and explicit native names without input': async ({ browser, expect }) => { - expect(await (await browser.$('~menu-button-slot-name')).getProperty('name')).toBe('Create'); - expect(await (await browser.$('~menu-button-numeric-name')).getProperty('name')).toBe('0'); - expect(await (await browser.$('~menu-button-explicit-name')).getProperty('name')).toBe('Create document'); - expect(await (await browser.$('~menu-button-alias-name')).getProperty('name')).toBe('More actions'); + 'MNB-006 exposes slot, numeric and explicit native names without input': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + expect(await (await browser.$('~menu-button-slot-name')).getProperty('name')).toBe('Create'); + expect(await (await browser.$('~menu-button-numeric-name')).getProperty('name')).toBe('0'); + expect(await (await browser.$('~menu-button-explicit-name')).getProperty('name')).toBe('Create document'); + expect(await (await browser.$('~menu-button-alias-name')).getProperty('name')).toBe('More actions'); + }, }, }, parameters: { diff --git a/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx b/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx index 9ca5e46619..50088e4232 100644 --- a/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx +++ b/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx @@ -98,57 +98,58 @@ export const NativeIndicatorSemantics: Story = { return ; }, wdio: { - 'MNI-003 Win32 exposes checked native indicator roles, names and availability': async ({ browser, expect, platform, skip }) => { - if (platform !== 'win32') { - skip('This case qualifies Win32 UI Automation indicator-state projection.'); - return; - } - for (const [id, name, checked] of [ - ['menu-item-native-single-off', 'Single choice off', false], - ['menu-item-native-single-on', 'Single choice on', true], - ['menu-item-native-multi-off', 'Multiple choice off', false], - ['menu-item-native-multi-on', 'Multiple choice on', true], - ['menu-item-native-disabled', 'Disabled choice', true], - ] as const) { - const row = await browser.$(`~${id}`); - expect(await row.getTagName()).toBe('menuitem'); - expect(await row.getProperty('name')).toBe(name); - expect(await row.getProperty('checked')).toBe(checked); - } - expect(await (await browser.$('~menu-item-native-single-off')).getProperty('selected')).toBe(false); - expect(await (await browser.$('~menu-item-native-single-on')).getProperty('selected')).toBe(true); - await expect(await browser.$('~menu-item-native-disabled')).toBeDisabled(); + 'MNI-003 Win32 exposes checked native indicator roles, names and availability': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies Win32 UI Automation indicator-state projection.'); + return; + } + for (const [id, name, checked] of [ + ['menu-item-native-single-off', 'Single choice off', false], + ['menu-item-native-single-on', 'Single choice on', true], + ['menu-item-native-multi-off', 'Multiple choice off', false], + ['menu-item-native-multi-on', 'Multiple choice on', true], + ['menu-item-native-disabled', 'Disabled choice', true], + ] as const) { + const row = await browser.$(`~${id}`); + expect(await row.getTagName()).toBe('menuitem'); + expect(await row.getProperty('name')).toBe(name); + expect(await row.getProperty('checked')).toBe(checked); + } + expect(await (await browser.$('~menu-item-native-single-off')).getProperty('selected')).toBe(false); + expect(await (await browser.$('~menu-item-native-single-on')).getProperty('selected')).toBe(true); + await expect(await browser.$('~menu-item-native-disabled')).toBeDisabled(); + }, }, - 'MNI-003 Win32 Toggle forwards once without changing selection or pressing and disabled public activation rejects': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform !== 'win32') { - skip('This case qualifies Win32 UI Automation Toggle transport.'); - return; - } - const row = await browser.$('~menu-item-native-multi-off'); - const disabled = await browser.$('~menu-item-native-disabled'); - const actions = await browser.$('~menu-item-native-actions'); - const presses = await browser.$('~menu-item-native-presses'); - await expect(actions).toHaveAttribute('name', '0'); - await row.click(); - await expect(actions).toHaveAttribute('name', '1'); - await expect(await browser.$('~menu-item-native-last-action')).toHaveAttribute('name', 'Toggle'); - await expect(presses).toHaveAttribute('name', '0'); - expect(await row.getProperty('checked')).toBe(false); - let rejection: unknown; - try { - await disabled.click(); - } catch (error) { - rejection = error; - } - expect(rejection).toBeDefined(); - await expect(actions).toHaveAttribute('name', '1'); - await expect(presses).toHaveAttribute('name', '0'); - expect(await disabled.getProperty('checked')).toBe(true); + 'MNI-003 Win32 Toggle forwards once without changing selection or pressing and disabled public activation rejects': { + inputModes: { win32: 'accessibility' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies Win32 UI Automation Toggle transport.'); + return; + } + const row = await browser.$('~menu-item-native-multi-off'); + const disabled = await browser.$('~menu-item-native-disabled'); + const actions = await browser.$('~menu-item-native-actions'); + const presses = await browser.$('~menu-item-native-presses'); + await expect(actions).toHaveAttribute('name', '0'); + await row.click(); + await expect(actions).toHaveAttribute('name', '1'); + await expect(await browser.$('~menu-item-native-last-action')).toHaveAttribute('name', 'Toggle'); + await expect(presses).toHaveAttribute('name', '0'); + expect(await row.getProperty('checked')).toBe(false); + let rejection: unknown; + try { + await disabled.click(); + } catch (error) { + rejection = error; + } + expect(rejection).toBeDefined(); + await expect(actions).toHaveAttribute('name', '1'); + await expect(presses).toHaveAttribute('name', '0'); + expect(await disabled.getProperty('checked')).toBe(true); + }, }, }, }; @@ -275,45 +276,54 @@ export const DisabledFocusable: Story = { docs: { description: { story: 'An opted-in disabled row remains a focus stop, announces disabled, and cannot activate.' } }, }, wdio: { - 'exposes disabled native menu-item semantics': async ({ browser, expect }) => { - const row = await browser.$('~disabled-menu-command'); - expect(await row.getTagName()).toBe('menuitem'); - await expect(row).toBeDisabled(); - await expect(await browser.$('~disabled-menu-count')).toHaveText('0'); + 'exposes disabled native menu-item semantics': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const row = await browser.$('~disabled-menu-command'); + expect(await row.getTagName()).toBe('menuitem'); + await expect(row).toBeDisabled(); + await expect(await browser.$('~disabled-menu-count')).toHaveText('0'); + }, }, - 'navigates onto a disabled row without activating it': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab, clickPhysical } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await focusByTab(browser, 'disabled-menu-entry', 'disabled-menu-before'); - await browser.keys('\uE015'); - await expectNativeState(browser, 'disabled-menu-command', 'focused', true); - await expect(await browser.$('~disabled-menu-command')).toBeDisabled(); - await browser.keys('\uE007'); - await browser.keys('\uE00D'); - await clickPhysical(browser, 'disabled-menu-command'); - await expect(await browser.$('~disabled-menu-count')).toHaveText('0'); - await expectNativeState(browser, 'disabled-menu-command', 'focused', true); - await browser.keys('\uE015'); - await expectNativeState(browser, 'disabled-menu-after', 'focused', true); + 'navigates onto a disabled row without activating it': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, focusByTab, clickPhysical } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await focusByTab(browser, 'disabled-menu-entry', 'disabled-menu-before'); + await browser.keys('\uE015'); + await expectNativeState(browser, 'disabled-menu-command', 'focused', true); + await expect(await browser.$('~disabled-menu-command')).toBeDisabled(); + await browser.keys('\uE007'); + await browser.keys('\uE00D'); + await clickPhysical(browser, 'disabled-menu-command'); + await expect(await browser.$('~disabled-menu-count')).toHaveText('0'); + await expectNativeState(browser, 'disabled-menu-command', 'focused', true); + await browser.keys('\uE015'); + await expectNativeState(browser, 'disabled-menu-after', 'focused', true); + }, }, - 'updates native disabled state without replacing the command': async ({ browser, expect, skip }) => { - if (!browser.capabilities['furn:features']?.physicalClick) { - skip('Changing availability requires physical pointer input.'); - return; - } - const row = await browser.$('~disabled-menu-command'); - const { clickPhysical } = await import('../../common/desktopFocus.wdio.ts'); - await expect(row).toBeDisabled(); - await (await browser.$('~disabled-menu-toggle')).click(); - await expect(row).toBeEnabled(); - expect(await row.getTagName()).toBe('menuitem'); - await clickPhysical(browser, 'disabled-menu-command'); - await expect(await browser.$('~disabled-menu-count')).toHaveText('1'); - await (await browser.$('~disabled-menu-toggle')).click(); - await expect(row).toBeDisabled(); - await clickPhysical(browser, 'disabled-menu-command'); - await expect(await browser.$('~disabled-menu-count')).toHaveText('1'); + 'updates native disabled state without replacing the command': { + inputModes: { win32: 'physical' }, + run: async ({ browser, expect, skip }) => { + if (!browser.capabilities['furn:features']?.physicalClick) { + skip('Changing availability requires physical pointer input.'); + return; + } + const row = await browser.$('~disabled-menu-command'); + const { clickPhysical } = await import('../../common/desktopFocus.wdio.ts'); + await expect(row).toBeDisabled(); + await (await browser.$('~disabled-menu-toggle')).click(); + await expect(row).toBeEnabled(); + expect(await row.getTagName()).toBe('menuitem'); + await clickPhysical(browser, 'disabled-menu-command'); + await expect(await browser.$('~disabled-menu-count')).toHaveText('1'); + await (await browser.$('~disabled-menu-toggle')).click(); + await expect(row).toBeDisabled(); + await clickPhysical(browser, 'disabled-menu-command'); + await expect(await browser.$('~disabled-menu-count')).toHaveText('1'); + }, }, }, }; diff --git a/packages/agentic/components/src/components/menu/menu.stories.tsx b/packages/agentic/components/src/components/menu/menu.stories.tsx index 131aead60f..34e7c58173 100644 --- a/packages/agentic/components/src/components/menu/menu.stories.tsx +++ b/packages/agentic/components/src/components/menu/menu.stories.tsx @@ -113,187 +113,208 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-focus'], wdio: { - 'opens from the keyboard on the first row and closes on activation': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await ( - await import('./menu.wdio.ts') - ).openMenuFromKeyboard(context, { - entry: 'menu-entry', - trigger: 'menu-trigger', - reason: 'menu-reason', - focus: 'menu-focus', - first: 'Cut', - }); - expect(await (await browser.$('~menu-trigger')).getProperty('expanded')).toBe(true); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-focus')).toHaveText('Copy'); - // The disabled Paste row is a focus stop by default on Windows and Win32, but not on macOS. - const pasteStops = context.platform !== 'macos'; - if (pasteStops) { + 'opens from the keyboard on the first row and closes on activation': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await ( + await import('./menu.wdio.ts') + ).openMenuFromKeyboard(context, { + entry: 'menu-entry', + trigger: 'menu-trigger', + reason: 'menu-reason', + focus: 'menu-focus', + first: 'Cut', + }); + expect(await (await browser.$('~menu-trigger')).getProperty('expanded')).toBe(true); await browser.keys('\uE015'); - await expect(await browser.$('~menu-focus')).toHaveText('Paste'); - } - await browser.keys('\uE015'); - await expect(await browser.$('~menu-focus')).toHaveText('Share'); - await browser.keys('\uE013'); - if (pasteStops) { - await expect(await browser.$('~menu-focus')).toHaveText('Paste'); + await expect(await browser.$('~menu-focus')).toHaveText('Copy'); + // The disabled Paste row is a focus stop by default on Windows and Win32, but not on macOS. + const pasteStops = context.platform !== 'macos'; + if (pasteStops) { + await browser.keys('\uE015'); + await expect(await browser.$('~menu-focus')).toHaveText('Paste'); + } + await browser.keys('\uE015'); + await expect(await browser.$('~menu-focus')).toHaveText('Share'); await browser.keys('\uE013'); - } - await expect(await browser.$('~menu-focus')).toHaveText('Copy'); - await browser.keys('\uE007'); - await expect(await browser.$('~menu-action')).toHaveText('Copy'); - await expect(await browser.$('~menu-reason')).toHaveText('Closed: action'); - await expectNativeState(browser, 'menu-trigger', 'focused', true); + if (pasteStops) { + await expect(await browser.$('~menu-focus')).toHaveText('Paste'); + await browser.keys('\uE013'); + } + await expect(await browser.$('~menu-focus')).toHaveText('Copy'); + await browser.keys('\uE007'); + await expect(await browser.$('~menu-action')).toHaveText('Copy'); + await expect(await browser.$('~menu-reason')).toHaveText('Closed: action'); + await expectNativeState(browser, 'menu-trigger', 'focused', true); + }, }, - 'navigates with Home, End, and type-ahead': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await ( - await import('./menu.wdio.ts') - ).openMenuFromKeyboard(context, { - entry: 'menu-entry', - trigger: 'menu-trigger', - reason: 'menu-reason', - focus: 'menu-focus', - first: 'Cut', - }); - await browser.keys('\uE010'); - await expect(await browser.$('~menu-focus')).toHaveText('Delete'); - await browser.keys('\uE011'); - await expect(await browser.$('~menu-focus')).toHaveText('Cut'); - await browser.keys('d'); - await expect(await browser.$('~menu-focus')).toHaveText('Delete'); - await browser.keys('c'); - await expect(await browser.$('~menu-focus')).toHaveText('Cut'); - await browser.keys('c'); - await expect(await browser.$('~menu-focus')).toHaveText('Copy'); - await browser.keys('\uE00C'); - await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'menu-trigger', 'focused', true); + 'navigates with Home, End, and type-ahead': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await ( + await import('./menu.wdio.ts') + ).openMenuFromKeyboard(context, { + entry: 'menu-entry', + trigger: 'menu-trigger', + reason: 'menu-reason', + focus: 'menu-focus', + first: 'Cut', + }); + await browser.keys('\uE010'); + await expect(await browser.$('~menu-focus')).toHaveText('Delete'); + await browser.keys('\uE011'); + await expect(await browser.$('~menu-focus')).toHaveText('Cut'); + await browser.keys('d'); + await expect(await browser.$('~menu-focus')).toHaveText('Delete'); + await browser.keys('c'); + await expect(await browser.$('~menu-focus')).toHaveText('Cut'); + await browser.keys('c'); + await expect(await browser.$('~menu-focus')).toHaveText('Copy'); + await browser.keys('\uE00C'); + await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'menu-trigger', 'focused', true); + }, }, - 'opens and closes two submenu levels with arrows and Escape': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await ( - await import('./menu.wdio.ts') - ).openMenuFromKeyboard(context, { - entry: 'menu-entry', - trigger: 'menu-trigger', - reason: 'menu-reason', - focus: 'menu-focus', - first: 'Cut', - }); - await browser.keys('s'); - await expect(await browser.$('~menu-focus')).toHaveText('Share'); - await browser.keys('\uE014'); - await expect(await browser.$('~menu-focus')).toHaveText('Email'); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-focus')).toHaveText('More'); - await browser.keys('\uE014'); - await expect(await browser.$('~menu-focus')).toHaveText('Print'); - // A restored row was already its popup's first responder, so its focus is observed through the next arrow key. - await browser.keys('\uE012'); - await browser.keys('\uE013'); - await expect(await browser.$('~menu-focus')).toHaveText('Email'); - await browser.keys('\uE00C'); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-focus')).toHaveText('Delete'); - expect(await (await browser.$('~menu-reason')).getText()).toBe('Opened: trigger'); - await browser.keys('\uE00C'); - await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'menu-trigger', 'focused', true); + 'opens and closes two submenu levels with arrows and Escape': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await ( + await import('./menu.wdio.ts') + ).openMenuFromKeyboard(context, { + entry: 'menu-entry', + trigger: 'menu-trigger', + reason: 'menu-reason', + focus: 'menu-focus', + first: 'Cut', + }); + await browser.keys('s'); + await expect(await browser.$('~menu-focus')).toHaveText('Share'); + await browser.keys('\uE014'); + await expect(await browser.$('~menu-focus')).toHaveText('Email'); + await browser.keys('\uE015'); + await expect(await browser.$('~menu-focus')).toHaveText('More'); + await browser.keys('\uE014'); + await expect(await browser.$('~menu-focus')).toHaveText('Print'); + // A restored row was already its popup's first responder, so its focus is observed through the next arrow key. + await browser.keys('\uE012'); + await browser.keys('\uE013'); + await expect(await browser.$('~menu-focus')).toHaveText('Email'); + await browser.keys('\uE00C'); + await browser.keys('\uE015'); + await expect(await browser.$('~menu-focus')).toHaveText('Delete'); + expect(await (await browser.$('~menu-reason')).getText()).toBe('Opened: trigger'); + await browser.keys('\uE00C'); + await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'menu-trigger', 'focused', true); + }, }, - 'closes the whole menu on Tab from a submenu and returns to the trigger': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed } = await import('./menu.wdio.ts'); - const { browser, expect } = context; - await ( - await import('./menu.wdio.ts') - ).openMenuFromKeyboard(context, { - entry: 'menu-entry', - trigger: 'menu-trigger', - reason: 'menu-reason', - focus: 'menu-focus', - first: 'Cut', - }); - await browser.keys('s'); - await browser.keys('\uE014'); - await expect(await browser.$('~menu-focus')).toHaveText('Email'); - await browser.keys('\uE004'); - await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); - await expectMenuClosed(context, 'menu-trigger'); - await expectNativeState(browser, 'menu-trigger', 'focused', true); + 'closes the whole menu on Tab from a submenu and returns to the trigger': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed } = await import('./menu.wdio.ts'); + const { browser, expect } = context; + await ( + await import('./menu.wdio.ts') + ).openMenuFromKeyboard(context, { + entry: 'menu-entry', + trigger: 'menu-trigger', + reason: 'menu-reason', + focus: 'menu-focus', + first: 'Cut', + }); + await browser.keys('s'); + await browser.keys('\uE014'); + await expect(await browser.$('~menu-focus')).toHaveText('Email'); + await browser.keys('\uE004'); + await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); + await expectMenuClosed(context, 'menu-trigger'); + await expectNativeState(browser, 'menu-trigger', 'focused', true); + }, }, - 'exposes named menu surfaces and submenu expanded state': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed, inWindowWith } = await import('./menu.wdio.ts'); - const { browser, expect } = context; - await ( - await import('./menu.wdio.ts') - ).openMenuFromKeyboard(context, { - entry: 'menu-entry', - trigger: 'menu-trigger', - reason: 'menu-reason', - focus: 'menu-focus', - first: 'Cut', - }); - await browser.keys('s'); - await browser.keys('\uE014'); - await expect(await browser.$('~menu-focus')).toHaveText('Email'); - // Inspecting another window moves key status, so semantics are read after the keyboard steps. - expect(await (await browser.$('~menu-trigger')).getProperty('expanded')).toBe(true); - await inWindowWith(context, 'menu-surface', async () => { - const surface = await browser.$('~menu-surface'); - expect(await surface.getProperty('role')).toBe('menu'); - expect(await surface.getProperty('name')).toBe('Edit'); - expect(await (await browser.$('~menu-share')).getProperty('expanded')).toBe(true); - expect(await (await browser.$('~menu-paste')).getProperty('enabled')).toBe(false); - }); - await inWindowWith(context, 'menu-share-surface', async () => { - const surface = await browser.$('~menu-share-surface'); - expect(await surface.getProperty('role')).toBe('menu'); - expect(await surface.getProperty('name')).toBe('Share'); - }); - await (await browser.$('~menu-trigger')).click(); - await expectMenuClosed(context, 'menu-trigger'); + 'exposes named menu surfaces and submenu expanded state': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed, inWindowWith } = await import('./menu.wdio.ts'); + const { browser, expect } = context; + await ( + await import('./menu.wdio.ts') + ).openMenuFromKeyboard(context, { + entry: 'menu-entry', + trigger: 'menu-trigger', + reason: 'menu-reason', + focus: 'menu-focus', + first: 'Cut', + }); + await browser.keys('s'); + await browser.keys('\uE014'); + await expect(await browser.$('~menu-focus')).toHaveText('Email'); + // Inspecting another window moves key status, so semantics are read after the keyboard steps. + expect(await (await browser.$('~menu-trigger')).getProperty('expanded')).toBe(true); + await inWindowWith(context, 'menu-surface', async () => { + const surface = await browser.$('~menu-surface'); + expect(await surface.getProperty('role')).toBe('menu'); + expect(await surface.getProperty('name')).toBe('Edit'); + expect(await (await browser.$('~menu-share')).getProperty('expanded')).toBe(true); + expect(await (await browser.$('~menu-paste')).getProperty('enabled')).toBe(false); + }); + await inWindowWith(context, 'menu-share-surface', async () => { + const surface = await browser.$('~menu-share-surface'); + expect(await surface.getProperty('role')).toBe('menu'); + expect(await surface.getProperty('name')).toBe('Share'); + }); + await (await browser.$('~menu-trigger')).click(); + await expectMenuClosed(context, 'menu-trigger'); + }, }, - 'opens from the pointer without a focused row and enters on Down': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed } = await import('./menu.wdio.ts'); - const { browser, expect } = context; - await (await browser.$('~menu-trigger')).click(); - await expect(await browser.$('~menu-reason')).toHaveText('Opened: trigger'); - await browser.pause(300); - expect(await (await browser.$('~menu-focus')).getText()).toBe('None'); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-focus')).toHaveText('Cut'); - await (await browser.$('~menu-trigger')).click(); - await expect(await browser.$('~menu-reason')).toHaveText('Closed: trigger'); - await expectMenuClosed(context, 'menu-trigger'); + 'opens from the pointer without a focused row and enters on Down': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed } = await import('./menu.wdio.ts'); + const { browser, expect } = context; + await (await browser.$('~menu-trigger')).click(); + await expect(await browser.$('~menu-reason')).toHaveText('Opened: trigger'); + await browser.pause(300); + expect(await (await browser.$('~menu-focus')).getText()).toBe('None'); + await browser.keys('\uE015'); + await expect(await browser.$('~menu-focus')).toHaveText('Cut'); + await (await browser.$('~menu-trigger')).click(); + await expect(await browser.$('~menu-reason')).toHaveText('Closed: trigger'); + await expectMenuClosed(context, 'menu-trigger'); + }, }, - 'opens submenus on hover and closes the family when a nested row runs': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectMenuClosed, hover, inWindowWith } = await import('./menu.wdio.ts'); - const { browser, expect } = context; - await (await browser.$('~menu-trigger')).click(); - await expect(await browser.$('~menu-reason')).toHaveText('Opened: trigger'); - await hover(context, 'menu-share'); - await hover(context, 'menu-more'); - await inWindowWith(context, 'menu-export', async () => { - await (await browser.$('~menu-export')).click(); - }); - await expect(await browser.$('~menu-action')).toHaveText('Export'); - await expect(await browser.$('~menu-reason')).toHaveText('Closed: action'); - await expectMenuClosed(context, 'menu-trigger'); + 'opens submenus on hover and closes the family when a nested row runs': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectMenuClosed, hover, inWindowWith } = await import('./menu.wdio.ts'); + const { browser, expect } = context; + await (await browser.$('~menu-trigger')).click(); + await expect(await browser.$('~menu-reason')).toHaveText('Opened: trigger'); + await hover(context, 'menu-share'); + await hover(context, 'menu-more'); + await inWindowWith(context, 'menu-export', async () => { + await (await browser.$('~menu-export')).click(); + }); + await expect(await browser.$('~menu-action')).toHaveText('Export'); + await expect(await browser.$('~menu-reason')).toHaveText('Closed: action'); + await expectMenuClosed(context, 'menu-trigger'); + }, }, }, }; @@ -305,28 +326,31 @@ export const DisabledFocusable: Story = { docs: { description: { story: 'Disabled rows stay discoverable as focus stops and refuse activation.' } }, }, wdio: { - 'keeps a disabled row focusable without running it': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await ( - await import('./menu.wdio.ts') - ).openMenuFromKeyboard(context, { - entry: 'menu-entry', - trigger: 'menu-trigger', - reason: 'menu-reason', - focus: 'menu-focus', - first: 'Cut', - }); - await browser.keys('\uE015'); - await browser.keys('\uE015'); - await expect(await browser.$('~menu-focus')).toHaveText('Paste'); - await browser.keys('\uE007'); - await browser.pause(300); - expect(await (await browser.$('~menu-action')).getText()).toBe('None'); - expect(await (await browser.$('~menu-reason')).getText()).toBe('Opened: trigger'); - await browser.keys('\uE00C'); - await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); + 'keeps a disabled row focusable without running it': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await ( + await import('./menu.wdio.ts') + ).openMenuFromKeyboard(context, { + entry: 'menu-entry', + trigger: 'menu-trigger', + reason: 'menu-reason', + focus: 'menu-focus', + first: 'Cut', + }); + await browser.keys('\uE015'); + await browser.keys('\uE015'); + await expect(await browser.$('~menu-focus')).toHaveText('Paste'); + await browser.keys('\uE007'); + await browser.pause(300); + expect(await (await browser.$('~menu-action')).getText()).toBe('None'); + expect(await (await browser.$('~menu-reason')).getText()).toBe('Opened: trigger'); + await browser.keys('\uE00C'); + await expect(await browser.$('~menu-reason')).toHaveText('Closed: dismiss'); + }, }, }, }; diff --git a/packages/agentic/components/src/components/popover/popover.stories.tsx b/packages/agentic/components/src/components/popover/popover.stories.tsx index 578bfaa073..6acfcd6c8e 100644 --- a/packages/agentic/components/src/components/popover/popover.stories.tsx +++ b/packages/agentic/components/src/components/popover/popover.stories.tsx @@ -128,61 +128,70 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-focus'], wdio: { - 'opens below the trigger as a named group and enters its first content': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { inPopupWindow, ownerScreenRect, surfaceScreenRect } = await import('./popover.wdio.ts'); - const { browser, expect } = context; - const trigger = await browser.$('~popover-trigger'); - expect(await trigger.getProperty('expanded')).toBe(false); - await trigger.click(); - await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~popover-focus')).toHaveText('First'); - expect(await trigger.getProperty('expanded')).toBe(true); - await inPopupWindow(context, async () => { - const surface = await browser.$('~popover-surface'); - expect(await surface.getProperty('role')).toBe('group'); - expect(await surface.getProperty('name')).toBe('File details'); - }); - const anchor = await ownerScreenRect(context, 'popover-trigger'); - const popup = await surfaceScreenRect(context, 'popover-surface'); - // The Win32 host ignores the placement gap and places the popup flush against the trigger. - const gap = context.platform === 'win32' ? 0 : 4; - expect(Math.abs(popup.x + popup.width / 2 - (anchor.x + anchor.width / 2))).toBeLessThanOrEqual(1); - expect(Math.abs(popup.y - (anchor.y + anchor.height + gap))).toBeLessThanOrEqual(1); + 'opens below the trigger as a named group and enters its first content': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { inPopupWindow, ownerScreenRect, surfaceScreenRect } = await import('./popover.wdio.ts'); + const { browser, expect } = context; + const trigger = await browser.$('~popover-trigger'); + expect(await trigger.getProperty('expanded')).toBe(false); + await trigger.click(); + await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~popover-focus')).toHaveText('First'); + expect(await trigger.getProperty('expanded')).toBe(true); + await inPopupWindow(context, async () => { + const surface = await browser.$('~popover-surface'); + expect(await surface.getProperty('role')).toBe('group'); + expect(await surface.getProperty('name')).toBe('File details'); + }); + const anchor = await ownerScreenRect(context, 'popover-trigger'); + const popup = await surfaceScreenRect(context, 'popover-surface'); + // The Win32 host ignores the placement gap and places the popup flush against the trigger. + const gap = context.platform === 'win32' ? 0 : 4; + expect(Math.abs(popup.x + popup.width / 2 - (anchor.x + anchor.width / 2))).toBeLessThanOrEqual(1); + expect(Math.abs(popup.y - (anchor.y + anchor.height + gap))).toBeLessThanOrEqual(1); + }, }, - 'keeps Tab inside the popup and returns focus to the trigger on Escape': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await focusByTab(browser, 'popover-entry', 'popover-trigger'); - await browser.keys('\uE007'); - await expect(await browser.$('~popover-focus')).toHaveText('First'); - await browser.keys('\uE004'); - await expect(await browser.$('~popover-focus')).toHaveText('Second'); - await browser.keys('\uE004'); - await expect(await browser.$('~popover-focus')).toHaveText('First'); - await browser.keys('\uE00C'); - await expect(await browser.$('~popover-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'popover-trigger', 'focused', true); + 'keeps Tab inside the popup and returns focus to the trigger on Escape': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await focusByTab(browser, 'popover-entry', 'popover-trigger'); + await browser.keys('\uE007'); + await expect(await browser.$('~popover-focus')).toHaveText('First'); + await browser.keys('\uE004'); + await expect(await browser.$('~popover-focus')).toHaveText('Second'); + await browser.keys('\uE004'); + await expect(await browser.$('~popover-focus')).toHaveText('First'); + await browser.keys('\uE00C'); + await expect(await browser.$('~popover-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'popover-trigger', 'focused', true); + }, }, - 'closes on outside input and on a trigger press without reopening': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectClosed } = await import('./popover.wdio.ts'); - const { browser, expect } = context; - const trigger = await browser.$('~popover-trigger'); - await trigger.click(); - await expect(await browser.$('~popover-focus')).toHaveText('First'); - await (await browser.$('~popover-entry')).click(); - await expect(await browser.$('~popover-reason')).toHaveText('Closed: dismiss'); - await expectClosed(context, 'popover-trigger'); - await trigger.click(); - await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~popover-focus')).toHaveText('First'); - await trigger.click(); - await expect(await browser.$('~popover-reason')).toHaveText('Closed: trigger'); - await expectClosed(context, 'popover-trigger'); + 'closes on outside input and on a trigger press without reopening': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectClosed } = await import('./popover.wdio.ts'); + const { browser, expect } = context; + const trigger = await browser.$('~popover-trigger'); + await trigger.click(); + await expect(await browser.$('~popover-focus')).toHaveText('First'); + await (await browser.$('~popover-entry')).click(); + await expect(await browser.$('~popover-reason')).toHaveText('Closed: dismiss'); + await expectClosed(context, 'popover-trigger'); + await trigger.click(); + await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~popover-focus')).toHaveText('First'); + await trigger.click(); + await expect(await browser.$('~popover-reason')).toHaveText('Closed: trigger'); + await expectClosed(context, 'popover-trigger'); + }, }, }, }; @@ -201,15 +210,18 @@ export const StaticContent: Story = { docs: { description: { story: 'Without focusable content, the surface itself becomes the entry focus stop.' } }, }, wdio: { - 'focuses the surface when no content can take focus': async (context) => { - const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await (await browser.$('~popover-trigger')).click(); - await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~popover-focus')).toHaveText('Surface'); - await browser.keys('\uE00C'); - await expect(await browser.$('~popover-reason')).toHaveText('Closed: dismiss'); + 'focuses the surface when no content can take focus': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await (await browser.$('~popover-trigger')).click(); + await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~popover-focus')).toHaveText('Surface'); + await browser.keys('\uE00C'); + await expect(await browser.$('~popover-reason')).toHaveText('Closed: dismiss'); + }, }, }, }; @@ -221,18 +233,21 @@ export const Controlled: Story = { docs: { description: { story: 'Content closes a controlled Popover; focus returns to its trigger.' } }, }, wdio: { - 'returns focus to the trigger when content closes the popup': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { expectClosed } = await import('./popover.wdio.ts'); - const { browser, expect } = context; - await (await browser.$('~popover-trigger')).click(); - await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~popover-focus')).toHaveText('Save'); - await browser.keys('\uE007'); - await expect(await browser.$('~popover-name')).toHaveText('Quarterly report (renamed)'); - await expectClosed(context, 'popover-trigger'); - await expectNativeState(browser, 'popover-trigger', 'focused', true); + 'returns focus to the trigger when content closes the popup': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { expectClosed } = await import('./popover.wdio.ts'); + const { browser, expect } = context; + await (await browser.$('~popover-trigger')).click(); + await expect(await browser.$('~popover-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~popover-focus')).toHaveText('Save'); + await browser.keys('\uE007'); + await expect(await browser.$('~popover-name')).toHaveText('Quarterly report (renamed)'); + await expectClosed(context, 'popover-trigger'); + await expectNativeState(browser, 'popover-trigger', 'focused', true); + }, }, }, }; diff --git a/packages/agentic/components/src/components/radio/radio.stories.tsx b/packages/agentic/components/src/components/radio/radio.stories.tsx index 2553e01fef..fbf6100a87 100644 --- a/packages/agentic/components/src/components/radio/radio.stories.tsx +++ b/packages/agentic/components/src/components/radio/radio.stories.tsx @@ -93,53 +93,54 @@ export const NativeSelectionSemantics: Story = { return ; }, wdio: { - 'RAD-003 RAD-005 Win32 exposes native RadioButton selection, names and disabled state': async ({ browser, expect, platform, skip }) => { - if (platform !== 'win32') { - skip('This case qualifies Win32 UI Automation SelectionItem projection.'); - return; - } - for (const [id, name, selected] of [ - ['radio-native-off', 'Unselected option', false], - ['radio-native-on', 'Selected option', true], - ['radio-native-disabled', 'Disabled option', true], - ] as const) { - const radio = await browser.$(`~${id}`); - expect(await radio.getTagName()).toBe('radio'); - expect(await radio.getProperty('name')).toBe(name); - expect(await radio.getProperty('selected')).toBe(selected); - } - await expect(await browser.$('~radio-native-disabled')).toBeDisabled(); + 'RAD-003 RAD-005 Win32 exposes native RadioButton selection, names and disabled state': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies Win32 UI Automation SelectionItem projection.'); + return; + } + for (const [id, name, selected] of [ + ['radio-native-off', 'Unselected option', false], + ['radio-native-on', 'Selected option', true], + ['radio-native-disabled', 'Disabled option', true], + ] as const) { + const radio = await browser.$(`~${id}`); + expect(await radio.getTagName()).toBe('radio'); + expect(await radio.getProperty('name')).toBe(name); + expect(await radio.getProperty('selected')).toBe(selected); + } + await expect(await browser.$('~radio-native-disabled')).toBeDisabled(); + }, }, - 'RAD-003 Win32 Select forwards once without self-selection or presses and disabled public activation rejects': async ({ - browser, - expect, - platform, - skip, - }) => { - if (platform !== 'win32') { - skip('This case qualifies Win32 UI Automation Select transport.'); - return; - } - const radio = await browser.$('~radio-native-off'); - const disabled = await browser.$('~radio-native-disabled'); - const actions = await browser.$('~radio-native-actions'); - const presses = await browser.$('~radio-native-presses'); - await expect(actions).toHaveAttribute('name', '0'); - await radio.click(); - await expect(actions).toHaveAttribute('name', '1'); - await expect(await browser.$('~radio-native-last-action')).toHaveAttribute('name', 'Select'); - await expect(presses).toHaveAttribute('name', '0'); - expect(await radio.getProperty('selected')).toBe(false); - let rejection: unknown; - try { - await disabled.click(); - } catch (error) { - rejection = error; - } - expect(rejection).toBeDefined(); - await expect(actions).toHaveAttribute('name', '1'); - await expect(presses).toHaveAttribute('name', '0'); - expect(await disabled.getProperty('selected')).toBe(true); + 'RAD-003 Win32 Select forwards once without self-selection or presses and disabled public activation rejects': { + inputModes: { win32: 'accessibility' }, + run: async ({ browser, expect, platform, skip }) => { + if (platform !== 'win32') { + skip('This case qualifies Win32 UI Automation Select transport.'); + return; + } + const radio = await browser.$('~radio-native-off'); + const disabled = await browser.$('~radio-native-disabled'); + const actions = await browser.$('~radio-native-actions'); + const presses = await browser.$('~radio-native-presses'); + await expect(actions).toHaveAttribute('name', '0'); + await radio.click(); + await expect(actions).toHaveAttribute('name', '1'); + await expect(await browser.$('~radio-native-last-action')).toHaveAttribute('name', 'Select'); + await expect(presses).toHaveAttribute('name', '0'); + expect(await radio.getProperty('selected')).toBe(false); + let rejection: unknown; + try { + await disabled.click(); + } catch (error) { + rejection = error; + } + expect(rejection).toBeDefined(); + await expect(actions).toHaveAttribute('name', '1'); + await expect(presses).toHaveAttribute('name', '0'); + expect(await disabled.getProperty('selected')).toBe(true); + }, }, }, }; diff --git a/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx b/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx index 878b24042c..f8710f24a9 100644 --- a/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx +++ b/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx @@ -88,32 +88,35 @@ export const Motion: Story = { }, }, wdio: { - 'renders changing native pixels when gradient animation is available': async ({ browser, expect, skip }) => { - const features = browser.capabilities['furn:features']; - if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); - if (!features.elementScreenshot) { - skip('This test requires native element screenshots.'); - return; - } - const preference = await browser.$('~skeleton-motion-preference'); - await browser.waitUntil(async () => - ['Motion enabled', 'Reduced motion enabled', 'Gradient unavailable for native platform colors'].includes( - await preference.getText(), - ), - ); - const skeleton = await browser.$('~skeleton-motion'); - await expect(skeleton).toExist(); - expect(await browser.isElementDisplayed(await skeleton.elementId)).toBe(true); - const frames = new Set(); - for (let index = 0; index < 5; index++) { - frames.add(await browser.takeElementScreenshot(await skeleton.elementId)); - await browser.pause(117); - } - if ((await preference.getText()) === 'Motion enabled') { - expect(frames.size).toBeGreaterThan(1); - } else { - expect(frames.size).toBe(1); - } + 'renders changing native pixels when gradient animation is available': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (!features.elementScreenshot) { + skip('This test requires native element screenshots.'); + return; + } + const preference = await browser.$('~skeleton-motion-preference'); + await browser.waitUntil(async () => + ['Motion enabled', 'Reduced motion enabled', 'Gradient unavailable for native platform colors'].includes( + await preference.getText(), + ), + ); + const skeleton = await browser.$('~skeleton-motion'); + await expect(skeleton).toExist(); + expect(await browser.isElementDisplayed(await skeleton.elementId)).toBe(true); + const frames = new Set(); + for (let index = 0; index < 5; index++) { + frames.add(await browser.takeElementScreenshot(await skeleton.elementId)); + await browser.pause(117); + } + if ((await preference.getText()) === 'Motion enabled') { + expect(frames.size).toBeGreaterThan(1); + } else { + expect(frames.size).toBe(1); + } + }, }, }, }; diff --git a/packages/agentic/components/src/components/spinner/spinner.stories.tsx b/packages/agentic/components/src/components/spinner/spinner.stories.tsx index 8333075f7f..8f7c6c7b34 100644 --- a/packages/agentic/components/src/components/spinner/spinner.stories.tsx +++ b/packages/agentic/components/src/components/spinner/spinner.stories.tsx @@ -94,28 +94,31 @@ export const Motion: Story = { }, }, wdio: { - 'renders changing native pixels unless reduced motion is enabled': async ({ browser, expect, skip }) => { - const features = browser.capabilities['furn:features']; - if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); - if (!features.elementScreenshot) { - skip('This test requires native element screenshots.'); - return; - } - const preference = await browser.$('~spinner-motion-preference'); - await browser.waitUntil(async () => ['Motion enabled', 'Reduced motion enabled'].includes(await preference.getText())); - const spinner = await browser.$('~spinner-motion'); - await expect(spinner).toExist(); - expect(await browser.isElementDisplayed(await spinner.elementId)).toBe(true); - const frames = new Set(); - for (let index = 0; index < 5; index++) { - frames.add(await browser.takeElementScreenshot(await spinner.elementId)); - await browser.pause(117); - } - if ((await preference.getText()) === 'Motion enabled') { - expect(frames.size).toBeGreaterThan(1); - } else { - expect(frames.size).toBe(1); - } + 'renders changing native pixels unless reduced motion is enabled': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (!features.elementScreenshot) { + skip('This test requires native element screenshots.'); + return; + } + const preference = await browser.$('~spinner-motion-preference'); + await browser.waitUntil(async () => ['Motion enabled', 'Reduced motion enabled'].includes(await preference.getText())); + const spinner = await browser.$('~spinner-motion'); + await expect(spinner).toExist(); + expect(await browser.isElementDisplayed(await spinner.elementId)).toBe(true); + const frames = new Set(); + for (let index = 0; index < 5; index++) { + frames.add(await browser.takeElementScreenshot(await spinner.elementId)); + await browser.pause(117); + } + if ((await preference.getText()) === 'Motion enabled') { + expect(frames.size).toBeGreaterThan(1); + } else { + expect(frames.size).toBe(1); + } + }, }, }, }; diff --git a/packages/agentic/components/src/components/switch/switch.stories.tsx b/packages/agentic/components/src/components/switch/switch.stories.tsx index b1bc0483bd..46360af50c 100644 --- a/packages/agentic/components/src/components/switch/switch.stories.tsx +++ b/packages/agentic/components/src/components/switch/switch.stories.tsx @@ -95,27 +95,30 @@ export const FocusManagement: Story = { render: () => , tags: ['desktop-focus'], wdio: { - 'toggles exactly once per pointer, Enter, and Space activation': async (context) => { - const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus, focusByTab } = - await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - const control = await browser.$('~focus-switch'); - const count = await browser.$('~focus-switch-count'); - await (await browser.$('~focus-switch-entry')).click(); - await expectNativeState(browser, 'focus-switch-entry', 'focused', true); - await expectNativeState(browser, 'focus-switch', 'checked', false); - await control.click(); - await expectWindowsPointerFocus(context, 'focus-switch'); - await expect(count).toHaveText('1'); - await expectNativeState(browser, 'focus-switch', 'checked', true); - await focusByTab(browser, 'focus-switch-entry', 'focus-switch'); - await browser.keys('\uE007'); - await expect(count).toHaveText('2'); - await expectNativeState(browser, 'focus-switch', 'checked', false); - await browser.keys('\uE00D'); - await expect(count).toHaveText('3'); - await expectNativeState(browser, 'focus-switch', 'checked', true); + 'toggles exactly once per pointer, Enter, and Space activation': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, expectWindowsPointerFocus, focusByTab } = + await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + const control = await browser.$('~focus-switch'); + const count = await browser.$('~focus-switch-count'); + await (await browser.$('~focus-switch-entry')).click(); + await expectNativeState(browser, 'focus-switch-entry', 'focused', true); + await expectNativeState(browser, 'focus-switch', 'checked', false); + await control.click(); + await expectWindowsPointerFocus(context, 'focus-switch'); + await expect(count).toHaveText('1'); + await expectNativeState(browser, 'focus-switch', 'checked', true); + await focusByTab(browser, 'focus-switch-entry', 'focus-switch'); + await browser.keys('\uE007'); + await expect(count).toHaveText('2'); + await expectNativeState(browser, 'focus-switch', 'checked', false); + await browser.keys('\uE00D'); + await expect(count).toHaveText('3'); + await expectNativeState(browser, 'focus-switch', 'checked', true); + }, }, }, }; @@ -197,13 +200,16 @@ export const Checked: Story = { }, }, wdio: { - 'SWCH-001 SWCH-006 projects both initial checked values without input': async ({ browser, expect }) => { - const off = await browser.$('~switch-spec-off'); - const on = await browser.$('~switch-spec-on'); - await expect(off).toBeEnabled(); - await expect(on).toBeEnabled(); - expect(await off.getProperty('checked')).toBe(false); - expect(await on.getProperty('checked')).toBe(true); + 'SWCH-001 SWCH-006 projects both initial checked values without input': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const off = await browser.$('~switch-spec-off'); + const on = await browser.$('~switch-spec-on'); + await expect(off).toBeEnabled(); + await expect(on).toBeEnabled(); + expect(await off.getProperty('checked')).toBe(false); + expect(await on.getProperty('checked')).toBe(true); + }, }, }, }; diff --git a/packages/agentic/components/src/components/tablist/tablist.stories.tsx b/packages/agentic/components/src/components/tablist/tablist.stories.tsx index d2c685b0fe..e815c594f2 100644 --- a/packages/agentic/components/src/components/tablist/tablist.stories.tsx +++ b/packages/agentic/components/src/components/tablist/tablist.stories.tsx @@ -87,56 +87,65 @@ export const FocusManagement: Story = { render: () => , tags: ['desktop-focus'], wdio: { - 'commits selection before focus, skips disabled tabs, and supports wrap, Home, and End': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await focusByTab(browser, 'focus-tab-entry', 'focus-tab-one'); - await expect(await browser.$('~focus-tab-disabled')).not.toBeEnabled(); - for (const [key, id, value] of [ - ['\uE014', 'focus-tab-two', 'focus-panel-two'], - ['\uE014', 'focus-tab-one', 'focus-panel-one'], - ['\uE010', 'focus-tab-two', 'focus-panel-two'], - ['\uE011', 'focus-tab-one', 'focus-panel-one'], - ]) { - await browser.keys(key); - await expectNativeState(browser, id, 'focused', true); - await expectNativeState(browser, id, 'selected', true); - await expect(await browser.$('~focus-tab-selection')).toHaveText(value); - await expect(await browser.$('~focus-tab-selection-at-focus')).toHaveText(value); - await expectNativeState(browser, 'focus-tab-disabled', 'focused', false); - } + 'commits selection before focus, skips disabled tabs, and supports wrap, Home, and End': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await focusByTab(browser, 'focus-tab-entry', 'focus-tab-one'); + await expect(await browser.$('~focus-tab-disabled')).not.toBeEnabled(); + for (const [key, id, value] of [ + ['\uE014', 'focus-tab-two', 'focus-panel-two'], + ['\uE014', 'focus-tab-one', 'focus-panel-one'], + ['\uE010', 'focus-tab-two', 'focus-panel-two'], + ['\uE011', 'focus-tab-one', 'focus-panel-one'], + ]) { + await browser.keys(key); + await expectNativeState(browser, id, 'focused', true); + await expectNativeState(browser, id, 'selected', true); + await expect(await browser.$('~focus-tab-selection')).toHaveText(value); + await expect(await browser.$('~focus-tab-selection-at-focus')).toHaveText(value); + await expectNativeState(browser, 'focus-tab-disabled', 'focused', false); + } + }, + }, + 'leaves modified navigation chords to their owner': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect, platform } = context; + // Control+Arrow is a macOS system shortcut, not a key the app can promise to receive. + const modifiers = + platform === 'macos' + ? [['\uE008', 'Shift']] + : [ + ['\uE009', 'Control'], + ['\uE008', 'Shift'], + ]; + for (const [modifier, name] of modifiers) { + await focusByTab(browser, 'focus-tab-entry', 'focus-tab-one'); + await browser.keys([modifier, '\uE014']); + await expect(await browser.$('~focus-tab-owner-key')).toHaveText(`${name}+ArrowRight`); + await expectNativeState(browser, 'focus-tab-one', 'selected', true); + await expect(await browser.$('~focus-tab-selection')).toHaveText('focus-panel-one'); + } + }, }, - 'leaves modified navigation chords to their owner': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect, platform } = context; - // Control+Arrow is a macOS system shortcut, not a key the app can promise to receive. - const modifiers = - platform === 'macos' - ? [['\uE008', 'Shift']] - : [ - ['\uE009', 'Control'], - ['\uE008', 'Shift'], - ]; - for (const [modifier, name] of modifiers) { + 'exposes native selected and disabled accessibility states': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { browser, expect } = context; + const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; await focusByTab(browser, 'focus-tab-entry', 'focus-tab-one'); - await browser.keys([modifier, '\uE014']); - await expect(await browser.$('~focus-tab-owner-key')).toHaveText(`${name}+ArrowRight`); + await expect(await browser.$('~focus-tab-disabled')).not.toBeEnabled(); await expectNativeState(browser, 'focus-tab-one', 'selected', true); - await expect(await browser.$('~focus-tab-selection')).toHaveText('focus-panel-one'); - } - }, - 'exposes native selected and disabled accessibility states': async (context) => { - const { browser, expect } = context; - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - await focusByTab(browser, 'focus-tab-entry', 'focus-tab-one'); - await expect(await browser.$('~focus-tab-disabled')).not.toBeEnabled(); - await expectNativeState(browser, 'focus-tab-one', 'selected', true); - await browser.keys('\uE014'); - await expectNativeState(browser, 'focus-tab-one', 'selected', false); - await expectNativeState(browser, 'focus-tab-two', 'selected', true); + await browser.keys('\uE014'); + await expectNativeState(browser, 'focus-tab-one', 'selected', false); + await expectNativeState(browser, 'focus-tab-two', 'selected', true); + }, }, }, }; @@ -145,19 +154,22 @@ export const ManualFocusManagement: Story = { render: () => , tags: ['desktop-focus'], wdio: { - 'keeps manual selection independent until Enter activates the focused tab': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, expect } = context; - await focusByTab(browser, 'focus-tab-entry', 'focus-tab-one'); - await browser.keys('\uE014'); - await expectNativeState(browser, 'focus-tab-two', 'focused', true); - await expectNativeState(browser, 'focus-tab-one', 'selected', true); - await expectNativeState(browser, 'focus-tab-two', 'selected', false); - await expect(await browser.$('~focus-tab-selection')).toHaveText('focus-panel-one'); - await browser.keys('\uE007'); - await expectNativeState(browser, 'focus-tab-two', 'selected', true); - await expect(await browser.$('~focus-tab-selection')).toHaveText('focus-panel-two'); + 'keeps manual selection independent until Enter activates the focused tab': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, expect } = context; + await focusByTab(browser, 'focus-tab-entry', 'focus-tab-one'); + await browser.keys('\uE014'); + await expectNativeState(browser, 'focus-tab-two', 'focused', true); + await expectNativeState(browser, 'focus-tab-one', 'selected', true); + await expectNativeState(browser, 'focus-tab-two', 'selected', false); + await expect(await browser.$('~focus-tab-selection')).toHaveText('focus-panel-one'); + await browser.keys('\uE007'); + await expectNativeState(browser, 'focus-tab-two', 'selected', true); + await expect(await browser.$('~focus-tab-selection')).toHaveText('focus-panel-two'); + }, }, }, }; diff --git a/packages/agentic/components/src/components/tag/tag.stories.tsx b/packages/agentic/components/src/components/tag/tag.stories.tsx index b90f5aa9b0..0aa0cf5c45 100644 --- a/packages/agentic/components/src/components/tag/tag.stories.tsx +++ b/packages/agentic/components/src/components/tag/tag.stories.tsx @@ -78,10 +78,13 @@ type Story = WdioStory>; export const Default: Story = { wdio: { - 'TAG-004 TAG-008 exposes the whole enabled tag as a native button': async ({ browser, expect }) => { - const tag = await browser.$('~agentic-storybook-tag'); - expect(await tag.getTagName()).toBe('button'); - await expect(tag).toBeEnabled(); + 'TAG-004 TAG-008 exposes the whole enabled tag as a native button': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const tag = await browser.$('~agentic-storybook-tag'); + expect(await tag.getTagName()).toBe('button'); + await expect(tag).toBeEnabled(); + }, }, }, }; @@ -89,10 +92,13 @@ export const Default: Story = { export const NativeDisabled: Story = { args: { content: 'Unavailable filter', disabled: true }, wdio: { - 'TAG-008 exposes disabled native button semantics': async ({ browser, expect }) => { - const tag = await browser.$('~agentic-storybook-tag'); - expect(await tag.getTagName()).toBe('button'); - await expect(tag).toBeDisabled(); + 'TAG-008 exposes disabled native button semantics': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const tag = await browser.$('~agentic-storybook-tag'); + expect(await tag.getTagName()).toBe('button'); + await expect(tag).toBeDisabled(); + }, }, }, }; diff --git a/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx b/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx index 11f443be8b..f78e079d71 100644 --- a/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx +++ b/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx @@ -240,31 +240,37 @@ export const Default: Story = { tags: ['desktop-e2e'], render: (args) => , wdio: { - 'uses platform directional navigation and exits the zone on Tab': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser, platform } = context; - await (await browser.$('~focus-zone-item-1')).click(); - await expectNativeState(browser, 'focus-zone-item-1', 'focused', true); - await browser.keys('\uE014'); - await expectNativeState(browser, 'focus-zone-item-2', 'focused', true); - await browser.keys('\uE015'); - await expectNativeState(browser, platform === 'macos' ? 'focus-zone-item-5' : 'focus-zone-item-3', 'focused', true); - await browser.keys('\uE004'); - await expectNativeState(browser, 'focus-zone-after', 'focused', true); + 'uses platform directional navigation and exits the zone on Tab': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser, platform } = context; + await (await browser.$('~focus-zone-item-1')).click(); + await expectNativeState(browser, 'focus-zone-item-1', 'focused', true); + await browser.keys('\uE014'); + await expectNativeState(browser, 'focus-zone-item-2', 'focused', true); + await browser.keys('\uE015'); + await expectNativeState(browser, platform === 'macos' ? 'focus-zone-item-5' : 'focus-zone-item-3', 'focused', true); + await browser.keys('\uE004'); + await expectNativeState(browser, 'focus-zone-after', 'focused', true); + }, }, - 'Shift+Tab exits to a preceding sibling': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser } = context; - await (await browser.$('~focus-zone-item-2')).click(); - await expectNativeState(browser, 'focus-zone-item-2', 'focused', true); - try { - await browser.keys(['\uE008', '\uE004']); - } finally { - await browser.releaseActions(); - } - await expectNativeState(browser, 'focus-zone-before', 'focused', true); + 'Shift+Tab exits to a preceding sibling': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + await (await browser.$('~focus-zone-item-2')).click(); + await expectNativeState(browser, 'focus-zone-item-2', 'focused', true); + try { + await browser.keys(['\uE008', '\uE004']); + } finally { + await browser.releaseActions(); + } + await expectNativeState(browser, 'focus-zone-before', 'focused', true); + }, }, }, parameters: { @@ -299,14 +305,17 @@ export const TwoDimensionalNavigation: Story = { }, render: (args) => , wdio: { - 'moves geometrically between rows': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser } = context; - await (await browser.$('~focus-zone-item-1')).click(); - await expectNativeState(browser, 'focus-zone-item-1', 'focused', true); - await browser.keys('\uE015'); - await expectNativeState(browser, 'focus-zone-item-4', 'focused', true); + 'moves geometrically between rows': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + await (await browser.$('~focus-zone-item-1')).click(); + await expectNativeState(browser, 'focus-zone-item-1', 'focused', true); + await browser.keys('\uE015'); + await expectNativeState(browser, 'focus-zone-item-4', 'focused', true); + }, }, }, parameters: { @@ -354,32 +363,38 @@ export const FirstChildTabExit: Story = { }, render: (args) => , wdio: { - 'Shift+Tab skips the first-child zone and later siblings while climbing ancestors': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser } = context; - for (const item of ['focus-zone-first-child-first', 'focus-zone-first-child-last']) { - await (await browser.$(`~${item}`)).click(); - await expectNativeState(browser, item, 'focused', true); - try { - await browser.keys(['\uE008', '\uE004']); - } finally { - await browser.releaseActions(); + 'Shift+Tab skips the first-child zone and later siblings while climbing ancestors': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + for (const item of ['focus-zone-first-child-first', 'focus-zone-first-child-last']) { + await (await browser.$(`~${item}`)).click(); + await expectNativeState(browser, item, 'focused', true); + try { + await browser.keys(['\uE008', '\uE004']); + } finally { + await browser.releaseActions(); + } + await expectNativeState(browser, 'focus-zone-previous', 'focused', true); + await expectNativeState(browser, item, 'focused', false); + await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', false); + await expectNativeState(browser, 'focus-zone-later-ancestor-sibling', 'focused', false); } - await expectNativeState(browser, 'focus-zone-previous', 'focused', true); - await expectNativeState(browser, item, 'focused', false); - await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', false); - await expectNativeState(browser, 'focus-zone-later-ancestor-sibling', 'focused', false); - } + }, }, - 'Tab exits a first-child zone to its next sibling': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { browser } = context; - await (await browser.$('~focus-zone-first-child-first')).click(); - await expectNativeState(browser, 'focus-zone-first-child-first', 'focused', true); - await browser.keys('\uE004'); - await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', true); + 'Tab exits a first-child zone to its next sibling': { + inputModes: { win32: 'physical' }, + run: async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + await (await browser.$('~focus-zone-first-child-first')).click(); + await expectNativeState(browser, 'focus-zone-first-child-first', 'focused', true); + await browser.keys('\uE004'); + await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', true); + }, }, }, parameters: { diff --git a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx index 43ed87a954..581e26f384 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx +++ b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx @@ -159,41 +159,44 @@ export const Overview: Story = { }, }, wdio: { - 'centers the intrinsic smaller line inside its larger reserve': async ({ browser, expect, platform }) => { - const assert: typeof import('node:assert') = (await import('node:assert')).default; - if (platform === 'macos') { - // Measure both Text line boxes; Paper rounds Text and wrapper View frames differently. - const status = await browser.$('~layout-stable-text-native-metrics'); - await browser.waitUntil(async () => (await status.getText()).startsWith('{')); - const metrics: { reserve: LayoutRectangle; visible: LayoutRectangle } = JSON.parse(await status.getText()); - assert( - metrics.visible.height > 0 && metrics.visible.height < metrics.reserve.height, - 'Visible text must retain its intrinsic height.', - ); - assert( - Math.abs(metrics.visible.y + metrics.visible.height / 2 - metrics.reserve.height / 2) <= 1, - 'Visible text must be vertically centered within one layout pixel.', - ); + 'centers the intrinsic smaller line inside its larger reserve': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect, platform }) => { + const assert: typeof import('node:assert') = (await import('node:assert')).default; + if (platform === 'macos') { + // Measure both Text line boxes; Paper rounds Text and wrapper View frames differently. + const status = await browser.$('~layout-stable-text-native-metrics'); + await browser.waitUntil(async () => (await status.getText()).startsWith('{')); + const metrics: { reserve: LayoutRectangle; visible: LayoutRectangle } = JSON.parse(await status.getText()); + assert( + metrics.visible.height > 0 && metrics.visible.height < metrics.reserve.height, + 'Visible text must retain its intrinsic height.', + ); + assert( + Math.abs(metrics.visible.y + metrics.visible.height / 2 - metrics.reserve.height / 2) <= 1, + 'Visible text must be vertically centered within one layout pixel.', + ); + assert( + Math.abs(metrics.visible.x) <= 1 && metrics.visible.width <= metrics.reserve.width + 1, + `Visible text must retain leading alignment and fit the reserved width: ${JSON.stringify(metrics)}.`, + ); + return; + } + const reserve = await browser.$('~layout-stable-text-large-reserve'); + const visible = await browser.$('~layout-stable-text-small-visible'); + await expect(reserve).toExist(); + await expect(visible).toExist(); + const reservedBounds = await browser.getElementRect(await reserve.elementId); + const visibleBounds = await browser.getElementRect(await visible.elementId); + assert( - Math.abs(metrics.visible.x) <= 1 && metrics.visible.width <= metrics.reserve.width + 1, - `Visible text must retain leading alignment and fit the reserved width: ${JSON.stringify(metrics)}.`, + visibleBounds.height > 0 && visibleBounds.height < reservedBounds.height, + 'Visible text must keep its smaller intrinsic height.', ); - return; - } - const reserve = await browser.$('~layout-stable-text-large-reserve'); - const visible = await browser.$('~layout-stable-text-small-visible'); - await expect(reserve).toExist(); - await expect(visible).toExist(); - const reservedBounds = await browser.getElementRect(await reserve.elementId); - const visibleBounds = await browser.getElementRect(await visible.elementId); - - assert( - visibleBounds.height > 0 && visibleBounds.height < reservedBounds.height, - 'Visible text must keep its smaller intrinsic height.', - ); - const reservedCenter = reservedBounds.y + reservedBounds.height / 2; - const visibleCenter = visibleBounds.y + visibleBounds.height / 2; - assert(Math.abs(reservedCenter - visibleCenter) <= 1, 'Visible text must be vertically centered within one layout pixel.'); + const reservedCenter = reservedBounds.y + reservedBounds.height / 2; + const visibleCenter = visibleBounds.y + visibleBounds.height / 2; + assert(Math.abs(reservedCenter - visibleCenter) <= 1, 'Visible text must be vertically centered within one layout pixel.'); + }, }, }, }; diff --git a/packages/agentic/desktop-driver/references/test-integration.md b/packages/agentic/desktop-driver/references/test-integration.md index 82e57e60be..7af5bcf35e 100644 --- a/packages/agentic/desktop-driver/references/test-integration.md +++ b/packages/agentic/desktop-driver/references/test-integration.md @@ -67,6 +67,12 @@ For Storybook targets, the connection registers: `desktopExpect(expectation)` is available without Storybook. +Manifest `wdio.inputModes` optionally carries serializable endpoint-specific +classifications keyed by exact callback name. Each endpoint value is +`accessibility`, `physical`, or `both`. Storybook owns static extraction and +selection; Desktop Driver transports this metadata without maintaining a +component test inventory or changing target input permissions. + The runner resets the preview for each test, filters and shards a stable `storyId/testId` ordering, checks required capabilities, and classifies assertion, timeout, cancellation, skip, and infrastructure outcomes. diff --git a/packages/agentic/desktop-driver/src/index.ts b/packages/agentic/desktop-driver/src/index.ts index 5c66f111c9..3d590887f9 100644 --- a/packages/agentic/desktop-driver/src/index.ts +++ b/packages/agentic/desktop-driver/src/index.ts @@ -131,6 +131,8 @@ export type { RecoveryEvidence, } from './server/index.js'; export type { + DesktopStoryInputMode, + DesktopStoryInputModes, DesktopStoryManifest, DesktopStoryManifestEntry, StoryOrchestrator, diff --git a/packages/agentic/desktop-driver/src/storybook.ts b/packages/agentic/desktop-driver/src/storybook.ts index 4946ad486e..cea5dfc5ff 100644 --- a/packages/agentic/desktop-driver/src/storybook.ts +++ b/packages/agentic/desktop-driver/src/storybook.ts @@ -1,6 +1,9 @@ import type { DesktopStoryTests } from './authoring/storyTests.js'; import type { DesktopEndpoint } from './protocol/types.js'; +export type DesktopStoryInputMode = 'accessibility' | 'physical' | 'both'; +export type DesktopStoryInputModes = Readonly>>; + export type DesktopStoryManifestEntry = { id: string; name: string; @@ -9,7 +12,12 @@ export type DesktopStoryManifestEntry = { tags: readonly string[]; title: string; tests?: DesktopStoryTests; - wdio?: { digest: string; exportName: string; testNames?: readonly string[] }; + wdio?: { + digest: string; + exportName: string; + testNames?: readonly string[]; + inputModes?: Readonly>; + }; }; export type DesktopStoryManifest = { diff --git a/packages/agentic/native-core/references/contributing.md b/packages/agentic/native-core/references/contributing.md index abd2c0a237..6f95f4b6fc 100644 --- a/packages/agentic/native-core/references/contributing.md +++ b/packages/agentic/native-core/references/contributing.md @@ -50,6 +50,13 @@ lifecycle: STORYBOOK_SMOKE_STORY='native-*' yarn storybook smoke --macos --mode stories-and-tests ``` +Legacy Callout's Win32 input classifications live beside each named callback +as `{ inputModes: { win32: 'physical' }, run: async (context) => { ... } }`. +The consuming app discovers these annotations and generates its catalog. +Other endpoints remain unfiltered unless they explicitly select an input mode. +Keep native capability guards and assertions in the callback; annotations are +selection metadata, not evidence of native readiness. + Use `--windows` or `--win32` for the Windows endpoints. This traverses the full catalog before running the selected native tests. Add `--paper` to run the macOS Paper lane; a successful Paper build alone does diff --git a/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx b/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx index 2a4d0b6e8b..ba6d59fa1b 100644 --- a/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx +++ b/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx @@ -186,29 +186,32 @@ export const Default: Story = { tags: ['desktop-e2e'], render: (args) => , wdio: { - 'opens, light-dismisses, and recreates a native popup': async ({ browser, expect, platform, skip }) => { - const features = browser.capabilities['furn:features']; - if (!features?.physicalClick) { - skip('Popup lifecycle qualification requires physical pointer input.'); - return; - } - const status = await browser.$('~agentic-storybook-callout-status'); - for (let attempt = 0; attempt < 2; attempt++) { - await expect(status).toHaveText('Native window: Shown'); - // Only the AppKit provider enumerates the popup as a top-level window. - if (platform === 'macos') expect(await browser.getWindowHandles()).toHaveLength(2); - await status.click(); - if (platform === 'macos') { - await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { - timeout: 5000, - timeoutMsg: 'Expected dismissal to remove the native Callout window.', - }); + 'opens, light-dismisses, and recreates a native popup': { + inputModes: { win32: 'physical' }, + run: async ({ browser, expect, platform, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features?.physicalClick) { + skip('Popup lifecycle qualification requires physical pointer input.'); + return; } - await expect(status).toHaveText('Native window: Dismissed'); - if (attempt === 0) { - await (await browser.$('~agentic-storybook-callout-trigger')).click(); + const status = await browser.$('~agentic-storybook-callout-status'); + for (let attempt = 0; attempt < 2; attempt++) { + await expect(status).toHaveText('Native window: Shown'); + // Only the AppKit provider enumerates the popup as a top-level window. + if (platform === 'macos') expect(await browser.getWindowHandles()).toHaveLength(2); + await status.click(); + if (platform === 'macos') { + await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { + timeout: 5000, + timeoutMsg: 'Expected dismissal to remove the native Callout window.', + }); + } + await expect(status).toHaveText('Native window: Dismissed'); + if (attempt === 0) { + await (await browser.$('~agentic-storybook-callout-trigger')).click(); + } } - } + }, }, }, }; @@ -231,16 +234,19 @@ export const WindowCommands: Story = { render: (args) => , tags: ['desktop-e2e'], wdio: { - 'dismisses initially focused content on Escape': async ({ browser, expect, skip }) => { - const features = browser.capabilities['furn:features']; - if (!features?.physicalClick || !features.keyboard) { - skip('Escape dismissal requires an interactive desktop with keyboard input.'); - return; - } - const status = await browser.$('~agentic-storybook-callout-status'); - await expect(status).toHaveText('Native window: Shown'); - await browser.keys('\uE00C'); - await expect(status).toHaveText('Native window: Dismissed'); + 'dismisses initially focused content on Escape': { + inputModes: { win32: 'physical' }, + run: async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features?.physicalClick || !features.keyboard) { + skip('Escape dismissal requires an interactive desktop with keyboard input.'); + return; + } + const status = await browser.$('~agentic-storybook-callout-status'); + await expect(status).toHaveText('Native window: Shown'); + await browser.keys('\uE00C'); + await expect(status).toHaveText('Native window: Dismissed'); + }, }, }, parameters: { diff --git a/packages/agentic/storybook-desktop/README.md b/packages/agentic/storybook-desktop/README.md index 20fb98bac4..75a98a963b 100644 --- a/packages/agentic/storybook-desktop/README.md +++ b/packages/agentic/storybook-desktop/README.md @@ -27,11 +27,13 @@ The [reference index](references/README.md) routes platform, readiness, and troubleshooting tasks. Package agents should start from [AGENTS.md](AGENTS.md). -Applications can configure exact story/callback pairs with `wdio.testCases` -and an explicit click mode to partition accessibility and physical-input -coverage. See [integration](references/integration.md#exact-callback-selection) -for selection semantics; capability availability and execution verification -remain separate from selection. +Applications can select colocated, endpoint-specific test classifications with +`wdio.inputMode` and an explicit click policy. Static discovery validates every +eligible annotation and exposes the metadata without a second test inventory. +Exact story/callback selection with `wdio.testCases` remains supported. +See [story-tests](references/story-tests.md) for authoring and +[integration](references/integration.md#exact-callback-selection) for selection; +capability availability and execution verification remain separate. ## Responsibilities diff --git a/packages/agentic/storybook-desktop/config/transform-story-tests.cjs b/packages/agentic/storybook-desktop/config/transform-story-tests.cjs index aac57cf91a..5f45372a66 100644 --- a/packages/agentic/storybook-desktop/config/transform-story-tests.cjs +++ b/packages/agentic/storybook-desktop/config/transform-story-tests.cjs @@ -65,6 +65,42 @@ function collectCallbacks(value) { return callback; }; if (!value.isObjectExpression()) return [{ callback: requireCallback(value) }]; + const readCallback = (declaration) => { + if (!declaration.isObjectExpression()) return { callback: requireCallback(declaration) }; + const fields = new Map(); + for (const member of declaration.get('properties')) { + if (!member.isObjectProperty() || member.node.computed) { + fail(member, 'classified tests require static inputModes and run properties.'); + } + const name = keyName(member); + if (!['inputModes', 'run'].includes(name) || fields.has(name)) { + fail(member, 'classified tests require unique inputModes and run properties.'); + } + fields.set(name, unwrap(member.get('value'))); + } + const modes = fields.get('inputModes'); + const callback = fields.get('run'); + if (!modes?.isObjectExpression() || !callback) { + fail(declaration, 'classified tests require a literal inputModes object and an inline run function.'); + } + const inputModes = {}; + for (const member of modes.get('properties')) { + if (!member.isObjectProperty() || member.node.computed) { + fail(member, 'inputModes requires static endpoint properties.'); + } + const endpoint = keyName(member); + const mode = unwrap(member.get('value')); + if (!['macos', 'windows', 'win32'].includes(endpoint) || Object.hasOwn(inputModes, endpoint)) { + fail(member, 'inputModes requires unique desktop endpoints: macos, windows, or win32.'); + } + if (!mode.isStringLiteral() || !['accessibility', 'physical', 'both'].includes(mode.node.value)) { + fail(member, 'inputModes values must be accessibility, physical, or both literals.'); + } + inputModes[endpoint] = mode.node.value; + } + if (!Object.keys(inputModes).length) fail(modes, 'inputModes must classify at least one endpoint.'); + return { callback: requireCallback(callback), inputModes }; + }; const callbacks = []; const names = new Set(); for (const member of value.get('properties')) { @@ -75,7 +111,7 @@ function collectCallbacks(value) { if (!name || !name.trim()) fail(member, 'named tests require a non-empty name.'); if (names.has(name)) fail(member, `duplicate test name "${name}".`); names.add(name); - callbacks.push({ name, callback: requireCallback(unwrap(member.get('value'))) }); + callbacks.push({ name, ...readCallback(unwrap(member.get('value'))) }); } if (!callbacks.length) fail(value, 'a named test collection must contain at least one test.'); return callbacks; diff --git a/packages/agentic/storybook-desktop/config/transform-story-tests.d.cts b/packages/agentic/storybook-desktop/config/transform-story-tests.d.cts index 8444ed0b64..d37df517e3 100644 --- a/packages/agentic/storybook-desktop/config/transform-story-tests.d.cts +++ b/packages/agentic/storybook-desktop/config/transform-story-tests.d.cts @@ -1,10 +1,12 @@ import type { NodePath, PluginObj, types } from '@babel/core'; +import type { DesktopStoryInputModes } from '@fluentui-react-native/desktop-driver'; declare function storyTests(): PluginObj; declare namespace storyTests { type Callback = { name?: string; + inputModes?: DesktopStoryInputModes; callback: NodePath; }; type StoryTest = { diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index 5e1f9acfb4..aa7717eb7b 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -59,12 +59,16 @@ callback execution, including grouped smoke. Omitted selections preserve the existing behavior; legacy single callbacks use the name `default`. Declared pairs that do not identify an executable callback fail selection. -An app can own a reviewed input-mode catalog and select different pairs with -explicit `wdio.clickMode: 'accessibility'` or `'physical'`. The shared runner -does not infer supported modes from callback names. Consumers must validate -catalog completeness and actual execution; declaring a test does not prove -that it ran. The Win32 Storybook app demonstrates this with separate CI lanes, -shared callbacks in both, and explicit reporting of its existing quarantine. +For input lanes, set `wdio.inputMode: 'accessibility'` or `'physical'` alongside +the matching `wdio.clickMode`. The runner selects the exact per-test endpoint +annotations extracted from story source, including `both` in each lane. +Every eligible callback must declare the target endpoint before other filters +are applied. Modes are never inferred from names or assertions. + +The Win32 app generates its catalog from complete `test --list` discovery and +owns only the explicit quarantine registry. Consumers must still verify actual +execution: classification does not prove a callback ran or grant input +capabilities. Existing exact-pair selection intersects input-mode selection. CLI reports record the selected endpoint and configured renderer. Smoke and callback runs use canonical `run.json` documents with `kind: 'tests'`; command diff --git a/packages/agentic/storybook-desktop/references/story-tests.md b/packages/agentic/storybook-desktop/references/story-tests.md index 1fd21d7e2e..cc8563276b 100644 --- a/packages/agentic/storybook-desktop/references/story-tests.md +++ b/packages/agentic/storybook-desktop/references/story-tests.md @@ -51,6 +51,33 @@ closures over imported helpers, args, or render state fail extraction with source context. The older single `wdio: async (context) => ...` form remains supported as the `default` test. Use `WdioStory>` and import `WdioStory` with `import type`. +Named tests can colocate endpoint-specific input requirements: + +```tsx +wdio: { + 'supports activation': { + inputModes: { win32: 'both' }, + run: async ({ browser, expect }) => { + const button = await browser.$('~save-button'); + await button.click(); + await expect(button).toBeEnabled(); + }, + }, +} +``` + +Descriptors require exactly `inputModes` and an inline `run` function. +`inputModes` is a nonempty literal map of `macos`, `windows`, or `win32` to +`accessibility`, `physical`, or `both`; dynamic values and duplicate or unknown +fields fail extraction. An endpoint annotation applies only when that endpoint +selects an input lane. Set `wdio.inputMode` to `accessibility` or `physical` in +the consuming app configuration: every eligible callback must then declare that +endpoint before any story, tag, or test filtering. `both` participates in each +lane. Selection does not grant native input authority or replace capability +checks. Existing bare callbacks remain supported without input-mode selection. +`test --list` includes discovered per-test `inputModes`; the shared harness +does not maintain an inventory of component test names. + Literal dynamic imports of Node-compatible helpers inside the callback resolve from the original story file. The shared Babel config removes callbacks before Metro resolves their dependencies. diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index 45b2a9c4df..d50be41c6a 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -219,6 +219,45 @@ function makeConfig(platformOptions = {}) { } describe('DesktopStorybookCli', () => { + test('discovers classifications, selects both lanes, and rejects unclassified callbacks before filters hide them', async () => { + const output = { write: jest.fn() }; + const config = makeDesktopStorybookConfig({ projectRoot: storybookRoot }); + const inputModes = { + semantics: { win32: 'both' as const }, + pointer: { win32: 'physical' as const }, + action: { win32: 'accessibility' as const }, + }; + const manifest = { + endpoint: 'win32' as const, + schemaVersion: 1 as const, + platformManifestDigest: 'digest', + portablePlanDigest: 'portable', + entries: [ + { + id: 'button--default', + name: 'Default', + title: 'Button', + sourcePath: 'button.stories.tsx', + packageName: 'fixture', + tags: [], + wdio: { exportName: 'Default', digest: 'callback', testNames: ['semantics', 'pointer', 'action'], inputModes }, + }, + ], + }; + const cli = new DesktopStorybookCli(config, { createStoryManifest: async () => manifest, output }); + await cli.test('win32', { list: true }); + expect(JSON.parse(output.write.mock.calls[0][0])[0].inputModes).toEqual(inputModes); + for (const inputMode of ['accessibility', 'physical'] as const) { + output.write.mockClear(); + await cli.test('win32', { list: true, inputMode }); + expect(JSON.parse(output.write.mock.calls[0][0])[0].tests).toEqual(['semantics', inputMode === 'physical' ? 'pointer' : 'action']); + } + manifest.entries[0].wdio.testNames.push('unclassified'); + await expect(cli.test('win32', { list: true, inputMode: 'physical', test: 'pointer' })).rejects.toThrow( + /Unclassified win32 wdio callback: button--default \/ unclassified/, + ); + }); + test.each(['plain', 'json', 'ndjson'] as const)( 'lists exact callback pairs without a story/name cross-product with the %s reporter', async (reporterMode) => { diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts index 108294e9e9..4db2d7569e 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -343,11 +343,19 @@ export class DesktopStorybookCli { throw new Error('No executable wdio story tests matched the requested selection.'); } if (options.list) { - const value = selected.map(({ id, sourcePath, wdio }) => ({ - id, - sourcePath, - ...(wdio?.testNames ? { tests: wdio.testNames.filter((name) => matchesWdioCase(id, name, settings)) } : {}), - })); + const value = selected.map(({ id, sourcePath, wdio }) => { + const tests = (wdio?.testNames ?? ['default']).filter((name) => + matchesWdioCase(id, name, settings, wdio?.inputModes?.[name]?.[platform]), + ); + return { + id, + sourcePath, + ...(wdio?.testNames ? { tests } : {}), + ...(wdio?.inputModes + ? { inputModes: Object.fromEntries(Object.entries(wdio.inputModes).filter(([name]) => tests.includes(name))) } + : {}), + }; + }); if (this.reporterMode === 'json' || this.reporterMode === 'ndjson') this.writeStructured(value); else this.output.write(`${JSON.stringify(value, null, 2)}\n`); return; diff --git a/packages/agentic/storybook-desktop/src/config/transformStoryTests.test.ts b/packages/agentic/storybook-desktop/src/config/transformStoryTests.test.ts index 9aceedaba9..396fca009d 100644 --- a/packages/agentic/storybook-desktop/src/config/transformStoryTests.test.ts +++ b/packages/agentic/storybook-desktop/src/config/transformStoryTests.test.ts @@ -72,6 +72,18 @@ describe('native Storybook test stripping', () => { expect(output).not.toMatch(/first test|second test|node:assert|node:fs|wdio/); }); + test.each(['macos', 'windows', 'win32'])('strips classified callbacks and metadata from the native bundle on %s', (platform) => { + const output = transform( + `import { View } from 'react-native'; + export const Example = { render: () => , wdio: { + 'classified test': { inputModes: { win32: 'both' }, run: async () => { await import('node:fs'); } }, + } };`, + platform, + ); + expect(output).toContain('keep-native'); + expect(output).not.toMatch(/classified test|inputModes|node:fs|wdio/); + }); + test('leaves non-story files untouched', () => { const output = transform(`export const configuration = { wdio: () => 'not-a-story' };`, 'macos', 'configuration.ts'); expect(output).toContain('wdio'); diff --git a/packages/agentic/storybook-desktop/src/config/wdio.test.ts b/packages/agentic/storybook-desktop/src/config/wdio.test.ts index 8b88e2c16e..b4299ad6b3 100644 --- a/packages/agentic/storybook-desktop/src/config/wdio.test.ts +++ b/packages/agentic/storybook-desktop/src/config/wdio.test.ts @@ -25,6 +25,12 @@ describe('Storybook-owned wdio configuration', () => { expect(() => resolveWdioOptions({ tag: ' ' })).toThrow('wdio.tag'); }); + test('accepts explicit input lanes and rejects unknown modes', () => { + expect(resolveWdioOptions({ inputMode: 'physical' }).inputMode).toBe('physical'); + // @ts-expect-error Input modes are intentionally a closed set. + expect(() => resolveWdioOptions({ inputMode: 'automatic' })).toThrow('wdio.inputMode'); + }); + test('snapshots exact callback identities and rejects empty or duplicate selections', () => { const testCases = [{ storyId: 'button--default', testName: 'activation' }]; const settings = resolveWdioOptions({ testCases }); diff --git a/packages/agentic/storybook-desktop/src/config/wdio.ts b/packages/agentic/storybook-desktop/src/config/wdio.ts index f688418d5c..b29556b392 100644 --- a/packages/agentic/storybook-desktop/src/config/wdio.ts +++ b/packages/agentic/storybook-desktop/src/config/wdio.ts @@ -7,6 +7,7 @@ export type DesktopStorybookWdioOptions = { tag?: string; test?: string; testCases?: readonly { storyId: string; testName: string }[]; + inputMode?: 'accessibility' | 'physical'; timeoutMs?: number; }; @@ -23,6 +24,9 @@ export function resolveWdioOptions( if (!['auto', 'accessibility', 'physical'].includes(clickMode)) { throw new TypeError('wdio.clickMode must be auto, accessibility, or physical.'); } + if (options.inputMode !== undefined && !['accessibility', 'physical'].includes(options.inputMode)) { + throw new TypeError('wdio.inputMode must be accessibility or physical.'); + } for (const key of ['story', 'tag', 'test'] as const) { if (options[key] !== undefined && (typeof options[key] !== 'string' || !options[key].trim())) { throw new TypeError(`wdio.${key} must be a non-empty string.`); diff --git a/packages/agentic/storybook-desktop/src/driver/storyManifest.test.ts b/packages/agentic/storybook-desktop/src/driver/storyManifest.test.ts index 2497d6b682..f3919dcc26 100644 --- a/packages/agentic/storybook-desktop/src/driver/storyManifest.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/storyManifest.test.ts @@ -52,6 +52,10 @@ describe('createDesktopStoryManifest', () => { const named = await manifestForSource(`export const Default = { wdio: { 'first case': () => console.log('hidden-body'), 'second case': () => {}, } };`); + const classified = await manifestForSource(`export const Default = { wdio: { + 'first case': { inputModes: { win32: 'both' }, run: () => console.log('hidden-body') }, + 'second case': { inputModes: { win32: 'physical' }, run: () => {} }, + } };`); expect(before.entries[0]).not.toHaveProperty('wdio'); expect(before.platformManifestDigest).toBe( createHash('sha256') @@ -69,6 +73,14 @@ describe('createDesktopStoryManifest', () => { expect(named.entries[0].wdio).toMatchObject({ exportName: 'Default', testNames: ['first case', 'second case'] }); expect(named.portablePlanDigest).toBe(before.portablePlanDigest); expect(JSON.stringify(named)).not.toContain('hidden-body'); + expect(classified.entries[0].wdio).toMatchObject({ + testNames: ['first case', 'second case'], + inputModes: { 'first case': { win32: 'both' }, 'second case': { win32: 'physical' } }, + }); + expect(classified.portablePlanDigest).toBe(named.portablePlanDigest); + expect(classified.platformManifestDigest).not.toBe(named.platformManifestDigest); + expect(classified.entries[0].wdio!.digest).not.toBe(named.entries[0].wdio!.digest); + expect(JSON.stringify(classified)).not.toContain('hidden-body'); }); test('fails instead of silently dropping callbacks from excluded or unrecognized story exports', async () => { diff --git a/packages/agentic/storybook-desktop/src/driver/storyManifest.ts b/packages/agentic/storybook-desktop/src/driver/storyManifest.ts index c1f0a039f8..eee939b9e0 100644 --- a/packages/agentic/storybook-desktop/src/driver/storyManifest.ts +++ b/packages/agentic/storybook-desktop/src/driver/storyManifest.ts @@ -86,6 +86,7 @@ export async function createDesktopStoryManifest( for (const [exportName, staticStory] of stories) { const wdio = wdioTests.get(exportName); const testNames = wdio?.tests.flatMap(({ name }) => (name === undefined ? [] : [name])); + const inputModes = wdio?.tests.flatMap(({ name, inputModes }) => (inputModes ? [[name ?? 'default', inputModes] as const] : [])); const tests = readDesktopStoryTests( extractDesktopStoryTests(csf._storyAnnotations?.[exportName]?.parameters, sourceFile, staticStory.id) ?? staticStory.parameters?.desktopDriver, @@ -100,7 +101,16 @@ export async function createDesktopStoryManifest( tags: [...new Set([...(csf.meta?.tags ?? []), ...(staticStory.tags ?? []), 'story'])].sort(), title: csf.meta?.title ?? staticStory.id.split('--')[0], ...(tests ? { tests } : {}), - ...(wdio ? { wdio: { digest: wdio.digest, exportName, ...(testNames?.length ? { testNames } : {}) } } : {}), + ...(wdio + ? { + wdio: { + digest: wdio.digest, + exportName, + ...(testNames?.length ? { testNames } : {}), + ...(inputModes?.length ? { inputModes: Object.fromEntries(inputModes) } : {}), + }, + } + : {}), }); } diff --git a/packages/agentic/storybook-desktop/src/testing/extractWdioTests.test.ts b/packages/agentic/storybook-desktop/src/testing/extractWdioTests.test.ts index f73abf0e39..6f0ac00e73 100644 --- a/packages/agentic/storybook-desktop/src/testing/extractWdioTests.test.ts +++ b/packages/agentic/storybook-desktop/src/testing/extractWdioTests.test.ts @@ -9,6 +9,35 @@ import { extractWdioTests } from './extractWdioTests.js'; const sourceFile = path.resolve(__dirname, 'fixture.stories.tsx'); describe('extractWdioTests', () => { + test('extracts endpoint input modes alongside unchanged callbacks and fingerprints classification changes', async () => { + const source = `export const Example = { wdio: { + named: { inputModes: { win32: 'both', macos: 'physical' }, run: async ({ expect }) => { expect(true); } }, + legacy: async () => {}, + } };`; + const extracted = extractWdioTests(source, sourceFile).get('Example')!; + expect(extracted.tests[0].inputModes).toEqual({ win32: 'both', macos: 'physical' }); + expect(extracted.tests[1].inputModes).toBeUndefined(); + const assertion = jest.fn(); + await vm.runInNewContext(`(${extracted.tests[0].code})`)({ expect: assertion }); + expect(assertion).toHaveBeenCalledWith(true); + const changed = extractWdioTests(source.replace("win32: 'both'", "win32: 'physical'"), sourceFile).get('Example')!; + expect(changed.tests[0].code).toBe(extracted.tests[0].code); + expect(changed.digest).not.toBe(extracted.digest); + }); + + test.each([ + [`{ inputModes: {}, run: () => {} }`, /at least one endpoint/], + [`{ inputModes: { android: 'physical' }, run: () => {} }`, /desktop endpoints/], + [`{ inputModes: { win32: 'automatic' }, run: () => {} }`, /values must be/], + [`{ inputModes: { win32: mode }, run: () => {} }`, /values must be/], + [`{ inputModes: { ...shared }, run: () => {} }`, /static endpoint/], + [`{ inputModes: { win32: 'both', win32: 'physical' }, run: () => {} }`, /unique desktop endpoints/], + [`{ inputModes: { win32: 'both' }, run: helper }`, /inline/], + [`{ inputModes: { win32: 'both' }, run: () => {}, extra: true }`, /unique inputModes and run/], + ])('rejects invalid or dynamic classified test declarations %s', (declaration, message) => { + expect(() => extractWdioTests(`export const Example = { wdio: { named: ${declaration} } };`, sourceFile)).toThrow(message); + }); + test('extracts named test functions in declaration order with independent local scopes', async () => { const result = extractWdioTests( ` @@ -41,7 +70,7 @@ describe('extractWdioTests', () => { ["{ ['name']: () => {} }", /static properties/], ['{ named() {} }', /static properties/], ['{ named: helper }', /inline/], - ['{ named: { nested: () => {} } }', /inline/], + ['{ named: { nested: () => {} } }', /classified tests require/], ])('rejects invalid named collections %s', (value, message) => { expect(() => extractWdioTests(`export const Example = { wdio: ${value} };`, sourceFile)).toThrow(message); }); diff --git a/packages/agentic/storybook-desktop/src/testing/extractWdioTests.ts b/packages/agentic/storybook-desktop/src/testing/extractWdioTests.ts index 493d5239bf..c53fb3bd04 100644 --- a/packages/agentic/storybook-desktop/src/testing/extractWdioTests.ts +++ b/packages/agentic/storybook-desktop/src/testing/extractWdioTests.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { transformSync, type NodePath, type types } from '@babel/core'; +import type { DesktopStoryInputModes } from '@fluentui-react-native/desktop-driver'; import storyTests from '../../config/transform-story-tests.cjs'; @@ -8,7 +9,7 @@ const { collectWdioTests, compileWdioTest } = storyTests; export type ExtractedWdioStory = { digest: string; - tests: readonly { name?: string; code: string }[]; + tests: readonly { name?: string; code: string; inputModes?: DesktopStoryInputModes }[]; }; export function extractWdioTests(code: string, sourceFile: string): ReadonlyMap { @@ -24,8 +25,9 @@ export function extractWdioTests(code: string, sourceFile: string): ReadonlyMap< visitor: { Program(program: NodePath) { for (const [exportName, { callbacks, value }] of collectWdioTests(program)) { - const compiled = callbacks.map(({ callback, name }) => ({ + const compiled = callbacks.map(({ callback, name, inputModes }) => ({ ...(name === undefined ? {} : { name }), + ...(inputModes === undefined ? {} : { inputModes }), code: compileWdioTest(callback, sourceFile, code), })); const source = code.slice(value.node.start!, value.node.end!); diff --git a/packages/agentic/storybook-desktop/src/testing/index.ts b/packages/agentic/storybook-desktop/src/testing/index.ts index e5df94e7d3..c447a16c81 100644 --- a/packages/agentic/storybook-desktop/src/testing/index.ts +++ b/packages/agentic/storybook-desktop/src/testing/index.ts @@ -1 +1 @@ -export type { WdioStory, WdioStoryContext, WdioStoryTest, WdioStoryTests } from './types.js'; +export type { ClassifiedWdioStoryTest, WdioStory, WdioStoryContext, WdioStoryTest, WdioStoryTests } from './types.js'; diff --git a/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts b/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts index bc02972a32..382101f8f8 100644 --- a/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts +++ b/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { DesktopStoryManifest, DesktopStoryManifestEntry } from '@fluentui-react-native/desktop-driver'; +import type { DesktopStoryInputMode, DesktopStoryManifest, DesktopStoryManifestEntry } from '@fluentui-react-native/desktop-driver'; import type { DesktopArtifact } from '@fluentui-react-native/desktop-driver/authoring'; import { ArtifactManager, captureDesktopFailureEvidence } from '@fluentui-react-native/desktop-driver/artifacts'; import { connectDesktopWebdriver } from '@fluentui-react-native/desktop-driver/wdio'; @@ -57,6 +57,17 @@ export function selectWdioStories( manifest: DesktopStoryManifest, options: DesktopStorybookWdioOptions, ): readonly DesktopStoryManifestEntry[] { + if (options.inputMode !== undefined) { + for (const entry of manifest.entries) { + if (!entry.wdio) continue; + for (const name of entry.wdio.testNames ?? ['default']) { + const mode = entry.wdio.inputModes?.[name]?.[manifest.endpoint]; + if (!mode || !['accessibility', 'physical', 'both'].includes(mode)) { + throw new Error(`Unclassified ${manifest.endpoint} wdio callback: ${entry.id} / ${name}.`); + } + } + } + } for (const test of options.testCases ?? []) { const entry = manifest.entries.find(({ id }) => id === test.storyId); if (!entry?.wdio || !(entry.wdio.testNames ?? ['default']).includes(test.testName)) { @@ -66,7 +77,11 @@ export function selectWdioStories( return manifest.entries .filter((entry) => entry.wdio && (!options.story || path.matchesGlob(entry.id, options.story))) .filter((entry) => !options.tag || entry.tags.includes(options.tag)) - .filter((entry) => (entry.wdio?.testNames ?? ['default']).some((name) => matchesWdioCase(entry.id, name, options))) + .filter((entry) => + (entry.wdio?.testNames ?? ['default']).some((name) => + matchesWdioCase(entry.id, name, options, entry.wdio?.inputModes?.[name]?.[manifest.endpoint]), + ), + ) .sort((left, right) => left.id.localeCompare(right.id)); } @@ -86,7 +101,7 @@ export async function runWdioStoryTests( throw new Error(`Executable test source changed for ${entry.id}; regenerate the Storybook manifest.`); } return extracted.tests - .filter(({ name }) => matchesWdioCase(entry.id, name ?? 'default', settings)) + .filter(({ name, inputModes }) => matchesWdioCase(entry.id, name ?? 'default', settings, inputModes?.[options.platform])) .map(({ name, code }) => ({ entry, code, testName: name })); }); if (cases.length === 0) throw new Error('No executable wdio tests matched the requested selection.'); @@ -347,10 +362,16 @@ export function matchesWdioTest(name: string, pattern?: string): boolean { return pattern === undefined || path.matchesGlob(name, pattern); } -export function matchesWdioCase(storyId: string, testName: string, options: DesktopStorybookWdioOptions): boolean { +export function matchesWdioCase( + storyId: string, + testName: string, + options: DesktopStorybookWdioOptions, + inputMode?: DesktopStoryInputMode, +): boolean { return ( matchesWdioTest(testName, options.test) && - (options.testCases === undefined || options.testCases.some((entry) => entry.storyId === storyId && entry.testName === testName)) + (options.testCases === undefined || options.testCases.some((entry) => entry.storyId === storyId && entry.testName === testName)) && + (options.inputMode === undefined || inputMode === 'both' || inputMode === options.inputMode) ); } diff --git a/packages/agentic/storybook-desktop/src/testing/types.ts b/packages/agentic/storybook-desktop/src/testing/types.ts index 8077303d07..5462345606 100644 --- a/packages/agentic/storybook-desktop/src/testing/types.ts +++ b/packages/agentic/storybook-desktop/src/testing/types.ts @@ -1,5 +1,5 @@ import type { DesktopWebdriverSession } from '@fluentui-react-native/desktop-driver/wdio'; -import type { DesktopEndpoint } from '@fluentui-react-native/desktop-driver'; +import type { DesktopEndpoint, DesktopStoryInputModes } from '@fluentui-react-native/desktop-driver'; import type { expect } from 'expect-webdriverio'; export type WdioStoryContext = { @@ -12,7 +12,11 @@ export type WdioStoryContext = { }; export type WdioStoryTest = (context: WdioStoryContext) => void | Promise; -export type WdioStoryTests = Readonly>; +export type ClassifiedWdioStoryTest = { + inputModes: DesktopStoryInputModes; + run: WdioStoryTest; +}; +export type WdioStoryTests = Readonly>; /** Wrap StoryObj, importing this type only; callbacks execute in Node, not React Native. */ export type WdioStory = Omit & { diff --git a/packages/agentic/storybook-desktop/src/testing/wdio.types.test.ts b/packages/agentic/storybook-desktop/src/testing/wdio.types.test.ts index 10a341502e..620473aff9 100644 --- a/packages/agentic/storybook-desktop/src/testing/wdio.types.test.ts +++ b/packages/agentic/storybook-desktop/src/testing/wdio.types.test.ts @@ -52,9 +52,29 @@ const invalidArgs: WdioStory<{ args: { disabled: boolean } }> = { args: { disabled: 'false' }, }; +const classified: WdioStory = { + wdio: { + 'classified test': { + inputModes: { win32: 'both', macos: 'physical' }, + run: async ({ browser, platform }) => { + await browser.$('~button'); + const endpoint: WdioStoryContext['platform'] = platform; + // @ts-expect-error Classified callbacks retain the typed native browser. + await browser.inventedCommand(endpoint); + }, + }, + 'invalid mode': { + // @ts-expect-error Input modes remain a closed set. + inputModes: { win32: 'automatic' }, + run: () => {}, + }, + }, +}; + test('exposes a type-only inline callback contract', () => { expect(typeof story.wdio).toBe('function'); expect(typeof wrapped.wdio).toBe('function'); expect(typeof named.wdio).toBe('object'); expect(invalidArgs.args).toBeDefined(); + expect(typeof classified.wdio).toBe('object'); }); From a32c0db6690dafadc37c0cce675dbbe85623462d Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Thu, 5 Nov 2026 18:56:04 -0800 Subject: [PATCH 8/8] Include the Win32 quarantine source in the Storybook TypeScript project Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- apps/storybook/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index 2dd82e084c..54c5ddff47 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -12,7 +12,7 @@ "composite": true, "tsBuildInfoFile": "node_modules/.cache/tsconfig.tsbuildinfo" }, - "include": ["index.js", "src", "app.json", "desktop.config.mts", "input-test-modes.mts", "input-tests.win32.json"], + "include": ["index.js", "src", "app.json", "desktop.config.mts", "input-test-modes.mts", "input-test-quarantines.win32.json"], "exclude": ["node_modules", "dist", ".cache"], "references": [ {