diff --git a/.changeset/bright-input-lanes.md b/.changeset/bright-input-lanes.md new file mode 100644 index 0000000000..f97821d5c3 --- /dev/null +++ b/.changeset/bright-input-lanes.md @@ -0,0 +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 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/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3117b5b6eb..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,17 +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: 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: Smoke test Win32 Storybook + - name: Smoke test Win32 Storybook (${{ matrix.input-mode }}) + id: smoke + working-directory: apps/storybook + env: + 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 @@ -652,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..2453402823 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -42,6 +42,52 @@ 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. 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 +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` 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 +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 b90f1bd39f..a40c2db977 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_quarantines: input-test-quarantines.win32.json + win32_input_coverage: input-test-modes.mts config_adapter: src/main.ts app_entry: index.js app_shell: src/StorybookApp.tsx @@ -18,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 -- @@ -31,11 +38,13 @@ 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 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 @@ -45,6 +54,9 @@ 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 + generate_win32_input_catalog: yarn input-coverage generate + 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 673ea7ac2b..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,6 +25,7 @@ export default defineDesktopConfig({ reporting: { artifactsRoot: './artifacts/desktop' }, suites: { stories: { kind: 'csf-wdio' } }, integration: storybook({ + 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..0b77ab1754 --- /dev/null +++ b/apps/storybook/input-test-modes.mts @@ -0,0 +1,271 @@ +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 quarantineCatalog from './input-test-quarantines.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 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; +} + +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, quarantineDeclarations: unknown = quarantineCatalog.quarantines): InputTest[] { + const storyIds = new Set(); + const tests = array(value).flatMap((entry) => { + const story = object(entry); + 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}.`); + } + 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(quarantineDeclarations)) { + 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: readonly InputTest[]): InputTest[] { + return catalog.filter((test) => test.mode === mode || test.mode === 'both'); +} + +export function inputModeOptions(): { + clickMode?: InputMode; + inputMode?: InputMode; +} { + const mode = readInputMode(); + return mode ? { clickMode: mode, inputMode: mode } : {}; +} + +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 !== 'tests' || + 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 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.'); + } + 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(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 (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 (action === 'begin') { + writeGeneratedCatalog(catalog); + mkdirSync(coverageRoot, { recursive: true }); + writeFileSync( + snapshotPath, + `${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 (action === 'verify') { + const snapshot = object(JSON.parse(readFileSync(snapshotPath, 'utf8'))); + if ( + snapshot.mode !== mode || + snapshot.sourceCommit !== sourceCommit() || + snapshot.catalogDigest !== catalogDigest(catalog) || + 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, 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); + } +} + +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..53b256d39a --- /dev/null +++ b/apps/storybook/input-test-modes.test.mts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { parseInputCatalog, readInputMode, selectedInputTests, verifyInputReport } from './input-test-modes.mts'; + +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(discovery, quarantines)); + 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: 'tests', + 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(discovery, quarantines); + assert.deepEqual( + selectedInputTests('accessibility', parsed).map(({ testName }) => testName), + ['semantics', 'native action'], + ); + assert.deepEqual( + selectedInputTests('physical', parsed).map(({ testName }) => testName), + ['semantics', 'keyboard', 'semantics', 'quarantined'], + ); + assert.throws(() => readInputMode('automatic'), /STORYBOOK_INPUT_MODE/); +}); + +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'], + ); +}); + +test('accepts complete execution and reports the existing exact quarantine as unvalidated', () => { + const parsed = parseInputCatalog(discovery, quarantines); + 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(discovery, quarantines)); + 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(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/); + 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/); + 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/); + assert.throws(() => verifyInputReport({ ...report('physical'), work: [{ status: 'not-run' }] }, selected, 0), /did not finish/); +}); 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/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..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"], + "include": ["index.js", "src", "app.json", "desktop.config.mts", "input-test-modes.mts", "input-test-quarantines.win32.json"], "exclude": ["node_modules", "dist", ".cache"], "references": [ { 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-runtime/README.md b/packages/agentic/storybook-desktop-runtime/README.md index e9996cbaa2..454629bf3d 100644 --- a/packages/agentic/storybook-desktop-runtime/README.md +++ b/packages/agentic/storybook-desktop-runtime/README.md @@ -9,6 +9,11 @@ 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. +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) 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..2161c4db45 100644 --- a/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md +++ b/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md @@ -15,6 +15,15 @@ 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. + +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 new file mode 100644 index 0000000000..16bd0caa85 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.test.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +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', () => ({ + 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 })); +jest.mock('./DesktopStoryRoot', () => ({ DesktopStoryRoot: ({ children }: { children: React.ReactNode }) => children })); + +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 ( + + + + ); +} + +// 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')); + 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(); +}, 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( + + + + 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/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-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/README.md b/packages/agentic/storybook-desktop/README.md index a511ed1ffc..75a98a963b 100644 --- a/packages/agentic/storybook-desktop/README.md +++ b/packages/agentic/storybook-desktop/README.md @@ -27,6 +27,14 @@ The [reference index](references/README.md) routes platform, readiness, and troubleshooting tasks. Package agents should start from [AGENTS.md](AGENTS.md). +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 - This package owns Storybook-specific configuration, story extraction, 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 d3307b5cc0..aa7717eb7b 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -50,6 +50,30 @@ 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. + +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 +lifecycles use `kind: 'lifecycle'`. + ## Responsibilities This package owns Storybook-specific CLI lifecycle, static story manifests, 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 c94989b0b2..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,92 @@ 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) => { + 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, reporterMode }); + await cli.test('win32', { list: true }); + const actual = JSON.parse(output.write.mock.calls[0][0]); + expect(reporterMode === 'ndjson' ? actual.result : actual).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 5a199c7cda..4db2d7569e 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -46,7 +46,7 @@ import { 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 & { @@ -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) => matchesWdioTest(name, settings.test)) } : {}), - })); + 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/cli/createDesktopStorybookCommand.ts b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts index 263ab8e307..41e521553b 100644 --- a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts +++ b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts @@ -269,10 +269,19 @@ 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 + : undefined; 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..af549c5a1a 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,51 @@ 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 { + 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'], + { + 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], 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 dd816c2bfc..b4299ad6b3 100644 --- a/packages/agentic/storybook-desktop/src/config/wdio.test.ts +++ b/packages/agentic/storybook-desktop/src/config/wdio.test.ts @@ -24,4 +24,20 @@ describe('Storybook-owned wdio configuration', () => { expect(() => resolveWdioOptions({ story: '' })).toThrow('wdio.story'); 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 }); + 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..b29556b392 100644 --- a/packages/agentic/storybook-desktop/src/config/wdio.ts +++ b/packages/agentic/storybook-desktop/src/config/wdio.ts @@ -6,6 +6,8 @@ export type DesktopStorybookWdioOptions = { story?: string; tag?: string; test?: string; + testCases?: readonly { storyId: string; testName: string }[]; + inputMode?: 'accessibility' | 'physical'; timeoutMs?: number; }; @@ -22,10 +24,28 @@ 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.`); } } - 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/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 52cacc56c1..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,10 +57,31 @@ 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)) { + 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, entry.wdio?.inputModes?.[name]?.[manifest.endpoint]), + ), + ) .sort((left, right) => left.id.localeCompare(right.id)); } @@ -80,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 }) => matchesWdioTest(name ?? 'default', settings.test)) + .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.'); @@ -341,6 +362,19 @@ export function matchesWdioTest(name: string, pattern?: string): boolean { return pattern === undefined || path.matchesGlob(name, pattern); } +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.inputMode === undefined || inputMode === 'both' || inputMode === options.inputMode) + ); +} + function csfTestId(storyId: string, testName?: string): string { return `csf-${createHash('sha256') .update(JSON.stringify([storyId, testName ?? 'default'])) 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'); }); 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