diff --git a/.changeset/components-desktop-it.md b/.changeset/components-desktop-it.md new file mode 100644 index 0000000000..a2dde1a2b6 --- /dev/null +++ b/.changeset/components-desktop-it.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/components': patch +--- + +Declare every component and primitive story test with `desktopIt`, so each case states the input it needs, the endpoints it runs on, and its known gaps instead of skipping at run time, and remove the internal `requireDesktopFocus` story helper. diff --git a/.changeset/desktop-driver-ci-input-lanes.md b/.changeset/desktop-driver-ci-input-lanes.md new file mode 100644 index 0000000000..b1984ac91f --- /dev/null +++ b/.changeset/desktop-driver-ci-input-lanes.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/desktop-driver': patch +--- + +Describe the Storybook CI input lanes: the hosted Windows and Win32 jobs run the accessible lane, and an advisory Win32 job runs the physical lane. diff --git a/.changeset/storybook-desktop-channel-http-close.md b/.changeset/storybook-desktop-channel-http-close.md new file mode 100644 index 0000000000..eb22866e4b --- /dev/null +++ b/.changeset/storybook-desktop-channel-http-close.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +--- + +Close the upstream Storybook HTTP listener and its open connections when the legacy channel service stops, so lingering keep-alive or reconnect sockets cannot keep the channel process alive past its shutdown budget. diff --git a/.changeset/storybook-runtime-win32-preview.md b/.changeset/storybook-runtime-win32-preview.md new file mode 100644 index 0000000000..e83d056a3a --- /dev/null +++ b/.changeset/storybook-runtime-win32-preview.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/storybook-desktop-runtime': patch +--- + +Scroll the Win32 story preview inside its viewport and remount the preview for each navigation generation, so story tests start from a fresh, reachable preview. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6e324b297f..337071ba23 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -445,7 +445,7 @@ jobs: } - name: Smoke test Windows Storybook - run: yarn storybook smoke --windows --mode stories-and-tests + run: yarn storybook smoke --windows --mode stories-and-tests --input accessible working-directory: apps/storybook - name: Upload Windows Storybook artifacts @@ -510,7 +510,7 @@ jobs: if-no-files-found: warn win32-storybook: - name: Win32 Storybook PR + name: Win32 Storybook PR (accessible) runs-on: windows-latest timeout-minutes: 60 steps: @@ -537,14 +537,58 @@ jobs: disabled-input-features: keyboard,physicalClick,wheel - name: Smoke test Win32 Storybook - run: yarn storybook smoke --win32 --mode stories-and-tests + run: yarn storybook smoke --win32 --mode stories-and-tests --input accessible working-directory: apps/storybook - 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_accessible_${{ github.run_id }}_${{ github.run_attempt }} + path: | + apps/storybook/artifacts/win32 + apps/storybook/artifacts/desktop + ${{ steps.desktop-driver.outputs.doctor-path }} + ${{ steps.desktop-driver.outputs.cache-root }}/v1/artifacts/**/build.log + ${{ steps.desktop-driver.outputs.cache-root }}/v1/diagnostics/** + if-no-files-found: warn + + # An advisory pilot outside the aggregate PR check: hosted physical input is not yet qualified (#4349). + win32-storybook-physical: + name: Win32 Storybook PR (physical) + runs-on: windows-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up toolchain + uses: microsoft/react-native-test-app/.github/actions/setup-toolchain@1c99dd2947a77097bf937701e3dcc90aa109d17e # 5.4.15 + with: + platform: windows + node-version: 22 + + - name: Install dependencies + run: yarn + + - name: Build packages + run: yarn build + + - name: Set up Win32 Desktop Driver + id: desktop-driver + uses: ./.github/actions/setup-desktop-driver + with: + platform: win32 + + - name: Smoke test Win32 Storybook + run: yarn storybook smoke --win32 --mode stories-and-tests --input physical + working-directory: apps/storybook + + - name: Upload Win32 Storybook artifacts + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: Storybook_win32_paper_physical_${{ github.run_id }}_${{ github.run_attempt }} path: | apps/storybook/artifacts/win32 apps/storybook/artifacts/desktop diff --git a/apps/storybook/README.md b/apps/storybook/README.md index cbc89ee41d..50e75358cb 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -23,7 +23,11 @@ yarn storybook smoke --macos --mode stories-and-tests ``` Replace `macos` with `windows` or `win32`. Use `yarn storybook test --- --list` to discover executable story-test names. Runtime IDs and +-- --list` to discover executable story-test names. Add +`--input accessible` to `test` or `smoke --mode stories-and-tests` to exclude +cases that need physical input, or `--input physical` on an unattended +machine. Accessible runs avoid pointer and keyboard injection, but may still +activate windows or change focus; keep desktop workloads exclusive. Runtime IDs and listener ports are available through the app's `desktop-driver` command. See `agent-map.yaml` for the current command contract and paths. diff --git a/apps/storybook/agent-map.yaml b/apps/storybook/agent-map.yaml index b90f1bd39f..f8243cf7b1 100644 --- a/apps/storybook/agent-map.yaml +++ b/apps/storybook/agent-map.yaml @@ -31,11 +31,12 @@ 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: desktopIt cases with input, platform, and gap descriptors in CSF3 story wdio arrays 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 + input_lane: --input accessible|physical smoke_mode: stories-and-tests execution: navigate-and-remount-then-run-tests-grouped-by-story diff --git a/packages/agentic/components/references/tests-and-stories.md b/packages/agentic/components/references/tests-and-stories.md index edc3276894..5691b7398f 100644 --- a/packages/agentic/components/references/tests-and-stories.md +++ b/packages/agentic/components/references/tests-and-stories.md @@ -137,23 +137,41 @@ validate. Prefer executable WebdriverIO tests under `wdio`, typed with `WdioStory>` from `@fluentui-react-native/storybook-desktop/testing` using `import type`. -The story is the suite (`describe`), and named functions are its cases (`it`): +The story is the suite (`describe`). Declare each case (`it`) with `desktopIt` +from the same entry point, adding a descriptor when the case needs host input +or applies only to some endpoints: ```tsx export const Default: Story = { - wdio: { - 'is enabled': async ({ browser, expect }) => { + wdio: [ + desktopIt('is enabled', async ({ browser, expect }) => { await expect(await browser.$('~story-button')).toBeEnabled(); - }, - }, + }), + desktopIt('moves focus with Tab', { requires: ['physical-click', 'keyboard', 'focus'] }, async ({ browser, expect }) => { + // ... + }), + ], }; ``` Each callback receives `browser`, `expect`, `platform`, `desktop`, `signal`, and `skip`. The typed target `platform` is `macos`, `windows`, or `win32`; Win32 is distinct from its WebDriver `platformName` of `windows`. Branch -inside callbacks for genuine platform differences and use explicit skip -reasons for unsupported `browser.capabilities['furn:features']`. +inside callbacks for genuine platform differences in one assertion. Do not +call `skip` or feature-check `browser.capabilities['furn:features']` for a +condition the descriptor can state: + +- `requires` lists the host capabilities the case uses, and its physical + entries put the case in the physical input lane. Focus and keyboard cases + declare `['physical-click', 'keyboard', 'focus']`. A case that reads only + native state declares nothing and runs in both lanes. Use `click` when either + click transport proves the behavior, `physical-click` for pointer-specific + behavior, and `accessibility-click` for UIA or AX action dispatch. +- `platforms` scopes the case to endpoints, such as `['win32']` or `['!macos']`. + Split a case when endpoints need different transports rather than branching + on the click mode. +- `unsupported` records a known product gap on named endpoints with a reason, + and `quarantine` suspends a flaky case with an issue and an `expires` date. All catalog tests use WDIO; custom `desktopDriver` plans remain only in runner compatibility fixtures. Keep names static and callbacks self-contained. Node helpers may be @@ -162,11 +180,13 @@ story-module bindings. The shared Babel config strips all test functions before native dependency collection. Named cases get independent previews, sessions, workers, deadlines, results, and failure evidence. -The single-function `wdio` form remains supported. Run with -`yarn storybook test --` and optionally `--test `. -`--list` discovers names without executing code. The `stories-and-tests` +Run with `yarn storybook test --`, optionally with `--test ` +and `--input accessible|physical` to select one input lane. `--list` discovers +cases and their resolved input without executing code. The `stories-and-tests` smoke mode runs these functions alongside remaining legacy plans, grouped by -story. See the executable test contract (`@fluentui-react-native/storybook-desktop/references/story-tests.md`). +story. See the executable test contract +(`@fluentui-react-native/storybook-desktop/references/story-tests.md`) for the +full descriptor, lane, and reporting rules. The components package's `test:stories` project checks every test-bearing story without emitting it into the component library. Add new test-bearing files to @@ -191,9 +211,12 @@ timing. ### Desktop focus test scope Keep shared requirements executable on Windows Fabric, Office Win32, and macOS. -`requireDesktopFocus` checks native capabilities, not an operating-system -allowlist. A missing capability skips the case with a reason; a platform -difference usually changes one assertion rather than skipping the entire case. +Focus cases declare `requires: ['physical-click', 'keyboard', 'focus']`, which +describes native capabilities, not an operating-system allowlist. The physical +lane runs them, the accessible lane excludes them, and a run without a lane +reports a case whose capability the host lacks as unsupported, with its reason, +instead of executing it. A platform difference +usually changes one assertion rather than excluding the entire case. | Behavior | Scope and expected result | | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -225,13 +248,15 @@ resolved-style coverage without incidental scene rerenders, plus native visual evidence for renderer appearance. Likewise, a checked/selected state read after a physical click does not exercise UIA/AX action dispatch. -Report passed, failed, and skipped counts together. A zero-exit smoke traversal -with platform-skipped focus cases is not cross-platform focus qualification. +Report passed, failed, skipped, unsupported, and excluded counts together. A +zero-exit accessible-lane run excludes every focus case and is not +cross-platform focus qualification. The macOS Fabric View does not project disabled or selected accessibility state to AXEnabled/AXSelected (C-A11Y-005 in the repository evidence reference `accessibility.action-transport-evidence`). Those native -announcement assertions are isolated in explicitly skipped cases; shared +announcement assertions are isolated in cases declaring +`unsupported: { platforms: ['macos'], reason }`; shared focus, activation, and selection-callback cases still run. Do not substitute rendered state for an AX result or describe these skips as intended macOS semantics. Track the native projection gap and revisit the cases when the diff --git a/packages/agentic/components/src/common/desktopFocus.wdio.ts b/packages/agentic/components/src/common/desktopFocus.wdio.ts index c22e292b55..e2432891fd 100644 --- a/packages/agentic/components/src/common/desktopFocus.wdio.ts +++ b/packages/agentic/components/src/common/desktopFocus.wdio.ts @@ -1,17 +1,5 @@ import type { WdioStoryContext } from '@fluentui-react-native/storybook-desktop/testing'; -export function requireDesktopFocus({ browser, skip }: Pick): boolean { - const features = browser.capabilities['furn:features']; - if (!features) { - throw new Error('Desktop Driver did not provide feature capabilities.'); - } - if (!features.physicalClick || !features.keyboard || !features.focus) { - skip('Native focus qualification requires physical pointer input, keyboard input, and focus observation.'); - return false; - } - return true; -} - /** Click-to-focus is Windows-specific; ordinary AppKit controls need not take focus on click. */ export async function expectWindowsPointerFocus( { browser, platform }: Pick, diff --git a/packages/agentic/components/src/components/avatar/avatar.stories.tsx b/packages/agentic/components/src/components/avatar/avatar.stories.tsx index f27248cc3d..2f1c700e57 100644 --- a/packages/agentic/components/src/components/avatar/avatar.stories.tsx +++ b/packages/agentic/components/src/components/avatar/avatar.stories.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import { desktopIt } from '@fluentui-react-native/storybook-desktop/testing'; import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; import { Avatar } from './avatar'; @@ -108,32 +109,35 @@ export const ActivityRing: Story = { }, }, }, - wdio: { - 'AVT-004 exposes the informative identity as a named native image': async ({ browser, expect }) => { + wdio: [ + desktopIt('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'); - }, - '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.', - ); + }), + desktopIt( + '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.', + ); + } } - } - }, - }, + }, + ), + ], }; const styles = StyleSheet.create({ diff --git a/packages/agentic/components/src/components/button/button.stories.tsx b/packages/agentic/components/src/components/button/button.stories.tsx index a178b57d9a..b20507a710 100644 --- a/packages/agentic/components/src/components/button/button.stories.tsx +++ b/packages/agentic/components/src/components/button/button.stories.tsx @@ -8,6 +8,7 @@ import type { Meta, StoryObj } from '@storybook/react-native'; import { ThemedRoot, useRootInputModality } from '@fluentui-react-native/design'; import { resolveAccessibilityAction } from '@fluentui-react-native/framework-base'; import type { FocusKeyboardEvent, FocusRequest } from '@fluentui-react-native/framework-base'; +import { desktopIt } from '@fluentui-react-native/storybook-desktop/testing'; import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; import { Button } from './button'; @@ -96,28 +97,26 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-e2e'], - wdio: { - 'exposes enabled button semantics': async ({ browser, expect }) => { + wdio: [ + desktopIt('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'); - }, - '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/); - }, - }, + }), + desktopIt( + 'supports native pointer activation', + { requires: ['physical-click', 'element-screenshot', 'focus'] }, + async ({ browser, expect, platform }) => { + const { expectWindowsPointerFocus } = await import('../../common/desktopFocus.wdio.ts'); + 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/); + }, + ), + ], }; function ModalityProbe({ testID = 'focus-input-modality' }: { testID?: string }) { @@ -168,149 +167,165 @@ function FocusManagementScene() { 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); - }, - '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' }] }]); + wdio: [ + desktopIt( + 'honors pointer focus policy and invokes exactly once per Enter and Space', + { requires: ['physical-click', 'keyboard', 'focus'] }, + async (context) => { + const { expectNativeState, expectWindowsPointerFocus, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + 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'); + }, + ), + desktopIt( + 'updates same-target modality without refocusing and shares it with nested themes', + { requires: ['physical-click', 'keyboard', 'focus'] }, + async (context) => { + const { expectNativeState, expectWindowsPointerFocus, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + 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(); - } - }, - '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' }, - ], - }, - ]); + 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'); + }, + ), + desktopIt( + 'skips disabled stops and does not activate the new owner on a stale key-up', + { requires: ['physical-click', 'keyboard', 'focus'] }, + async (context) => { + const { expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + 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(); - } - }, - 'exposes the native disabled accessibility state': async ({ browser, expect }) => { + await expect(await browser.$('~focus-probe-next-count')).toHaveText('0'); + await browser.keys(['\uE008', '\uE004']); + await expectNativeState(browser, 'focus-probe-button', 'focused', true); + }, + ), + desktopIt( + 'suppresses repeats and activates at the native platform key phase', + { requires: ['physical-click', 'keyboard', 'focus'] }, + async (context) => { + const { focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + 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(); + } + }, + ), + desktopIt( + 'releases pressed feedback after an intervening key without a stale activation', + { + requires: ['physical-click', 'keyboard', 'focus'], + quarantine: { + issue: '#4373', + reason: 'Win32 does not forward onPressOut after Space with an intervening key.', + expires: '2027-01-31', + platforms: ['win32'], + }, + }, + async (context) => { + const { focusByTab } = await import('../../common/desktopFocus.wdio.ts'); + 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(); + } + }, + ), + desktopIt('exposes the native disabled accessibility state', async ({ browser, expect }) => { await expect(await browser.$('~focus-probe-disabled')).not.toBeEnabled(); - }, - 'releases repeated modifiers before the next Tab': async (context) => { - const { requireDesktopFocus, expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; + }), + desktopIt('releases repeated modifiers before the next Tab', { requires: ['physical-click', 'keyboard', 'focus'] }, async (context) => { + const { expectNativeState, focusByTab } = await import('../../common/desktopFocus.wdio.ts'); const { browser } = context; await focusByTab(browser, 'focus-probe-entry', 'focus-probe-button'); try { @@ -331,8 +346,8 @@ export const FocusManagement: Story = { } finally { await browser.releaseActions(); } - }, - }, + }), + ], }; function FocusRequestsScene() { @@ -361,32 +376,35 @@ function FocusRequestsScene() { 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'); - }, - }, + wdio: [ + desktopIt( + 'confirms native requests without changing modality and rejects disabled or detached targets', + { requires: ['physical-click', 'keyboard', 'focus'] }, + async (context) => { + const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + 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'); + }, + ), + ], }; export const Overview: Story = { @@ -518,41 +536,35 @@ export const Selected: Story = { }; export const ExternallyDrivenSelection: Story = { - wdio: { - 'starts unselected': async ({ browser, expect }) => { + wdio: [ + desktopIt('starts unselected', 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'); + }), + desktopIt('updates and resets caller-owned selection', { requires: ['click'], platforms: ['!macos'] }, async ({ browser, expect }) => { 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 favorite.click(); await expect(status).toHaveText('Selected'); - if (platform === 'macos') { - await clickPhysical(browser, 'agentic-storybook-button-reset'); - } else { - await reset.click(); - } + await (await browser.$('~agentic-storybook-button-reset')).click(); await expect(status).toHaveText('Not selected'); - }, - }, + }), + desktopIt( + 'updates and resets caller-owned selection with a physical pointer', + { requires: ['physical-click'], platforms: ['macos'] }, + async ({ browser, expect }) => { + const { clickPhysical } = await import('../../common/desktopFocus.wdio.ts'); + const status = await browser.$('~agentic-storybook-button-selection-state'); + await expect(await browser.$('~agentic-storybook-button-favorite')).toBeEnabled(); + await expect(status).toHaveText('Not selected'); + await clickPhysical(browser, 'agentic-storybook-button-favorite'); + await expect(status).toHaveText('Selected'); + await clickPhysical(browser, 'agentic-storybook-button-reset'); + await expect(status).toHaveText('Not selected'); + }, + ), + ], render: () => { const ToggleGroup = () => { const [selected, setSelected] = useState(false); @@ -624,8 +636,8 @@ export const NativeNames: Story = {