diff --git a/.changeset/bright-books-configure.md b/.changeset/bright-books-configure.md new file mode 100644 index 00000000000..096b21855ba --- /dev/null +++ b/.changeset/bright-books-configure.md @@ -0,0 +1,6 @@ +--- +"@fluentui-react-native/storybook-desktop": minor +"@fluentui-react-native/storybook-desktop-runtime": minor +--- + +Add reusable platform-aware configuration and CLI APIs for serving, preparing, bundling, building, running, and smoke testing desktop Storybook applications. The package now owns complete Windows Fabric and Win32 smoke lifecycles, native host launch, synchronized story traversal, desktop UX checks, process cleanup, and per-enlistment bundle and service isolation. React Native runtime code and peers are isolated in a companion package so Yarn's pnpm linker can invoke the peer-free CLI through a physical workspace locator. diff --git a/.changeset/fresh-dragons-drive.md b/.changeset/fresh-dragons-drive.md new file mode 100644 index 00000000000..1fe302f1b94 --- /dev/null +++ b/.changeset/fresh-dragons-drive.md @@ -0,0 +1,17 @@ +--- +"@fluentui-react-native/desktop-driver": minor +"@fluentui-react-native/components": patch +"@fluentui-react-native/storybook-desktop": minor +"@fluentui-react-native/storybook-desktop-runtime": minor +--- + +Add the platform-neutral W3C desktop driver and integrate Storybook manifests, +authenticated runtime readiness, deterministic preview resets, and same-process +driver supervision. Add portable Button, Checkbox, and Input story plans for +WebdriverIO and agent validation. Desktop Storybook smoke runs can now either +traverse the complete catalog or traverse it and then execute the authored +desktop-e2e plans. +Smoke startup now waits through the initial Metro compilation, macOS cleanup +terminates the exact bundle-identifier process, and Windows CI installs the +required Windows App Runtime while the shared registration lifecycle installs +the SDK-provided Debug VCLibs frameworks. diff --git a/.github/skills/agentic-component-authoring/references/tests-and-stories.md b/.github/skills/agentic-component-authoring/references/tests-and-stories.md index 4175a237dd2..3c1069e2d4d 100644 --- a/.github/skills/agentic-component-authoring/references/tests-and-stories.md +++ b/.github/skills/agentic-component-authoring/references/tests-and-stories.md @@ -113,6 +113,38 @@ layout order, or native class names. Keep the initial args deterministic and add identifiers only to the small smoke set that agents and CI actively validate. +Portable desktop automation is authored inline under +`parameters.desktopDriver` and typed with `DesktopStoryTests` from +`@fluentui-react-native/desktop-driver/authoring`: + +```tsx +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'enabled-button', + steps: [ + { action: 'wait', target: { testId: 'story-button' } }, + { expect: { state: 'enabled', target: { testId: 'story-button' }, value: true } }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; +``` + +Keep the plan a static JSON literal. Storybook extracts it without importing +the React Native module, so variables, functions, spreads, computed properties, +and runtime platform branches are rejected. Express real differences with +`platforms`, `requires`, and explicit skip results. Use `testID` for actions; +use role, accessible name, state, and value assertions to verify the public +accessibility contract. Button, Checkbox, and Input defaults are the canonical +initial examples. + Button uses focused appearance, size, shape, icon, selection, disabled, and constrained-content stories. Icon uses a source and size overview plus focused font, image, SVG, size, color, and accessibility stories. @@ -132,8 +164,8 @@ yarn workspace @fluentui-react-native/components format yarn workspace @fluentui-react-native/components lint yarn workspace @fluentui-react-native/components build yarn workspace @fluentui-react-native/components test -yarn workspace @fluentui-react-native/agentic-components-storybook bundle:macos -yarn workspace @fluentui-react-native/agentic-components-storybook bundle:windows +yarn workspace @fluentui-react-native/agentic-components-storybook storybook bundle --macos +yarn workspace @fluentui-react-native/agentic-components-storybook storybook bundle --windows ``` Run the smallest affected package test while iterating. Run the full package sequence before completion. Run the root diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3ce36ba8f5a..8cc9ece1c0f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -208,15 +208,15 @@ jobs: - name: Bundle macOS run: | set -eox pipefail - yarn bundle:macos + yarn storybook bundle --macos working-directory: apps/storybook - name: Pod install - run: yarn pods:macos + run: yarn storybook prep --macos working-directory: apps/storybook - - name: Build macOS app - run: yarn macos:build + - name: Run through storybook smoke tests + run: yarn storybook smoke --macos --mode stories-and-tests working-directory: apps/storybook env: CCACHE_DISABLE: 1 @@ -355,18 +355,36 @@ jobs: - name: Build packages run: yarn build - - name: Bundle Windows - run: yarn bundle:windows + - name: Prep Windows Storybook + run: yarn storybook prep --windows working-directory: apps/storybook - - name: Generate RNW app - run: yarn install-windows-test-app --use-nuget - working-directory: apps/storybook + - name: Install Windows App Runtime 1.8 + shell: pwsh + run: | + $installer = Join-Path $env:RUNNER_TEMP 'WindowsAppRuntimeInstall-x64.exe' + Invoke-WebRequest 'https://aka.ms/windowsappsdk/1.8/1.8.260804001/windowsappruntimeinstall-x64.exe' -OutFile $installer + & $installer --quiet + if ($LASTEXITCODE -ne 0) { + throw "Windows App Runtime installer exited with code $LASTEXITCODE." + } - - name: Build RNW app - run: yarn rnx-cli run-windows --no-packager --no-deploy --no-launch + $runtime = Get-AppxPackage -Name 'Microsoft.WindowsAppRuntime.1.8' + if (-not $runtime) { + throw 'Microsoft.WindowsAppRuntime.1.8 was not registered for the runner user.' + } + + - name: Smoke test Windows Storybook + run: yarn storybook smoke --windows --mode stories-and-tests working-directory: apps/storybook + - name: Upload Windows Storybook artifacts + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Storybook_windows_Dump + path: apps/storybook/artifacts/windows + win32: name: Win32 PR runs-on: windows-latest @@ -414,7 +432,6 @@ jobs: path: | apps/E2E/reports apps/E2E/errorShots - apps/storybook/artifacts/win32 win32-storybook: name: Win32 Storybook PR @@ -436,10 +453,17 @@ jobs: - name: Build packages run: yarn build - - name: Bundle Win32 - run: yarn bundle:win32 + - name: Smoke test Win32 Storybook + run: yarn storybook smoke --win32 --mode stories-and-tests working-directory: apps/storybook + - name: Upload Win32 Storybook artifacts + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Storybook_win32_Dump + path: apps/storybook/artifacts/win32 + check-changesets: name: Check for Changesets runs-on: ubuntu-latest diff --git a/.yarnrc.yml b/.yarnrc.yml index 87aaa367514..2087f73fcb8 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -86,6 +86,9 @@ packageExtensions: dependencies: "@svgr/plugin-jsx": "*" "@svgr/plugin-svgo": "*" + "@storybook/react-native@10.5.4": + dependencies: + esbuild: "0.28.1" "@wdio/appium-service@*": dependencies: appium: "*" diff --git a/apps/storybook/.gitignore b/apps/storybook/.gitignore index 2c0828ecf5c..7c96d1fa40a 100644 --- a/apps/storybook/.gitignore +++ b/apps/storybook/.gitignore @@ -1,6 +1,7 @@ # Generated by the withStorybook metro wrapper / storybook-generate script src/storybook.requires.ts src/storybook.requires.js +storybook-desktop.generated/ # Metro bundle output *.jsbundle @@ -12,6 +13,7 @@ macos/build/ macos/DerivedData/ macos/Podfile.lock macos/.xcode.env +macos/.storybook-desktop/ macos/*.xcodeproj/ macos/*.xcworkspace/ windows/.vs/ diff --git a/apps/storybook/AGENTS.md b/apps/storybook/AGENTS.md index ac26b88e19b..7936bf07399 100644 --- a/apps/storybook/AGENTS.md +++ b/apps/storybook/AGENTS.md @@ -13,18 +13,46 @@ Read this file, `README.md`, and `package.json` before changing the Storybook ap - Run `yarn` from the repository root only after dependency manifests change or when a declared command fails because a dependency is missing. - Preserve unrelated manifest and lockfile edits already present in the worktree. +- Keep only native command exceptions and ownership-specific smoke settings in `storybook.config.mts`. Standard macOS + and Windows prep, bundle, build, and run commands come from the shared config, derive identity from `app.json`, and + route through `rnx-cli`. Use `yarn storybook --`; do not add platform aliases or app-local + lifecycle scripts. +- `app.json` owns the custom `storybook.testIDPrefix`. Do not create another + app identity file or duplicate the prefix in runtime source. + +## Desktop Driver workflow + +- Use `yarn storybook manifest --` to validate static story-plan + extraction. +- Use `yarn storybook driver --` to start Metro, the Storybook + channel/MCP listener, and the WebDriver listener under one owned supervisor. +- Use the app's `yarn desktop-driver` script for JSON story-run and agent + commands against that listener. +- The Stage 1 provider is deliberately fake. Do not add Windows or macOS native + automation code until the corresponding Stage 2 plan begins. +- Authored tests belong in component story `parameters.desktopDriver`, not in + this app. The app owns identity, package discovery, platform exclusions, and + generated manifests. +- Keep `storybook-desktop.generated`, reports, trees, screenshots, and run + manifests ignored. Never patch generated runtime identity or story manifests. +- Treat the exact-platform and portable-plan digests as contracts. A dynamic or + invalid plan must fail generation rather than disappear from the manifest. +- Preserve nonce-authenticated runtime hello/readiness/error messages and + native story-root verification; do not fall back to uncorrelated channel + events. ## macOS native workflow -- Run `pods:macos` for normal project generation and pod installation. Do not run `pod install --project-directory=...` - from the repository root: CocoaPods keeps that working directory for React Native CLI autolinking under the pnpm - linker. -- Run `pods:macos:update` when generated Pods came from an older React Native macOS patch release and CocoaPods reports a - changed local podspec. -- Run `bundle:macos` to verify the JavaScript bundle, `macos:build` for a non-launching native build, and - `macos:build:clean` after changing pods, native workarounds, Xcode settings, or React Native versions. +- Run `yarn storybook prep --macos` for project generation and Pod installation. Do not run CocoaPods from the + repository root because subprocess dependency resolution must start in this workspace. +- Run `yarn storybook bundle --macos` for the JavaScript bundle, `yarn storybook build --macos` for a non-launching + native build, and `yarn storybook smoke --macos` for the complete owned lifecycle. +- Preserve the shared smoke instance context: its canonical-root hash coordinates the macOS bundle identifier, + Storybook port, Metro port, generated runtime polyfill, and exact app shutdown. Do not replace those values with + process-name matching or fixed smoke ports. - Only `macos/Podfile` is hand-authored. The workspace, Pods, Podfile.lock, build directory, and DerivedData are generated - and ignored; never patch or commit them. + and ignored; `storybook-desktop.generated` and `macos/.storybook-desktop` are generated instance state. Never patch or + commit these outputs. - Diagnose the first actionable CocoaPods or compiler error before editing configuration. If autolinking claims a listed dependency is missing, verify resolution from this app directory before adding another dependency. - Avoid patching generated pod source. If a temporary source patch is unavoidable, document its exact version boundary. @@ -32,20 +60,19 @@ Read this file, `README.md`, and `package.json` before changing the Storybook ap ## Windows native workflow -- Run `windows:info` before investigating a machine-specific toolchain failure. -- Use `windows:generate` to regenerate the Fabric solution, `windows` for the ordinary development build, - `windows:build` for a non-deploying native build, and `windows:offline` for the bundled Release workflow. -- Use `windows:agent` for the complete agent workflow: start the channel server and Metro, build and launch the app, - and validate the smoke stories through stable UI Automation selectors. Use `windows:agent:stop` to stop only the - process IDs recorded by that session. +- Use `yarn storybook prep --windows`, `bundle --windows`, `build --windows`, and `run --windows` for individual + stages. Use `yarn storybook smoke --windows --mode stories` for the package-owned generation, channel server, native + build and registration, Metro launch, full indexed-story traversal, and ownership-safe cleanup. Use + `--mode stories-and-tests` to run the component-authored desktop-e2e plans after the complete traversal. +- Keep the Windows story-pattern overrides in `storybook.config.mts`; the + current Accordion and Callout Fabric stories still fail-fast the RNW 0.81 + host during traversal. - WinAppDriver screenshots are not a reliable capture path for WinAppSDK Composition content. After selecting a story - with `storybook:control`, use the agent host's desktop screenshot tool when visual evidence is required. + through the Storybook control channel, use the agent host's desktop screenshot tool when visual evidence is required. - Build logs, automation evidence, visual trees, screenshots, and session manifests belong under ignored `artifacts/windows`. - Stable native automation selectors use explicit `testID` props. Do not select by visible text, layout order, or generated native class name. -- The Storybook REST control helper is `storybook:control`; `storybook:smoke` selects every indexed story and waits for - its rendered event. - Keep generated solutions, packages, registrations, and build outputs uncommitted. ## Win32 native workflow @@ -53,16 +80,19 @@ Read this file, `README.md`, and `package.json` before changing the Storybook ap - Win32 is the `@office-iss/react-native-win32` Paper endpoint hosted by `@office-iss/rex-win32`; do not treat it as the React Native Windows Fabric endpoint or generate a `react-native-test-app` project for it. -- Run `bundle:win32` before `win32`. The bundle is the native dependency source +- Run `yarn storybook bundle --win32` before `yarn storybook run --win32`. The bundle is the native dependency source for the prebuilt REX host. +- Keep package discovery and platform-specific story inclusion in + `storybook.config.mts`; keep `src/main.ts` as the shared config adapter. - Keep shared Storybook source platform-neutral. Win32-specific source belongs in `.win32.ts` or `.win32.tsx` files, and Metro platform resolution belongs in `metro.config.js`. - REX 0.81.1's V8 cannot parse the Unicode-property regular expressions - bundled by the current Storybook release. Keep their compatibility transform + bundled by the current Storybook release. Keep the shared desktop package's compatibility transform scoped to Win32; remove it when the REX engine supports Unicode property escapes. -- Win32 uses desktop-only chrome in `StorybookUI.win32.tsx` because +- Win32 uses desktop-only chrome in + `../../packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx` because react-native-win32 omits window dimensions and Storybook's mobile LiteUI drawer crashes the Paper host. Keep its default layout conceptually aligned with desktop LiteUI: persistent resizable Sidebar, story preview, and @@ -75,16 +105,14 @@ Read this file, `README.md`, and `package.json` before changing the Storybook ap - Keep macOS and Windows on upstream LiteUI. Replacing it with the reduced Win32 chrome would regress addon controls and responsive behavior while increasing local maintenance. -- Generate Win32 stories with `prebuild:win32`. It intentionally excludes the - ListItem and Accordion stories because their Paper implementations fail-fast - crash REX 0.81.1. Callout stories and the Callout-backed portal chrome remain - included; keep the ordinary `prebuild` catalog unchanged for macOS and - Windows. +- Keep the Win32 story-pattern override in `storybook.config.mts`. It intentionally excludes ListItem and Accordion + because their Paper implementations fail-fast crash REX 0.81.1. - Keep the Win32 window title distinct from the Windows Fabric title so automation never attaches to the wrong endpoint. -- Use `win32:ci` for the complete bundle/launch/smoke workflow. Its logs belong - under ignored `artifacts/win32`, and it must stop only the process IDs it - started or resolved by its exact port and window title. +- Use `yarn storybook smoke --win32 --mode stories` for the package-owned bundle, launch, native desktop-chrome checks, + full story traversal, and cleanup. Use `--mode stories-and-tests` to run the component-authored desktop-e2e plans + afterward. Logs belong under ignored `artifacts/win32`. Keep `build --win32` unsupported because the endpoint uses a + prebuilt host rather than an app-owned native project. ## Validation diff --git a/apps/storybook/README.md b/apps/storybook/README.md index 95344d9f5da..7b6dedbdd25 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -1,33 +1,36 @@ # Agentic Components Storybook -On-device [Storybook](https://storybook.js.org/) app (Storybook for React Native v10) for +On-device [Storybook](https://storybook.js.org/) test app (Storybook for React Native v10) for `@fluentui-react-native/components` and linked standalone native packages. It loads every -`*.stories.(ts|tsx)` file from the agentic library source (`../src`) plus the standalone -Callout package so its native stories run in the Fabric host. +`*.stories.(ts|tsx)` file from the agentic components package plus the standalone Callout +package so its native stories run in the Fabric host. -It runs in Storybook **liteMode**, which mocks out the heavy default on-device UI +The reusable desktop CLI and configuration live in +`packages/agentic/storybook-desktop`, with peer-dependent React Native +implementation in `packages/agentic/storybook-desktop-runtime`. The runtime +runs Storybook in **liteMode**, which mocks out the heavy default on-device UI (`@storybook/react-native-ui`). This avoids the `react-native-reanimated` / `react-native-gesture-handler` / `@gorhom/bottom-sheet` / `react-native-svg` native dependency chain, which does not bundle cleanly with this repo's Metro + Babel + pnpm-linker toolchain (Reanimated's Babel plugin crashes when Metro bundles Reanimated from source). -The app shell includes a persistent theme header above the Storybook UI. It can leave stories +The shared app shell includes a persistent theme header above the Storybook UI. It can leave stories unwrapped (`No theme`, the default) or apply the default light, dark, or high-contrast FURN Theme. The selected Theme wraps the preview decorator, so it applies to every rendered story and remains selected while navigating between stories. -The macOS, Windows Fabric, and Win32 Paper endpoints live in this workspace and -share the same entry point and generated story catalog. Win32 stays here rather -than in a sibling package so story discovery and agent control cannot drift. -Shared Storybook source remains platform-neutral; `metro.config.js` redirects -`react-native` imports to `@office-iss/react-native-win32` only while producing -the Win32 bundle. +The macOS, Windows Fabric, and Win32 Paper native endpoints live in this workspace and +share the same entry point and generated story catalog. Story discovery and native identity stay +app-owned, while the platform-neutral UI, configuration helpers, and `storybook-desktop` CLI come +from the shared package. The app exposes only the shared CLI entry points; native lifecycle scripts +remain package-owned. ## Layout ``` storybook/ - src/ Storybook config, generated requires, and root component + src/ Storybook adapters, generated requires, and shared-runtime integration + storybook.config.mts App-owned package discovery, platform patterns, and native CLI settings index.js AppRegistry entry app.json react-native-test-app manifest metro.config.js rnx-kit metro config wrapped with withStorybook (liteMode) @@ -54,31 +57,30 @@ matching the other test apps in this repo. Only the hand-written `macos/Podfile` ```sh # from this directory # 1. Generate the Xcode project/workspace + install pods -yarn pods:macos +yarn storybook prep --macos # Optional: verify a native build without launching the app -yarn macos:build +yarn storybook build --macos # 2. Start Metro (also generates storybook.requires) yarn start # 3. In another terminal, build & launch the macOS app -yarn macos +yarn storybook run --macos ``` Requires Xcode + CocoaPods. -If `Pods` was generated against an older React Native macOS patch release and CocoaPods reports -that a local podspec such as `fmt` changed, refresh the local native dependencies: - -```sh -yarn pods:macos:update -``` +Run `yarn storybook smoke --macos` for the complete server, Metro, build/launch, all-story traversal, and +ownership-safe shutdown lifecycle. The shared CLI hashes this enlistment's canonical project root, +uses that suffix in the native bundle identifier, and selects dedicated Storybook and Metro ports. +Parallel smoke tests from different enlistments therefore launch, drive, and stop only their own app +and services, even when the default ports are already occupied. > `react-native-safe-area-context` note: Storybook's UI imports it, but its native module is > iOS-only (UIKit) and uses a Yoga API that doesn't compile for react-native-macos 0.81. It is -> therefore not installed; `metro.config.js` aliases the import to a JS-only stub in -> `.storybook-mocks/`, so no native module is needed. +> therefore not installed; the shared Metro helper aliases the import to a JS-only stub, so no +> native module is needed. ## Running on Windows @@ -88,45 +90,27 @@ Windows Fabric native library; its Paper implementation remains built into the p ```powershell # from this directory -# Generate when needed, build and register before Metro, then launch the Debug app -yarn windows - -# Stop the Storybook server, Metro, and app processes owned by this session -yarn windows:agent:stop -``` +# Traverse the complete story catalog +yarn storybook smoke --windows --mode stories -Requires Visual Studio 2022 with the React Native Windows build prerequisites. The generated -solution, `ExperimentalFeatures.props`, and build outputs are git-ignored and can be regenerated -with `yarn windows:generate`. +# Traverse the complete catalog, then run authored desktop-e2e plans +yarn storybook smoke --windows --mode stories-and-tests -The raw React Native Windows CLI path remains available as `yarn windows:cli`, but the declared -`windows` workflow avoids two failure modes in this app: CLI deployment can stall while enabling -Developer Mode, and starting Metro before the native build can make its watcher observe generated -AppPackages being rewritten. A manually launched Debug app has no embedded JavaScript bundle and -will remain on the loading screen unless Metro is already serving this workspace on port 8081. - -For a non-deploying build with structured logs: - -```powershell -yarn windows:info -yarn windows:build +# Individual development stages +yarn storybook prep --windows +yarn storybook build --windows +yarn storybook run --windows ``` -Logs are written beneath `artifacts/windows/build-logs`. - -The Debug app always loads from Metro; `react-native-test-app` does not automatically fall back -to an embedded bundle in Debug builds. To bundle, build, and launch a Release app that runs -without Metro: - -```powershell -yarn windows:offline -``` - -The Release package embeds `dist/index.windows.bundle`. Storybook's optional color-picker image -is intentionally not packaged because the Yarn pnpm asset path exceeds Windows' deployment path -limit; controls and stories otherwise run from the embedded bundle. The command replaces this -app's current Debug registration with its Release layout; running `yarn windows` later deploys -the Debug app again. +`stories` is the default smoke mode. Requires Visual Studio 2022 with the React Native Windows build prerequisites. The generated +solution, `ExperimentalFeatures.props`, registrations, and build outputs are git-ignored. The shared smoke command +bundles the Windows catalog, generates the solution, starts the platform-scoped channel server, builds and registers the Debug app, starts Metro, +launches the exact app window, renders every indexed story, optionally runs the component-authored plans, and stops only the processes it recorded. +During Stage 1 the authored plans use the manifest-derived fake target; the full story traversal remains native. Logs are written +beneath `artifacts/windows/smoke-logs`. +The Accordion and Callout stories remain excluded from the Windows catalog +because the current RNW 0.81 Fabric host still fail-fasts while traversing +them. Win32 continues to exercise Callout through its Paper endpoint. Storybook's development bundle intentionally contains separate `pretty-format` and `react-is` versions used by its internal tooling. They are excluded from the duplicate-module enforcement; @@ -142,30 +126,32 @@ Windows Fabric endpoint above and does not use a generated ```powershell # from this directory # Produce dist/index.win32.bundle from the same story catalog as the other endpoints -yarn bundle:win32 +yarn storybook bundle --win32 # Launch the prebuilt Paper host -yarn win32 +yarn storybook run --win32 + +# Complete CI-ready bundle, native UX, and story traversal lifecycle +yarn storybook smoke --win32 --mode stories + +# Run the same traversal followed by authored desktop-e2e plans +yarn storybook smoke --win32 --mode stories-and-tests ``` The host window title is `Agentic Components Storybook (Win32)` so automation can distinguish it from the Windows Fabric app. The pinned REX 0.81.1 host runs the resolved react-native-win32 0.81 release line. Runtime diagnostics are -written to the ignored `artifacts/win32/console.log`. For the development loop, -run `yarn start` and then `yarn win32:dev` in separate terminals. -`yarn bundle:win32:dev` produces a debuggable local bundle when Metro cannot be -kept running. +written to the ignored `artifacts/win32/console.log`. The current Storybook bundle contains regular expressions that use Unicode properties unsupported by the V8 engine in REX 0.81.1. The Win32-only Babel plugin in -`scripts/transform-win32-unicode-regex.cjs` expands those expressions at bundle +`packages/agentic/storybook-desktop/config/transform-win32-unicode-regex.cjs` expands those expressions at bundle time. Remove the workaround after the REX host accepts Unicode property escapes; other platform bundles never load the plugin. react-native-win32 intentionally leaves window width and height undefined, and Storybook's mobile `LiteUI` drawer crashes the Paper host after those metrics -are supplied. The Win32 endpoint therefore uses desktop-only chrome in -`StorybookUI.win32.tsx` with the same conceptual structure as macOS and +are supplied. The Win32 endpoint therefore uses the shared package's desktop-only chrome with the same conceptual structure as macOS and Windows: a persistent Sidebar on the left, story preview on the upper right, and an Actions-first addon panel along the bottom. Local splitters resize the sidebar width and addon height without reading global window dimensions. @@ -186,71 +172,80 @@ solving a platform problem they currently have. Nine ListItem stories and seven Accordion stories are omitted from the Win32-generated catalog because those components terminate the current REX -0.81.1 host with fail-fast code `0xC0000409`; macOS and Windows continue to -include them. The three standalone Callout stories run through the same Paper +0.81.1 host with fail-fast code `0xC0000409`; macOS continues to include them, +while Windows also omits Accordion and Callout. The three standalone Callout stories run through the same Paper `RCTCallout` implementation as the portal chrome. All 130 included stories render through the Win32 control-plane smoke sweep. -Run `yarn storybook-server:win32` with this endpoint so the server exposes the -same 130-story index as the app; the ordinary `storybook-server` command keeps -the full macOS and Windows catalog. Use `yarn storybook:smoke:win32` for the -native sweep; its short settle interval prevents REX Paper teardown races -between rapid story transitions. +Run `yarn storybook-server --win32` with this endpoint so the server exposes the +same 130-story index as the app; the ordinary `storybook-server` command keeps the full macOS and Windows catalog. +`yarn storybook smoke --win32` defaults to `--mode stories` and verifies the package-owned desktop regions, resize +handles, addon surface, the complete 130-story sweep, host liveness, and ownership-safe cleanup. The +`stories-and-tests` mode then runs the component-authored plans through the Stage 1 manifest-derived fake target. Native +plan execution begins with the Stage 2 providers. Logs are written +beneath `artifacts/win32/smoke-logs`. A native `build --win32` operation is intentionally unsupported because this +endpoint uses the prebuilt REX host. -`yarn win32:ci` bundles the endpoint, starts the scoped channel server and REX -host without the direct-debugger listener, verifies the default desktop -regions, resizes both splitters, opens and dismisses both native pop-outs, runs -the 130-story sweep, verifies that the host remains alive, and stops only its -recorded process IDs. Logs are written beneath `artifacts/win32`. - -### Windows agent workflow +## Bundling (no native toolchain required) -The complete agent workflow starts the Storybook channel server and Metro, builds and launches the -app, selects representative stories, and verifies their stable native UI Automation selectors: +You can produce the JS bundle without Xcode. This also generates `storybook.requires` first: -```powershell -yarn windows:agent +```sh +yarn storybook bundle --macos # -> writes dist/index.macos.jsbundle +yarn storybook bundle --win32 # -> writes dist/index.win32.bundle +yarn storybook bundle --windows # -> writes dist/index.windows.bundle ``` -The command records the exact server, Metro, and app process IDs in -`artifacts/windows/agent-session.json`. Stop that session without affecting unrelated development -processes: +These scripts route to `storybook-desktop bundle --macos|--win32|--windows`. The binary can also +infer the target from `FURN_STORYBOOK_PLATFORM` or the host platform when no explicit option is +provided. -```powershell -yarn windows:agent:stop -``` - -Use `yarn windows:agent:start` to leave the app ready for manual or external agent interaction -without immediately running the smoke tests. WinAppDriver 1.2.1 is required for automation. -The RNW automation package is pinned to the same 0.81.32 release as the resolved -`react-native-windows` dependency. Set `WINAPPDRIVERPATH` when the executable is not installed at -`C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe`. +## Agent interaction (WebSocket channel + MCP) -WinAppDriver 1.2.1 can attach to this WinAppSDK window and inspect its UI Automation tree, but its -screenshot endpoint does not reliably capture React Native Windows Composition content. Agents -that have a desktop screenshot tool should use it after selecting a story with -`storybook:control`; UI Automation remains the deterministic automated validation gate. +The running app can be driven by external agents through the reusable standalone Storybook channel +server (`storybook-server`, default `127.0.0.1:7007`): -## Bundling (no native toolchain required) +```sh +yarn storybook-server # host-platform default +yarn storybook-server --win32 # explicit Win32 catalog +# WebSocket: ws://127.0.0.1:7007/ MCP: http://127.0.0.1:7007/mcp +``` -You can produce the JS bundle without Xcode. This also generates `storybook.requires` first: +For the Stage 1 desktop-driver control plane, use the combined supervisor: ```sh -yarn bundle:macos # -> writes dist/index.macos.jsbundle -yarn bundle:win32 # -> writes dist/index.win32.bundle -yarn bundle:windows # -> writes dist/index.windows.bundle +yarn storybook driver --windows +yarn storybook manifest --windows +yarn storybook instance --windows ``` -## Agent interaction (WebSocket channel + MCP) +`driver` runs the Storybook channel/MCP listener and a separate W3C WebDriver +listener in the same Node process. `instance` reports the enlistment-specific +ports and target identity. The generated manifest contains the exact platform +catalog, relocatable source paths, serializable story-test plans, and platform +and portable-plan digests. The current provider is a deterministic fake host; +native Windows, Win32, and macOS providers are a later implementation stage. -The running app can be driven by external agents through a standalone Storybook channel server -(`storybook-server.cjs`, default `127.0.0.1:7007`): +The app exposes the shared JSON CLI as `yarn desktop-driver`. After the +supervisor and app are running, list or run the component-authored plans: ```sh -yarn storybook-server # WebSocket: ws://127.0.0.1:7007/ MCP: http://127.0.0.1:7007/mcp +yarn desktop-driver stories list \ + --url http://127.0.0.1: \ + --target agenticstorybook-windows + +yarn desktop-driver stories run \ + --url http://127.0.0.1: \ + --target agenticstorybook-windows \ + --tag desktop-e2e \ + --artifacts artifacts/windows/desktop-driver ``` -Run it alongside `yarn start` + `yarn macos` or `yarn windows`. The on-device app connects to it automatically -(`src/StorybookApp.tsx` calls `getStorybookUI({ enableWebsockets: true, host, port })`). +Use `agent describe` for a bounded native tree and `agent screenshot` for a +confined evidence artifact. These commands and the programmatic agent API use +the same manifests, selectors, runner, and result schema as WebdriverIO. + +Run it alongside `yarn start` and `yarn storybook run --macos|--windows`. The on-device app connects to it automatically +(`src/StorybookApp.tsx` creates the shared desktop Storybook app around the generated view). - **WebSocket channel** (`ws://127.0.0.1:7007/`): agents connect and emit Storybook channel events to drive the app — e.g. `setCurrentStory` (`{ storyId }`) to switch story, and arg-update events @@ -269,15 +264,6 @@ Run it alongside `yarn start` + `yarn macos` or `yarn windows`. The on-device ap - `POST /select-story-sync/` selects a story and waits for `storyRendered`. - `POST /send-event` broadcasts a Storybook channel event. -The declared helper wraps these endpoints: - -```powershell -yarn storybook:control list -yarn storybook:control select components-button--default -yarn storybook:control args components-button--default '{"appearance":"primary"}' -yarn storybook:smoke -``` - > We run the channel server standalone (via `@storybook/react-native/node`'s `createChannelServer`) > rather than through `withStorybook`, because the bundler-agnostic `withStorybook` only starts it in > entry-point-swapping mode (`STORYBOOK_ENABLED=true`), which conflicts with this app's in-app @@ -285,6 +271,8 @@ yarn storybook:smoke ## Writing stories -Follow the package-level story authoring instructions in `../AGENTS.md`. Add a `*.stories.tsx` file next to its component -under `../src`; standalone native package story globs are listed explicitly in `src/main.ts`. See -`../src/components/button/button.stories.tsx` for the canonical higher-order component example. +Follow the package-level story authoring instructions in `../../packages/agentic/components/AGENTS.md`. Add a +`*.stories.tsx` file next to its component; standalone native package story globs are listed explicitly in `src/main.ts`. +See `../../packages/agentic/components/src/components/button/button.stories.tsx` for the canonical higher-order component example. +Portable tests are static `parameters.desktopDriver` data with stable `testID` +selectors; Button, Checkbox, and Input demonstrate the initial contract. diff --git a/apps/storybook/app.json b/apps/storybook/app.json index 4d42273979f..4612f9b0dc2 100644 --- a/apps/storybook/app.json +++ b/apps/storybook/app.json @@ -7,6 +7,12 @@ "displayName": "Agentic Components Storybook" } ], + "macos": { + "bundleIdentifier": "com.microsoft.fluentui.agenticstorybook" + }, + "storybook": { + "testIDPrefix": "agentic-storybook" + }, "resources": { "macos": ["dist/assets", "dist/index.macos.jsbundle"], "windows": ["dist/index.windows.bundle"] diff --git a/apps/storybook/babel.config.js b/apps/storybook/babel.config.js index 39863afc277..06bdecddade 100644 --- a/apps/storybook/babel.config.js +++ b/apps/storybook/babel.config.js @@ -1,8 +1,3 @@ -module.exports = (api) => { - const platform = api.caller((caller) => caller?.platform); +const { createDesktopStorybookBabelConfig } = require('@fluentui-react-native/storybook-desktop/babel'); - return { - presets: ['module:@react-native/babel-preset'], - plugins: platform === 'win32' ? [require.resolve('./scripts/transform-win32-unicode-regex.cjs')] : [], - }; -}; +module.exports = createDesktopStorybookBabelConfig; diff --git a/apps/storybook/jest.windows.config.cjs b/apps/storybook/jest.windows.config.cjs deleted file mode 100644 index 79554dcd8c4..00000000000 --- a/apps/storybook/jest.windows.config.cjs +++ /dev/null @@ -1,22 +0,0 @@ -const path = require('node:path'); - -module.exports = { - maxWorkers: 1, - roots: [path.join(__dirname, 'windows-tests')], - testEnvironment: '@react-native-windows/automation', - testRegex: ['.*\\.test\\.cjs$'], - testTimeout: 120000, - verbose: true, - testEnvironmentOptions: { - app: process.env.STORYBOOK_WINDOWS_WINDOW_TITLE || 'Agentic Components Storybook', - rootLaunchApp: false, - useRootSession: true, - winAppDriverBin: process.env.WINAPPDRIVERPATH, - webdriverOptions: { - connectionRetryCount: 5, - connectionRetryTimeout: 30000, - logLevel: 'error', - waitforTimeout: 30000, - }, - }, -}; diff --git a/apps/storybook/metro.config.js b/apps/storybook/metro.config.js index cc54345e807..c9c25a08897 100644 --- a/apps/storybook/metro.config.js +++ b/apps/storybook/metro.config.js @@ -1,63 +1,6 @@ const path = require('node:path'); -const { makeMetroConfig } = require('@rnx-kit/metro-config'); -const MetroSymlinksResolver = require('@rnx-kit/metro-resolver-symlinks'); -const { withStorybook } = require('@storybook/react-native/withStorybook'); +const { createDesktopStorybookMetroConfig } = require('@fluentui-react-native/storybook-desktop-runtime/metro'); -const symlinkResolver = MetroSymlinksResolver({ - resolver: 'oxc-resolver', -}); - -function resolvePlatformModule(moduleName, platform) { - if (platform !== 'win32') { - return moduleName; - } - - if (moduleName === 'react-native') { - return '@office-iss/react-native-win32'; - } - - if (moduleName.startsWith('react-native/')) { - return `@office-iss/react-native-win32/${moduleName.slice('react-native/'.length)}`; - } - - return moduleName; -} - -// JS-only replacement for react-native-safe-area-context (its native module does not build for -// react-native-macos 0.81 — see the stub file for details). -const safeAreaStub = path.resolve(__dirname, './src/storybook-mocks/react-native-safe-area-context.js'); - -const config = makeMetroConfig({ - resolver: { - resolveRequest: (context, moduleName, platform) => { - // Storybook liteMode mocks out the heavy default UI (`@storybook/react-native-ui`, which - // pulls reanimated/gesture-handler/bottom-sheet/svg). withStorybook does this by checking - // the resolved file path for "@storybook/react-native-ui", but under the Yarn pnpm linker - // resolved paths use ".store/@storybook-react-native-ui-virtual-*" (dashes), so that check - // never matches. Mock it here by import specifier instead. The trailing `/` / exact-match - // guard avoids also mocking `-ui-lite` and `-ui-common`. - if (moduleName === '@storybook/react-native-ui' || moduleName.startsWith('@storybook/react-native-ui/')) { - return { type: 'empty' }; - } - // Redirect react-native-safe-area-context to a JS-only stub (no incompatible native module). - if (moduleName === 'react-native-safe-area-context') { - return { type: 'sourceFile', filePath: safeAreaStub }; - } - return symlinkResolver(context, resolvePlatformModule(moduleName, platform), platform); - }, - unstable_enablePackageExports: true, - unstable_conditionNames: ['react-native', 'import', 'require'], - disableHierarchicalLookup: true, - enableSymlinks: true, - }, - transformer: { - unstable_allowRequireContext: true, - }, -}); - -module.exports = withStorybook(config, { +module.exports = createDesktopStorybookMetroConfig({ configPath: path.resolve(__dirname, 'src'), - // Lite mode mocks out the heavy default Storybook UI so we don't need react-native-reanimated, - // react-native-gesture-handler, @gorhom/bottom-sheet or react-native-svg. - liteMode: true, }); diff --git a/apps/storybook/package.json b/apps/storybook/package.json index d0f5d139944..4903ff03a73 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -8,53 +8,23 @@ "repository": { "type": "git", "url": "https://github.com/microsoft/fluentui-react-native.git", - "directory": "packages/agentic/components/storybook" + "directory": "apps/storybook" }, "scripts": { "start": "rnx-cli start", - "pods:macos": "pod install --project-directory=macos", - "pods:macos:update": "pod update --no-repo-update --project-directory=macos", - "macos:build": "xcodebuild -workspace macos/AgenticStorybook.xcworkspace -scheme AgenticStorybook -configuration Debug -destination 'platform=macOS' -derivedDataPath macos/DerivedData CODE_SIGNING_ALLOWED=NO build", - "macos:build:clean": "xcodebuild -workspace macos/AgenticStorybook.xcworkspace -scheme AgenticStorybook -configuration Debug -destination 'platform=macOS' -derivedDataPath macos/DerivedData CODE_SIGNING_ALLOWED=NO clean build", - "macos": "rnx-cli run --platform macos", - "windows": "rnx-cli run --platform windows", - "windows:cli": "react-native run-windows --arch x64 --sln windows/AgenticStorybook.sln", - "windows:info": "react-native run-windows --info --no-telemetry", - "windows:build": "react-native run-windows --arch x64 --sln windows/AgenticStorybook.sln --no-packager --no-deploy --no-launch --logging --no-telemetry --buildLogDirectory artifacts/windows/build-logs", - "windows:register": "pwsh -NoProfile -File scripts/register-windows-app.ps1", - "windows:deploy": "yarn windows:build && yarn windows:register", - "windows:launch-only": "pwsh -NoProfile -File scripts/launch-windows-app.ps1", - "windows:ci": "yarn bundle:windows && yarn windows:generate && yarn windows:build", - "windows:offline": "yarn bundle:windows && yarn windows:generate && yarn windows --release --no-packager --no-deploy --no-launch --msbuildprops UseBundle=false && pwsh -NoProfile -File scripts/run-windows-offline.ps1", - "windows:generate": "install-windows-test-app --use-fabric", - "windows:agent": "pwsh -NoProfile -File scripts/start-windows-agent-session.ps1 -RunSmokeTest", - "windows:agent:start": "pwsh -NoProfile -File scripts/start-windows-agent-session.ps1", - "windows:agent:stop": "pwsh -NoProfile -File scripts/stop-windows-agent-session.ps1", - "windows:test": "jest --config jest.windows.config.cjs --runInBand", - "bundle:macos": "yarn prebuild && rnx-cli bundle --dev false --platform macos", - "bundle:win32": "yarn prebuild:win32 && rnx-cli bundle --dev false --platform win32", - "bundle:win32:dev": "yarn prebuild:win32 && rnx-cli bundle --platform win32", - "bundle:windows": "yarn prebuild && rnx-cli bundle --dev false --platform windows", - "win32": "node scripts/run-win32.cjs", - "win32:ci": "yarn bundle:win32 && pwsh -NoProfile -File scripts/run-win32-ci.ps1", - "win32:ci:host": "node scripts/run-win32.cjs --ci", - "win32:dev": "node scripts/run-win32.cjs --dev", + "storybook": "storybook-desktop", + "storybook-server": "storybook-desktop server", + "desktop-driver": "desktop-driver", "prebuild": "sb-rn-get-stories --config-path src", - "prebuild:win32": "cross-env STORYBOOK_PLATFORM=win32 sb-rn-get-stories --config-path src", - "storybook-server": "node storybook-server.cjs", - "storybook-server:win32": "cross-env STORYBOOK_PLATFORM=win32 node storybook-server.cjs", - "storybook:control": "node scripts/storybook-control.cjs", - "storybook:smoke": "node scripts/storybook-control.cjs smoke", - "storybook:smoke:win32": "cross-env STORYBOOK_SMOKE_FAIL_FAST=1 STORYBOOK_SMOKE_SETTLE_MS=250 node scripts/storybook-control.cjs smoke", "lint": "fluentui-scripts lint", "format": "fluentui-scripts format" }, "dependencies": { "@fluentui-react-native/callout": "workspace:*", "@fluentui-react-native/components": "workspace:*", - "@fluentui-react-native/default-theme": "workspace:*", - "@fluentui-react-native/design": "workspace:*", "@fluentui-react-native/focus-zone": "workspace:*", + "@fluentui-react-native/storybook-desktop": "workspace:*", + "@fluentui-react-native/storybook-desktop-runtime": "workspace:*", "@office-iss/react-native-win32": "^0.81.0", "@types/react": "~19.1.4", "react": "19.1.4", @@ -65,39 +35,24 @@ }, "devDependencies": { "@babel/core": "catalog:", + "@fluentui-react-native/desktop-driver": "workspace:*", "@fluentui-react-native/scripts": "workspace:*", "@office-iss/rex-win32": "0.81.1", "@react-native-community/cli": "^20.0.0", "@react-native-community/cli-platform-android": "^20.0.0", "@react-native-community/cli-platform-ios": "^20.0.0", - "@react-native-windows/automation": "0.81.32", "@react-native-windows/cli": "^0.81.0", "@react-native/babel-preset": "^0.81.0", "@react-native/metro-babel-transformer": "^0.81.0", "@react-native/metro-config": "^0.81.0", "@rnx-kit/cli": "catalog:", - "@rnx-kit/metro-config": "catalog:", - "@rnx-kit/metro-resolver-symlinks": "catalog:", "@storybook/addon-ondevice-actions": "^10.4.7", "@storybook/addon-ondevice-controls": "^10.4.7", - "@storybook/mcp": "^0.7.0", - "@storybook/react": "^10.4.6", "@storybook/react-native": "^10.4.7", - "@storybook/react-native-theming": "^10.4.7", - "@storybook/react-native-ui-common": "^10.4.7", - "@storybook/react-native-ui-lite": "^10.4.7", - "@tmcp/adapter-valibot": "^0.1.6", - "@tmcp/transport-http": "^0.8.6", "cross-env": "catalog:", - "jest": "^29.7.0", - "jest-environment-node": "^29.7.0", "metro": "^0.83.8", - "oxc-resolver": "catalog:", "react-native-test-app": "catalog:", - "regexpu-core": "^6.3.1", - "storybook": "^10.4.0", - "tmcp": "^1.19.4", - "valibot": "^1.4.2" + "storybook": "^10.4.0" }, "furn": { "knip": { diff --git a/apps/storybook/scripts/launch-windows-app.ps1 b/apps/storybook/scripts/launch-windows-app.ps1 deleted file mode 100644 index 2a84bcb156d..00000000000 --- a/apps/storybook/scripts/launch-windows-app.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -param( - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Debug' -) - -$ErrorActionPreference = 'Stop' - -$packageRoot = Split-Path -Parent $PSScriptRoot -$manifestPath = Join-Path $packageRoot "windows\ReactApp.Package\bin\x64\$Configuration\AppxManifest.xml" - -if (-not (Test-Path -LiteralPath $manifestPath)) { - throw "$Configuration manifest not found at '$manifestPath'. Build and deploy the Windows app first." -} - -[xml]$manifest = Get-Content -LiteralPath $manifestPath -$identityName = [string]$manifest.Package.Identity.Name -$appId = [string]$manifest.Package.Applications.Application.Id -$registeredPackage = Get-AppxPackage -Name $identityName - -if (-not $registeredPackage) { - throw "Package '$identityName' is not registered. Run yarn windows:deploy first." -} - -$target = "shell:AppsFolder\$($registeredPackage.PackageFamilyName)!$appId" -Start-Process explorer.exe $target diff --git a/apps/storybook/scripts/register-windows-app.ps1 b/apps/storybook/scripts/register-windows-app.ps1 deleted file mode 100644 index 448cf062d36..00000000000 --- a/apps/storybook/scripts/register-windows-app.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -param( - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Debug' -) - -$ErrorActionPreference = 'Stop' - -$packageRoot = Split-Path -Parent $PSScriptRoot -$manifestPath = Join-Path $packageRoot "windows\ReactApp.Package\bin\x64\$Configuration\AppxManifest.xml" - -if (-not (Test-Path -LiteralPath $manifestPath)) { - throw "$Configuration manifest not found at '$manifestPath'. Build the Windows app first." -} - -[xml]$manifest = Get-Content -LiteralPath $manifestPath -$identityName = [string]$manifest.Package.Identity.Name -$installedPackage = Get-AppxPackage -Name $identityName - -if ($installedPackage) { - Remove-AppxPackage -Package $installedPackage.PackageFullName -} - -Add-AppxPackage -Register $manifestPath diff --git a/apps/storybook/scripts/run-win32-ci.ps1 b/apps/storybook/scripts/run-win32-ci.ps1 deleted file mode 100644 index adf52fdf32e..00000000000 --- a/apps/storybook/scripts/run-win32-ci.ps1 +++ /dev/null @@ -1,289 +0,0 @@ -$ErrorActionPreference = 'Stop' - -$packageRoot = Split-Path -Parent $PSScriptRoot -$artifactRoot = Join-Path $packageRoot 'artifacts\win32' -$logRoot = Join-Path $artifactRoot 'ci-logs' -$storybookPort = 7007 -$windowTitle = 'Agentic Components Storybook (Win32)' - -New-Item -ItemType Directory -Path $logRoot -Force | Out-Null - -Add-Type @' -using System; -using System.Runtime.InteropServices; -public static class StorybookNativeMouse -{ - [DllImport("user32.dll")] - public static extern bool SetCursorPos(int x, int y); - [DllImport("user32.dll")] - public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo); -} -'@ - -if (Get-NetTCPConnection -State Listen -LocalPort $storybookPort -ErrorAction SilentlyContinue) { - throw "Port $storybookPort is already in use." -} - -if ( - Get-Process -Name ReactTest -ErrorAction SilentlyContinue | - Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq $windowTitle } -) { - throw "A '$windowTitle' window is already running." -} - -function Start-YarnScript { - param( - [Parameter(Mandatory)] - [string]$Script, - [Parameter(Mandatory)] - [string]$LogName - ) - - return Start-Process -FilePath $env:ComSpec -ArgumentList "/d /s /c `"yarn $Script`"" -WorkingDirectory $packageRoot ` - -RedirectStandardOutput (Join-Path $logRoot "$LogName.out.log") ` - -RedirectStandardError (Join-Path $logRoot "$LogName.err.log") -PassThru -WindowStyle Hidden -} - -function Wait-ForTcpPort { - param( - [Parameter(Mandatory)] - [int]$Port, - [int]$TimeoutSeconds = 120 - ) - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - $client = [System.Net.Sockets.TcpClient]::new() - try { - $task = $client.ConnectAsync('127.0.0.1', $Port) - if ($task.Wait(500) -and $client.Connected) { - return - } - } finally { - $client.Dispose() - } - Start-Sleep -Milliseconds 250 - } - - throw "Timed out waiting for port $Port." -} - -function Wait-ForApp { - param([int]$TimeoutSeconds = 120) - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - $process = Get-Process -Name ReactTest -ErrorAction SilentlyContinue | - Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq $windowTitle } | - Select-Object -First 1 - if ($process) { - return $process - } - Start-Sleep -Milliseconds 500 - } - - throw "Timed out waiting for the '$windowTitle' window." -} - -function Find-AutomationElement { - param( - [Parameter(Mandatory)] - [System.Diagnostics.Process]$Process, - [Parameter(Mandatory)] - [string]$AutomationId - ) - - Add-Type -AssemblyName UIAutomationClient - $condition = New-Object System.Windows.Automation.PropertyCondition( - [System.Windows.Automation.AutomationElement]::AutomationIdProperty, - $AutomationId - ) - $root = [System.Windows.Automation.AutomationElement]::RootElement - $elements = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition) - return $elements | Where-Object { $_.Current.ProcessId -eq $Process.Id } | Select-Object -First 1 -} - -function Wait-ForAutomationId { - param( - [Parameter(Mandatory)] - [System.Diagnostics.Process]$Process, - [Parameter(Mandatory)] - [string]$AutomationId, - [int]$TimeoutSeconds = 30 - ) - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - if (Find-AutomationElement -Process $Process -AutomationId $AutomationId) { - return - } - Start-Sleep -Milliseconds 250 - $Process.Refresh() - } - - throw "Timed out waiting for automation id '$AutomationId'." -} - -function Wait-ForAutomationIdToClose { - param( - [Parameter(Mandatory)] - [System.Diagnostics.Process]$Process, - [Parameter(Mandatory)] - [string]$AutomationId, - [int]$TimeoutSeconds = 30 - ) - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - if (-not (Find-AutomationElement -Process $Process -AutomationId $AutomationId)) { - return - } - Start-Sleep -Milliseconds 250 - $Process.Refresh() - } - - throw "Timed out waiting for automation id '$AutomationId' to close." -} - -function Invoke-AutomationId { - param( - [Parameter(Mandatory)] - [System.Diagnostics.Process]$Process, - [Parameter(Mandatory)] - [string]$AutomationId - ) - - $element = Find-AutomationElement -Process $Process -AutomationId $AutomationId - if (-not $element) { - throw "Could not find automation id '$AutomationId'." - } - - $point = $element.GetClickablePoint() - [StorybookNativeMouse]::SetCursorPos([int]$point.X, [int]$point.Y) | Out-Null - [StorybookNativeMouse]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) - [StorybookNativeMouse]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) -} - -function Resize-AutomationId { - param( - [Parameter(Mandatory)] - [System.Diagnostics.Process]$Process, - [Parameter(Mandatory)] - [string]$AutomationId, - [int]$DeltaX = 0, - [int]$DeltaY = 0 - ) - - $element = Find-AutomationElement -Process $Process -AutomationId $AutomationId - if (-not $element) { - throw "Could not find resize automation id '$AutomationId'." - } - - $before = $element.Current.BoundingRectangle - $startX = [int]($before.X + ($before.Width / 2)) - $startY = [int]($before.Y + ($before.Height / 2)) - [StorybookNativeMouse]::SetCursorPos($startX, $startY) | Out-Null - [StorybookNativeMouse]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) - for ($step = 1; $step -le 5; $step += 1) { - [StorybookNativeMouse]::SetCursorPos( - $startX + [int](($DeltaX * $step) / 5), - $startY + [int](($DeltaY * $step) / 5) - ) | Out-Null - Start-Sleep -Milliseconds 50 - } - [StorybookNativeMouse]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) - Start-Sleep -Milliseconds 500 - - $after = (Find-AutomationElement -Process $Process -AutomationId $AutomationId).Current.BoundingRectangle - if ($DeltaX -ne 0 -and [Math]::Abs($after.X - $before.X) -lt 20) { - throw "Resize handle '$AutomationId' did not move horizontally." - } - if ($DeltaY -ne 0 -and [Math]::Abs($after.Y - $before.Y) -lt 20) { - throw "Resize handle '$AutomationId' did not move vertically." - } -} - -$ownedProcessIds = [System.Collections.Generic.List[int]]::new() - -function Add-OwnedProcess { - param([Parameter(Mandatory)][int]$Id) - - if (-not $ownedProcessIds.Contains($Id)) { - $ownedProcessIds.Add($Id) - } -} - -try { - $serverLauncher = Start-YarnScript -Script 'storybook-server:win32' -LogName 'storybook-server' - Add-OwnedProcess -Id $serverLauncher.Id - Wait-ForTcpPort -Port $storybookPort - - $serverProcessId = Get-NetTCPConnection -State Listen -LocalPort $storybookPort | - Select-Object -First 1 -ExpandProperty OwningProcess - Add-OwnedProcess -Id $serverProcessId - - $index = Invoke-RestMethod -Uri "http://127.0.0.1:$storybookPort/index.json" - $storyCount = @($index.entries.PSObject.Properties).Count - if ($storyCount -eq 0) { - throw 'The Win32 Storybook server exposed no stories.' - } - foreach ($requiredStoryId in @('primitives-callout--default', 'primitives-callout--placement', 'primitives-callout--window-commands')) { - if (-not $index.entries.PSObject.Properties[$requiredStoryId]) { - throw "The Win32 Storybook server did not expose required story '$requiredStoryId'." - } - } - - $hostLauncher = Start-YarnScript -Script 'win32:ci:host' -LogName 'win32-host' - Add-OwnedProcess -Id $hostLauncher.Id - $appProcess = Wait-ForApp - $appProcessInfo = Get-CimInstance Win32_Process -Filter "ProcessId=$($appProcess.Id)" - Add-OwnedProcess -Id $appProcessInfo.ParentProcessId - Add-OwnedProcess -Id $appProcess.Id - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-sidebar-header' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-sidebar-resize' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addons-panel-header' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addons-resize' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addon-storybook-actions-panel' - - Resize-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-sidebar-resize' -DeltaX 40 - Resize-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addons-resize' -DeltaY -40 - - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-hide-sidebar' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-desktop-toolbar' - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-popout-stories' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-story-drawer' - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-close-stories' - Wait-ForAutomationIdToClose -Process $appProcess -AutomationId 'agentic-storybook-win32-story-drawer' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-sidebar-header' - - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-hide-sidebar' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-desktop-toolbar' - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-popout-addons' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addons-drawer' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addon-rncontrols' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addon-storybook-actions-panel' - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addon-storybook-actions-panel' - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-close-addons' - Wait-ForAutomationIdToClose -Process $appProcess -AutomationId 'agentic-storybook-win32-addons-drawer' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-addons-panel-header' - Invoke-AutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-show-sidebar' - Wait-ForAutomationId -Process $appProcess -AutomationId 'agentic-storybook-win32-sidebar-header' - - & yarn storybook:smoke:win32 - if ($LASTEXITCODE -ne 0) { - throw 'Win32 Storybook smoke test failed.' - } - - if (-not (Get-Process -Id $appProcess.Id -ErrorAction SilentlyContinue)) { - throw 'The Win32 Storybook host exited during the smoke test.' - } -} finally { - for ($index = $ownedProcessIds.Count - 1; $index -ge 0; $index -= 1) { - $ownedProcessId = $ownedProcessIds[$index] - if ($ownedProcessId -and $ownedProcessId -ne $PID) { - if (Get-Process -Id $ownedProcessId -ErrorAction SilentlyContinue) { - Stop-Process -Id $ownedProcessId - } - } - } -} diff --git a/apps/storybook/scripts/run-windows-offline.ps1 b/apps/storybook/scripts/run-windows-offline.ps1 deleted file mode 100644 index 7de46e652b1..00000000000 --- a/apps/storybook/scripts/run-windows-offline.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -param( - [switch]$NoLaunch -) - -$ErrorActionPreference = 'Stop' - -$packageRoot = Split-Path -Parent $PSScriptRoot -$manifestPath = Join-Path $packageRoot 'windows\ReactApp.Package\bin\x64\Release\AppxManifest.xml' - -if (-not (Test-Path -LiteralPath $manifestPath)) { - throw "Release manifest not found at '$manifestPath'. Build the Windows Release app first." -} - -[xml]$manifest = Get-Content -LiteralPath $manifestPath -$identityName = [string]$manifest.Package.Identity.Name -$appId = [string]$manifest.Package.Applications.Application.Id - -$installedPackage = Get-AppxPackage -Name $identityName -if ($installedPackage) { - Remove-AppxPackage -Package $installedPackage.PackageFullName -} - -Add-AppxPackage -Register $manifestPath - -if (-not $NoLaunch) { - $registeredPackage = Get-AppxPackage -Name $identityName - $target = "shell:AppsFolder\$($registeredPackage.PackageFamilyName)!$appId" - Start-Process explorer.exe $target -} diff --git a/apps/storybook/scripts/smoke-stories.json b/apps/storybook/scripts/smoke-stories.json deleted file mode 100644 index 44f44f4b4ee..00000000000 --- a/apps/storybook/scripts/smoke-stories.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "storyId": "components-button--default", - "testId": "agentic-storybook-button", - "artifactName": "button-default" - }, - { - "storyId": "primitives-icon--default", - "testId": "agentic-storybook-icon", - "artifactName": "icon-default" - }, - { - "storyId": "native-callout--default", - "testId": "agentic-storybook-callout-trigger", - "statusTestId": "agentic-storybook-callout-status", - "artifactName": "callout-default" - } -] diff --git a/apps/storybook/scripts/start-windows-agent-session.ps1 b/apps/storybook/scripts/start-windows-agent-session.ps1 deleted file mode 100644 index 6093cdd12a9..00000000000 --- a/apps/storybook/scripts/start-windows-agent-session.ps1 +++ /dev/null @@ -1,163 +0,0 @@ -param( - [switch]$Generate, - [switch]$RunSmokeTest -) - -$ErrorActionPreference = 'Stop' - -$packageRoot = Split-Path -Parent $PSScriptRoot -$artifactRoot = Join-Path $packageRoot 'artifacts\windows' -$logRoot = Join-Path $artifactRoot 'logs' -$sessionPath = Join-Path $artifactRoot 'agent-session.json' -$storybookPort = if ($env:STORYBOOK_WS_PORT) { [int]$env:STORYBOOK_WS_PORT } else { 7007 } -$metroPort = 8081 -$defaultWinAppDriverPath = "${env:ProgramFiles(x86)}\Windows Application Driver\WinAppDriver.exe" -$localWinAppDriverPath = Join-Path $artifactRoot 'winappdriver\SourceDir\Windows Application Driver\WinAppDriver.exe' - -New-Item -ItemType Directory -Path $logRoot -Force | Out-Null - -function Start-YarnScript { - param( - [Parameter(Mandatory)] - [string]$Script, - [Parameter(Mandatory)] - [string]$LogName - ) - - $stdout = Join-Path $logRoot "$LogName.out.log" - $stderr = Join-Path $logRoot "$LogName.err.log" - $arguments = "/d /s /c `"yarn $Script`"" - - return Start-Process -FilePath $env:ComSpec -ArgumentList $arguments -WorkingDirectory $packageRoot ` - -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru -WindowStyle Hidden -} - -function Wait-ForTcpPort { - param( - [Parameter(Mandatory)] - [int]$Port, - [int]$TimeoutSeconds = 120 - ) - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - $client = [System.Net.Sockets.TcpClient]::new() - try { - $task = $client.ConnectAsync('127.0.0.1', $Port) - if ($task.Wait(500) -and $client.Connected) { - return - } - } catch { - } finally { - $client.Dispose() - } - Start-Sleep -Milliseconds 250 - } - - throw "Timed out waiting for port $Port." -} - -function Get-PortOwner { - param([Parameter(Mandatory)][int]$Port) - - return Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue | - Select-Object -First 1 -ExpandProperty OwningProcess -} - -function Wait-ForApp { - param( - [int[]]$ExcludedProcessIds = @(), - [int]$TimeoutSeconds = 120 - ) - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - $process = Get-Process -Name ReactApp -ErrorAction SilentlyContinue | - Where-Object { - $_.Id -notin $ExcludedProcessIds -and - $_.MainWindowHandle -ne 0 -and - $_.MainWindowTitle - } | - Select-Object -First 1 - if ($process) { - return $process - } - Start-Sleep -Milliseconds 500 - } - - throw 'Timed out waiting for the ReactApp window.' -} - -Push-Location $packageRoot -try { - if (Test-Path -LiteralPath $sessionPath) { - throw "An agent session manifest already exists at '$sessionPath'. Run yarn windows:agent:stop first." - } - - if ($RunSmokeTest) { - if (-not $env:WINAPPDRIVERPATH) { - if (Test-Path -LiteralPath $defaultWinAppDriverPath) { - $env:WINAPPDRIVERPATH = $defaultWinAppDriverPath - } elseif (Test-Path -LiteralPath $localWinAppDriverPath) { - $env:WINAPPDRIVERPATH = $localWinAppDriverPath - } else { - throw 'WinAppDriver 1.2.1 is required. Install it or set WINAPPDRIVERPATH before running windows:agent.' - } - } - } - - $solutionPath = Join-Path $packageRoot 'windows\AgenticStorybook.sln' - if ($Generate -or -not (Test-Path -LiteralPath $solutionPath)) { - & yarn windows:generate - if ($LASTEXITCODE -ne 0) { - throw 'Windows project generation failed.' - } - } - - $storybookProcess = Start-YarnScript -Script 'storybook-server' -LogName 'storybook-server' - Wait-ForTcpPort -Port $storybookPort - Invoke-RestMethod -Uri "http://127.0.0.1:$storybookPort/index.json" | Out-Null - - & yarn windows:deploy - if ($LASTEXITCODE -ne 0) { - throw 'Windows build or deployment failed.' - } - - $metroProcess = Start-YarnScript -Script 'start' -LogName 'metro' - Wait-ForTcpPort -Port $metroPort - - $existingAppIds = @(Get-Process -Name ReactApp -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id) - & yarn windows:launch-only - if ($LASTEXITCODE -ne 0) { - throw 'Windows launch failed.' - } - - $appProcess = Wait-ForApp -ExcludedProcessIds $existingAppIds - $session = [ordered]@{ - startedAt = (Get-Date).ToString('o') - storybookPort = $storybookPort - metroPort = $metroPort - appWindowTitle = $appProcess.MainWindowTitle - processes = @( - [ordered]@{ role = 'storybook-launcher'; id = $storybookProcess.Id } - [ordered]@{ role = 'storybook-server'; id = Get-PortOwner -Port $storybookPort } - [ordered]@{ role = 'metro-launcher'; id = $metroProcess.Id } - [ordered]@{ role = 'metro'; id = Get-PortOwner -Port $metroPort } - [ordered]@{ role = 'app'; id = $appProcess.Id } - ) - } - $session | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $sessionPath - - if ($RunSmokeTest) { - $env:STORYBOOK_WINDOWS_WINDOW_TITLE = $appProcess.MainWindowTitle - $env:STORYBOOK_WINDOWS_PROCESS_ID = $appProcess.Id - & yarn windows:test - if ($LASTEXITCODE -ne 0) { - throw "Windows smoke automation failed. The running session is recorded at '$sessionPath'." - } - } - - Write-Host "Windows agent session ready. Manifest: $sessionPath" -} finally { - Pop-Location -} diff --git a/apps/storybook/scripts/stop-windows-agent-session.ps1 b/apps/storybook/scripts/stop-windows-agent-session.ps1 deleted file mode 100644 index 29d324f0286..00000000000 --- a/apps/storybook/scripts/stop-windows-agent-session.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -$ErrorActionPreference = 'Stop' - -$packageRoot = Split-Path -Parent $PSScriptRoot -$sessionPath = Join-Path $packageRoot 'artifacts\windows\agent-session.json' - -if (-not (Test-Path -LiteralPath $sessionPath)) { - throw "No agent session manifest exists at '$sessionPath'." -} - -$session = Get-Content -LiteralPath $sessionPath -Raw | ConvertFrom-Json -$currentProcessId = $PID - -$session.processes | - Where-Object { $_.id -and $_.id -ne $currentProcessId } | - Select-Object -ExpandProperty id -Unique | - ForEach-Object { - $process = Get-Process -Id $_ -ErrorAction SilentlyContinue - if ($process) { - Stop-Process -Id $_ - } - } - -Remove-Item -LiteralPath $sessionPath -Write-Host 'Windows agent session stopped.' diff --git a/apps/storybook/scripts/storybook-client.cjs b/apps/storybook/scripts/storybook-client.cjs deleted file mode 100644 index 024f0660924..00000000000 --- a/apps/storybook/scripts/storybook-client.cjs +++ /dev/null @@ -1,58 +0,0 @@ -const DEFAULT_HOST = process.env.STORYBOOK_WS_HOST || '127.0.0.1'; -const DEFAULT_PORT = Number(process.env.STORYBOOK_WS_PORT) || 7007; - -function baseUrl() { - // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- the agent channel is loopback-only and intentionally has no TLS setup - return `http://${DEFAULT_HOST}:${DEFAULT_PORT}`; -} - -async function request(pathname, options) { - const response = await fetch(`${baseUrl()}${pathname}`, options); - const body = await response.json().catch(() => ({})); - - if (!response.ok) { - throw new Error(body.error || `Storybook server returned ${response.status}`); - } - - return body; -} - -async function getIndex() { - return request('/index.json'); -} - -async function sendEvent(type, ...args) { - return request('/send-event', { - body: JSON.stringify({ type, args }), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }); -} - -async function selectStory(storyId, attempts = 45) { - let lastError; - - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - return await request(`/select-story-sync/${encodeURIComponent(storyId)}`, { method: 'POST' }); - } catch (error) { - lastError = error; - if (attempt < attempts) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - } - - throw lastError; -} - -async function updateArgs(storyId, updatedArgs) { - return sendEvent('updateStoryArgs', { storyId, updatedArgs }); -} - -module.exports = { - getIndex, - selectStory, - sendEvent, - updateArgs, -}; diff --git a/apps/storybook/scripts/storybook-control.cjs b/apps/storybook/scripts/storybook-control.cjs deleted file mode 100644 index 38d8e4adc30..00000000000 --- a/apps/storybook/scripts/storybook-control.cjs +++ /dev/null @@ -1,72 +0,0 @@ -const { getIndex, selectStory, updateArgs } = require('./storybook-client.cjs'); - -function print(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -async function main() { - const [command = 'list', ...args] = process.argv.slice(2); - - if (command === 'list') { - const index = await getIndex(); - const entries = Object.values(index.entries || {}).map(({ id, name, title, type }) => ({ id, name, title, type })); - print(entries); - return; - } - - if (command === 'select') { - const [storyId] = args; - if (!storyId) { - throw new Error('Usage: yarn storybook:control select '); - } - print(await selectStory(storyId)); - return; - } - - if (command === 'args') { - const [storyId, json] = args; - if (!storyId || !json) { - throw new Error('Usage: yarn storybook:control args '); - } - print(await updateArgs(storyId, JSON.parse(json))); - return; - } - - if (command === 'smoke') { - const index = await getIndex(); - const entries = Object.values(index.entries || {}).filter(({ type }) => type === 'story'); - const failures = []; - const settleMilliseconds = Number(process.env.STORYBOOK_SMOKE_SETTLE_MS) || 0; - const failFast = process.env.STORYBOOK_SMOKE_FAIL_FAST === '1'; - - for (const { id } of entries) { - try { - await selectStory(id); - if (settleMilliseconds > 0) { - await new Promise((resolve) => setTimeout(resolve, settleMilliseconds)); - } - process.stdout.write(`rendered ${id}\n`); - } catch (error) { - failures.push({ id, error: error.message }); - process.stderr.write(`failed ${id}: ${error.message}\n`); - if (failFast) { - break; - } - } - } - - if (failures.length > 0) { - throw new Error(`${failures.length} of ${entries.length} stories failed to render`); - } - - print({ success: true, stories: entries.length }); - return; - } - - throw new Error(`Unknown command "${command}"`); -} - -main().catch((error) => { - process.stderr.write(`${error.message}\n`); - process.exitCode = 1; -}); diff --git a/apps/storybook/src/StorybookApp.tsx b/apps/storybook/src/StorybookApp.tsx index e1ed1b11880..5aba43dcf2e 100644 --- a/apps/storybook/src/StorybookApp.tsx +++ b/apps/storybook/src/StorybookApp.tsx @@ -1,40 +1,8 @@ -// `storybook.requires` is generated by the `withStorybook` metro wrapper (or the -// `storybook-generate` script) from the `main.ts` stories glob. It is git-ignored. -import { view } from './storybook.requires'; -import { StorybookThemeHost } from './StorybookTheme'; -import { StorybookUIComponent } from './StorybookUI'; - -// We run Storybook in lite mode: the heavy default on-device UI (`@storybook/react-native-ui`, -// which needs reanimated/gesture-handler/etc.) is not bundled. StorybookUI resolves to LiteUI on -// macOS and Windows and to the channel-driven preview surface on Win32. +import { createDesktopStorybookApp } from '@fluentui-react-native/storybook-desktop-runtime'; -// Simple in-memory storage so Storybook doesn't warn about a missing `storage` (we intentionally -// avoid @react-native-async-storage/async-storage, which is another native module). The selected -// story just won't persist across full reloads. -const memoryStore: Record = {}; -const storage = { - getItem: async (key: string) => (key in memoryStore ? memoryStore[key] : null), - setItem: async (key: string, value: string) => { - memoryStore[key] = value; - }, -}; - -// `enableWebsockets` + `host`/`port` connect the app to the standalone Storybook channel server -// (`yarn storybook-server`, default ws://127.0.0.1:7007). This lets external agents drive the app — -// select stories, read/update control args — and powers the MCP endpoint. If the server isn't -// running the transport just logs a connection error; the app still works standalone. -const StorybookUI = view.getStorybookUI({ - enableWebsockets: true, - host: '127.0.0.1', - port: 7007, - CustomUIComponent: StorybookUIComponent, - storage, -}); +import appManifest from '../app.json'; +import { view } from './storybook.requires'; -const StorybookApp = () => ( - - - -); +const StorybookApp = createDesktopStorybookApp(view, { testIDPrefix: appManifest.storybook.testIDPrefix }); export default StorybookApp; diff --git a/apps/storybook/src/main.ts b/apps/storybook/src/main.ts index de6a11f7ec5..0f7aa215340 100644 --- a/apps/storybook/src/main.ts +++ b/apps/storybook/src/main.ts @@ -1,24 +1,7 @@ -import type { StorybookConfig } from '@storybook/react-native'; +import type { DesktopReactNativeStorybookConfig } from '@fluentui-react-native/storybook-desktop/config'; -/** - * Storybook configuration for the agentic-components on-device app. - * - * Stories are loaded from the sibling agentic library and standalone native packages - * that are linked into this application. - */ -const stories = - process.env.STORYBOOK_PLATFORM === 'win32' - ? [ - '../../../packages/agentic/components/src/primitives/**/*.stories.?(ts|tsx)', - '../../../packages/agentic/components/src/components/!(accordion|list-item)/**/*.stories.?(ts|tsx)', - '../../../packages/native/Callout/src/**/*.stories.?(ts|tsx)', - ] - : ['../../../packages/agentic/components/src/**/*.stories.?(ts|tsx)', '../../../packages/native/Callout/src/**/*.stories.?(ts|tsx)']; +import config from '../storybook.config.mts'; -const main: StorybookConfig = { - stories, - addons: [], - deviceAddons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], -}; +const storybookConfig: DesktopReactNativeStorybookConfig = config.getStorybookConfig(); -export default main; +export default storybookConfig; diff --git a/apps/storybook/src/preview.tsx b/apps/storybook/src/preview.tsx index 347e0813b46..f433cd8fb8a 100644 --- a/apps/storybook/src/preview.tsx +++ b/apps/storybook/src/preview.tsx @@ -1,19 +1,5 @@ -import { View } from 'react-native'; -import type { Preview } from '@storybook/react-native'; +import { createDesktopStorybookPreview } from '@fluentui-react-native/storybook-desktop-runtime'; -import { StorybookThemeProvider } from './StorybookTheme'; - -const preview: Preview = { - decorators: [ - (Story) => ( - - - - - - ), - ], - parameters: {}, -}; +const preview = createDesktopStorybookPreview(); export default preview; diff --git a/apps/storybook/src/storybook-mocks/react-native-safe-area-context.js b/apps/storybook/src/storybook-mocks/react-native-safe-area-context.js deleted file mode 100644 index 552bdd0db39..00000000000 --- a/apps/storybook/src/storybook-mocks/react-native-safe-area-context.js +++ /dev/null @@ -1,35 +0,0 @@ -// Lightweight JS-only stub for `react-native-safe-area-context`. -// -// Storybook's on-device UI imports SafeAreaProvider / SafeAreaView / useSafeAreaInsets at module -// load time. The real package ships a Fabric native module that is iOS-only (UIKit) and uses a Yoga -// API that is incompatible with react-native-macos 0.81, so it fails to compile for macOS. Since -// safe-area insets are irrelevant for this on-device Storybook host, we alias the package to this -// stub via metro.config.js and disable its native autolinking in react-native.config.js. -const React = require('react'); -const { View } = require('react-native'); - -const insets = { top: 0, right: 0, bottom: 0, left: 0 }; -const frame = { x: 0, y: 0, width: 0, height: 0 }; - -const SafeAreaInsetsContext = React.createContext(insets); -const SafeAreaFrameContext = React.createContext(frame); - -const SafeAreaProvider = ({ children }) => React.createElement(View, { style: { flex: 1 } }, children); - -const SafeAreaView = React.forwardRef((props, ref) => React.createElement(View, { ref, ...props })); - -const useSafeAreaInsets = () => insets; -const useSafeAreaFrame = () => frame; - -const initialWindowMetrics = { insets, frame }; - -module.exports = { - SafeAreaProvider, - SafeAreaConsumer: SafeAreaInsetsContext.Consumer, - SafeAreaInsetsContext, - SafeAreaFrameContext, - SafeAreaView, - useSafeAreaInsets, - useSafeAreaFrame, - initialWindowMetrics, -}; diff --git a/apps/storybook/storybook-server.cjs b/apps/storybook/storybook-server.cjs deleted file mode 100644 index e349a5053dd..00000000000 --- a/apps/storybook/storybook-server.cjs +++ /dev/null @@ -1,33 +0,0 @@ -// Standalone Storybook channel server for the agentic-components Storybook app. -// -// This lets external agents drive the running on-device Storybook app: -// - WebSocket channel (ws://:/): select the current story, read/update control -// args, and receive Storybook channel events. The on-device app connects to this server -// (configured via getStorybookUI({ enableWebsockets: true, host, port }) in src/StorybookApp.tsx). -// - MCP endpoint (http://:/mcp): an MCP server for AI agents to query story / -// component documentation and metadata (enabled via experimental_mcp). -// -// We run this standalone (instead of via withStorybook) because the bundler-agnostic -// `withStorybook` only starts the channel server in entry-point-swapping mode -// (STORYBOOK_ENABLED=true), which is incompatible with this app's in-app integration. -// -// Usage: `yarn storybook-server` (run alongside `yarn start` + `yarn macos`). -const path = require('node:path'); -const { createChannelServer } = require('@storybook/react-native/node'); - -const host = process.env.STORYBOOK_WS_HOST || '127.0.0.1'; -const port = Number(process.env.STORYBOOK_WS_PORT) || 7007; - -createChannelServer({ - host, - port, - configPath: path.resolve(__dirname, 'src'), - websockets: true, - experimental_mcp: true, - keepNodeProcessAlive: true, -}); - -// eslint-disable-next-line no-console -console.log(`Storybook channel server listening: - WebSocket : ws://${host}:${port}/ - MCP : http://${host}:${port}/mcp`); diff --git a/apps/storybook/storybook.config.mts b/apps/storybook/storybook.config.mts new file mode 100644 index 00000000000..926dcc6f44b --- /dev/null +++ b/apps/storybook/storybook.config.mts @@ -0,0 +1,59 @@ +import { + createWindowsSmokeOptions, + createWin32RunCommand, + createWin32SmokeCommand, + makeDesktopStorybookConfig, +} from '@fluentui-react-native/storybook-desktop/config'; + +import appManifest from './app.json' with { type: 'json' }; + +const win32Host = { + component: 'AgenticStorybook', + windowTitle: 'Agentic Components Storybook (Win32)', +} as const; + +export default makeDesktopStorybookConfig({ + projectRoot: new URL('.', import.meta.url), + storyPackages: [ + [ + '@fluentui-react-native/components', + { + platformSettings: { + windows: { + storyPatterns: ['src/primitives/**/*.stories.?(ts|tsx)', 'src/components/!(accordion)/**/*.stories.?(ts|tsx)'], + }, + win32: { + storyPatterns: ['src/primitives/**/*.stories.?(ts|tsx)', 'src/components/!(accordion|list-item)/**/*.stories.?(ts|tsx)'], + }, + }, + }, + ], + [ + '@fluentui-react-native/callout', + { + platformSettings: { + windows: { + storyPatterns: [], + }, + }, + }, + ], + ], + platformOptions: { + windows: { + smoke: createWindowsSmokeOptions({ + windowTitle: 'Agentic Components Storybook', + }), + }, + win32: { + run: createWin32RunCommand(win32Host), + smoke: { + command: createWin32SmokeCommand({ + ...win32Host, + testIDPrefix: appManifest.storybook.testIDPrefix, + requiredStoryIds: ['native-callout--default', 'native-callout--placement', 'native-callout--window-commands'], + }), + }, + }, + }, +}); diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index df7e6d2a8c4..2e379f55aa6 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@fluentui-react-native/scripts/tsconfig", "compilerOptions": { + "allowImportingTsExtensions": true, "noEmit": true, "jsx": "react-jsx", "module": "esnext", @@ -11,9 +12,12 @@ "composite": true, "tsBuildInfoFile": ".cache/tsconfig.tsbuildinfo" }, - "include": ["index.js", "src", "app.json"], + "include": ["index.js", "src", "app.json", "storybook.config.mts"], "exclude": ["node_modules", "dist", ".cache"], "references": [ + { + "path": "../../packages/agentic/storybook-desktop/tsconfig.json" + }, { "path": "../../packages/native/Callout/tsconfig.json" }, @@ -21,16 +25,16 @@ "path": "../../packages/agentic/components/tsconfig.json" }, { - "path": "../../packages/theming/default-theme/tsconfig.json" + "path": "../../packages/components/FocusZone/tsconfig.json" }, { - "path": "../../packages/agentic/design/tsconfig.json" + "path": "../../scripts/tsconfig.json" }, { - "path": "../../packages/components/FocusZone/tsconfig.json" + "path": "../../packages/agentic/storybook-desktop-runtime/tsconfig.json" }, { - "path": "../../scripts/tsconfig.json" + "path": "../../packages/agentic/desktop-driver/tsconfig.json" } ] } diff --git a/apps/storybook/windows-tests/storybook-smoke.test.cjs b/apps/storybook/windows-tests/storybook-smoke.test.cjs deleted file mode 100644 index 7b382574552..00000000000 --- a/apps/storybook/windows-tests/storybook-smoke.test.cjs +++ /dev/null @@ -1,121 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const { app } = require('@react-native-windows/automation'); -const smokeStories = require('../scripts/smoke-stories.json'); -const { getIndex, selectStory } = require('../scripts/storybook-client.cjs'); - -const artifactsDirectory = path.join(__dirname, '..', 'artifacts', 'windows', 'automation'); - -beforeAll(async () => { - fs.mkdirSync(artifactsDirectory, { recursive: true }); - await getIndex(); -}); - -test.each(smokeStories)('renders $storyId with stable native selectors', async ({ artifactName, statusTestId, storyId, testId }) => { - await selectStory(storyId); - - const element = await app.findElementByTestID(testId); - await element.waitForDisplayed({ timeout: 30000 }); - const displayed = await element.isDisplayed(); - expect(displayed).toBe(true); - - let statusText; - if (statusTestId) { - const statusElement = await app.findElementByTestID(statusTestId); - await statusElement.waitForDisplayed({ timeout: 30000 }); - statusText = await statusElement.getText(); - expect(statusText).toBe('Native window: Shown'); - } - - fs.writeFileSync( - path.join(artifactsDirectory, `${artifactName}.json`), - `${JSON.stringify({ storyId, testId, displayed, statusTestId, statusText }, null, 2)}\n`, - ); -}); - -test('moves focus between Button Overview controls after a click', async () => { - await selectStory('components-button--overview'); - - const primary = await app.findElementByTestID('agentic-storybook-button-overview-primary'); - const secondary = await app.findElementByTestID('agentic-storybook-button-overview-secondary'); - await primary.waitForDisplayed({ timeout: 30000 }); - await secondary.waitForDisplayed({ timeout: 30000 }); - - await primary.click(); - await browser.keys(['\uE004']); - - expect(await secondary.getAttribute('HasKeyboardFocus')).toBe('True'); -}); - -const calloutPlacements = [ - { - hint: 'topCenter', - isCorrect: (trigger, callout) => callout.y + callout.height <= trigger.y, - }, - { - hint: 'rightCenter', - isCorrect: (trigger, callout) => callout.x >= trigger.x + trigger.width, - }, - { - hint: 'bottomCenter', - isCorrect: (trigger, callout) => callout.y >= trigger.y + trigger.height, - }, - { - hint: 'leftCenter', - isCorrect: (trigger, callout) => callout.x + callout.width <= trigger.x, - }, -]; - -test.each(calloutPlacements)('anchors the Callout in the $hint direction', async ({ hint, isCorrect }) => { - await selectStory('components-button--default'); - await selectStory('native-callout--placement'); - - const trigger = await app.findElementByTestID(`agentic-storybook-callout-placement-${hint}-trigger`); - await trigger.waitForDisplayed({ timeout: 30000 }); - await trigger.click(); - - const callout = await app.findElementByTestID('agentic-storybook-callout-placement-content'); - await callout.waitForDisplayed({ timeout: 30000 }); - - const [triggerLocation, triggerSize, calloutLocation, calloutSize] = await Promise.all([ - trigger.getLocation(), - trigger.getSize(), - callout.getLocation(), - callout.getSize(), - ]); - - expect(isCorrect({ ...triggerLocation, ...triggerSize }, { ...calloutLocation, ...calloutSize })).toBe(true); -}); - -test.each([ - ['components-tag--default', 'agentic-storybook-tag'], - ['components-accordion--default', 'accordion-header'], - ['components-tab--selected', 'agentic-storybook-tab-selected'], - ['components-listboxitem--default', 'agentic-storybook-listbox-item'], - ['components-checkbox--default', 'agentic-storybook-checkbox'], - ['components-menuitem--selected', 'agentic-storybook-menu-item'], - ['components-listitem--selected-focus', 'agentic-storybook-list-item-selected'], - ['components-radio--default', 'agentic-storybook-radio'], - ['components-switch--default', 'agentic-storybook-switch'], -])('focuses %s without terminating the app', async (storyId, testId) => { - await selectStory(storyId); - - const element = await app.findElementByTestID(testId); - await element.waitForDisplayed({ timeout: 30000 }); - await element.click(); - await new Promise((resolve) => setTimeout(resolve, 3000)); - - expect(await element.getAttribute('HasKeyboardFocus')).toBe('True'); -}); - -test('focuses the interactive Card without terminating the app', async () => { - await selectStory('components-card--interactive'); - - const card = await app.findElementByXPath('//Button[@Name="Open report"]'); - await card.waitForDisplayed({ timeout: 30000 }); - await card.click(); - await new Promise((resolve) => setTimeout(resolve, 3000)); - - expect(await card.getAttribute('HasKeyboardFocus')).toBe('True'); -}); diff --git a/packages/agentic/components/AGENTS.md b/packages/agentic/components/AGENTS.md index a6d9ed1d5a3..72526f95a51 100644 --- a/packages/agentic/components/AGENTS.md +++ b/packages/agentic/components/AGENTS.md @@ -9,6 +9,9 @@ invariants; detailed authoring recipes live in the - Higher-order components live in `src/components`; read `src/components/AGENTS.md`. - Primitive components live in `src/primitives`; read `src/primitives/AGENTS.md`. - Story files are library source and follow the tests and stories reference. +- Portable desktop story tests are static `parameters.desktopDriver` plans. + Use the public authoring types, stable `testID` selectors, declarative + capability requirements, and no platform branches or executable callbacks. - Storybook application, native project, Metro, bundle, or CocoaPods work follows `storybook/AGENTS.md` and the `agentic-storybook-development` skill. - Native React Native Windows Fabric component work follows the @@ -23,5 +26,7 @@ invariants; detailed authoring recipes live in the - Export higher-order components and public types explicitly from `src/index.ts`; export primitives and their public types explicitly from `src/primitives/index.ts`. Never use wildcard exports. - Colocate runtime tests, type tests, and Storybook stories with the implementation. +- Keep desktop story plans inline and statically extractable; do not hide them + behind variables, spreads, functions, or computed values. - Use package scripts for format, lint, build, tests, and snapshots. - Do not copy web-only APIs, CSS behavior, or DOM assumptions into React Native. diff --git a/packages/agentic/components/package.json b/packages/agentic/components/package.json index 1cce81c8e7a..6d2ab79f66f 100644 --- a/packages/agentic/components/package.json +++ b/packages/agentic/components/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "@babel/core": "catalog:", + "@fluentui-react-native/desktop-driver": "workspace:*", "@fluentui-react-native/scripts": "workspace:*", "@react-native-community/cli": "^20.0.0", "@react-native-community/cli-platform-android": "^20.0.0", diff --git a/packages/agentic/components/src/components/AGENTS.md b/packages/agentic/components/src/components/AGENTS.md index f7bd6fcde11..d7086d2331f 100644 --- a/packages/agentic/components/src/components/AGENTS.md +++ b/packages/agentic/components/src/components/AGENTS.md @@ -42,6 +42,10 @@ audit. layout. - Test both paths of a self-driving axis, and test that an externally driven `selected` does not change on press. - Keep a self-driving controlled prop out of story `args`, and keep an identity-changing axis out of story controls. +- Author desktop automation under `parameters.desktopDriver` as static data + satisfying `DesktopStoryTests`. Use one stable `testID` per interacted + element, declare capabilities, and assert public native semantics rather than + implementation structure. ## Focused references diff --git a/packages/agentic/components/src/components/button/button.stories.tsx b/packages/agentic/components/src/components/button/button.stories.tsx index 23d37df9da6..c9977294adc 100644 --- a/packages/agentic/components/src/components/button/button.stories.tsx +++ b/packages/agentic/components/src/components/button/button.stories.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import type { DesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; import { Button } from './button'; import type { ButtonAppearance, ButtonShape, ButtonSize } from './button.types'; @@ -75,7 +76,29 @@ export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'pointer-focus', + title: 'Responds to activation and receives focus', + requires: ['element-screenshot', 'focus'], + steps: [ + { action: 'wait', target: { testId: 'agentic-storybook-button' } }, + { expect: { state: 'role', target: { testId: 'agentic-storybook-button' }, value: 'button' } }, + { expect: { state: 'enabled', target: { testId: 'agentic-storybook-button' }, value: true } }, + { action: 'click', target: { testId: 'agentic-storybook-button' } }, + { expect: { state: 'focused', target: { testId: 'agentic-storybook-button' }, value: true } }, + { action: 'screenshot', name: 'button-focused', target: { testId: 'agentic-storybook-button' } }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; export const Overview: Story = { render: () => ( diff --git a/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx b/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx index 0d53a006f80..7c03a22d74d 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx +++ b/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import type { DesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; import { Checkbox } from './checkbox'; import type { CheckboxStatus, CheckboxVariant } from './checkbox.types'; @@ -63,7 +64,30 @@ export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'toggles-checked-state', + title: 'Toggles through native activation', + steps: [ + { action: 'wait', target: { testId: 'agentic-storybook-checkbox' } }, + { expect: { state: 'role', target: { testId: 'agentic-storybook-checkbox' }, value: 'checkbox' } }, + { expect: { state: 'checked', target: { testId: 'agentic-storybook-checkbox' }, value: false } }, + { action: 'click', target: { testId: 'agentic-storybook-checkbox' } }, + { + action: 'wait', + until: { state: 'checked', target: { testId: 'agentic-storybook-checkbox' }, value: true }, + }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; export const Overview: Story = { render: () => ( diff --git a/packages/agentic/components/src/components/input/input.stories.tsx b/packages/agentic/components/src/components/input/input.stories.tsx index aa0d1ab307c..8d21bd740a7 100644 --- a/packages/agentic/components/src/components/input/input.stories.tsx +++ b/packages/agentic/components/src/components/input/input.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 type { DesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; import { directComponent } from '@fluentui-react-native/framework-base'; import type { IconElementProps } from '../../primitives/icon/icon.types'; @@ -47,6 +48,7 @@ const meta: Meta = { args: { placeholder: 'Search files', size: 'medium', + testID: 'agentic-storybook-input', variant: 'outline', }, argTypes: { @@ -67,7 +69,29 @@ export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'types-and-clears', + title: 'Accepts keyboard input and clears it', + requires: ['keyboard'], + steps: [ + { action: 'wait', target: { testId: 'agentic-storybook-input' } }, + { expect: { state: 'role', target: { testId: 'agentic-storybook-input' }, value: 'textbox' } }, + { action: 'type', target: { testId: 'agentic-storybook-input' }, text: 'Ada' }, + { expect: { state: 'value', target: { testId: 'agentic-storybook-input' }, value: 'Ada' } }, + { action: 'clear', target: { testId: 'agentic-storybook-input' } }, + { expect: { state: 'value', target: { testId: 'agentic-storybook-input' }, value: '' } }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; export const Overview: Story = { render: () => ( diff --git a/packages/agentic/components/tsconfig.json b/packages/agentic/components/tsconfig.json index adc3c0349cb..b39bacc96bb 100644 --- a/packages/agentic/components/tsconfig.json +++ b/packages/agentic/components/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../design/tsconfig.json" }, + { + "path": "../desktop-driver/tsconfig.json" + }, { "path": "../../framework-base/tsconfig.json" }, diff --git a/packages/agentic/desktop-driver/AGENTS.md b/packages/agentic/desktop-driver/AGENTS.md new file mode 100644 index 00000000000..97b843a0bce --- /dev/null +++ b/packages/agentic/desktop-driver/AGENTS.md @@ -0,0 +1,68 @@ +# Desktop driver development + +These instructions apply to `packages/agentic/desktop-driver`. + +## Module ownership + +- `protocol/` owns W3C parsing, errors, capabilities, actions, and deadlines. +- `server/` owns target/session/window/element state and HTTP routing. +- `host/` defines the platform-neutral native contract. +- `hosts/fake/` is the deterministic Stage 1 provider. +- `authoring/` owns serializable plan and result contracts. +- `runner/` executes plans and classifies outcomes. +- `wdio/` is the sanctioned high-level automation integration. +- `agent/` exposes bounded JSON-safe operations. +- `artifacts/` confines and atomically persists evidence. +- `cli/` parses commands and adapts structured APIs to JSON and exit codes. +- `testing/` owns reusable fake harnesses, not production fallbacks. + +## Invariants + +- Do not import Storybook, React, or React Native from this package. +- Keep protocol, client, authoring, runner, evidence, and fake-host code + platform-neutral. +- Put future operating-system integrations behind `DesktopHost`; do not branch + on `process.platform` outside host-provider selection. +- Keep the W3C server client-neutral. WebdriverIO belongs only in `wdio/`, + agent/CLI composition, and contract tests. +- Register targets on the server. Never accept arbitrary commands, environment + variables, or output paths from WebDriver capabilities. +- Permit one active session per target and reserve the target before any async + launch/attach work. +- Serialize commands per session and serialize all physical input across + sessions. Never dispatch reset, release, deletion, or another action around + an in-flight command. +- Preserve attached applications and clean up only resources recorded as owned. +- Apply deadlines to probe, launch, host command, cleanup, and dispose paths. +- Honor every `DesktopHost` `AbortSignal`: stop side effects and settle promptly + before the command queue advances. A provider may never complete input after + timeout cleanup. +- Release depressed input on failure, cancellation, and session deletion. +- Reject browser-origin requests and non-loopback serving by default. + +## Authored plans + +- Plans are versioned static JSON under `parameters.desktopDriver`. +- Keep selectors, steps, capability requirements, results, manifests, CLI + output, and agent output serializable. +- Reject unknown fields and dynamic values rather than silently dropping them. +- Never translate an unsupported native property into `false`. +- Use stable `testID` selectors for deterministic actions; role/name selectors + validate accessibility semantics. +- Keep platform differences declarative through `platforms`, `requires`, and + explicit skip results. + +## Evidence and agents + +- Confine every artifact beneath the configured run root. +- Preserve the original test failure when evidence capture also fails. +- Bound agent tree depth and node count. +- Do not expose native handles or unrestricted command execution. +- The Storybook MCP remains separate; do not add a schema-only MCP claim. + +## Validation + +Run the package's declared format, lint, build, and test scripts. Contract tests +must cover raw W3C, the typed client, WebdriverIO custom commands, JSON CLI, +agent operations, artifacts, target concurrency, shutdown races, and +representative Storybook plans before repository-level validation. diff --git a/packages/agentic/desktop-driver/PLAN.md b/packages/agentic/desktop-driver/PLAN.md new file mode 100644 index 00000000000..f17191a1438 --- /dev/null +++ b/packages/agentic/desktop-driver/PLAN.md @@ -0,0 +1,1394 @@ +# Desktop Driver Plan + +## Status + +Active architecture and implementation plan. This document starts from the +current checked-out tree and public platform/protocol documentation. It does +not depend on work from other branches. + +The effort starts with the platform-neutral protocol, fake host, Storybook +orchestration, WebdriverIO authoring, and agent contracts. Windows and macOS +native code is an explicit later stage, so native transport, signing, and +distribution choices do not block the initial implementation. + +## Implementation status + +Updated 2026-08-28. + +### Stage 1 Phase 1: Complete + +- Added the public `@fluentui-react-native/desktop-driver` package and repository + project references. +- Implemented W3C response/error routing, capability negotiation, + server-registered targets, one-session-per-target reservation, sessions, + timeouts, windows, elements, actions, screenshots, source, and unsupported + browser-command handling. +- Implemented stable WebDriver element references, native liveness checks, + preview-scoped staleness, configurable click modes, input-state tracking, + action validation, element-origin resolution, per-session command queues, + a global input mutex, abortable host deadlines, drained runner cancellation, + and ownership-safe shutdown. +- Added the deterministic fake host, typed low-level client, raw HTTP contract + coverage, and a WebdriverIO remote-session contract with no Appium service. +- Portable fake-host coverage exercises element lookup, click, text entry, + actions, waits, screenshots, stale references, concurrent session rejection, + and shutdown during session creation. + +### Stage 1 Phase 2: Complete + +- Added exact-platform Story Manifest generation with statically extracted, + validated `parameters.desktopDriver` plans, relocatable source paths, + platform digests, and portable-plan digests. +- Added a per-enlistment driver port and generated driver manifest containing + target identity, test-ID prefix, nonce, catalog digests, and service ports. +- Added authenticated, bridge-only runtime hello/readiness/error events with + request/run correlation, explicit hello challenges, same-story reset, and + preview generation. +- Added stable native app and story-root markers, native marker verification, + preview-only element invalidation, and a keyed per-run remount/error boundary. +- Added Storybook selection, reset, manifest, current-story, and args extension + commands to the WebDriver session. +- Added `storybook-desktop manifest`, `instance`, and `driver` flows. The + `driver` supervisor runs Metro plus separate Storybook and WebDriver listeners + while keeping both server protocols in one Node process. +- Added an embedded-server integration test and verified the live Windows + Stage 1 supervisor exposes equivalent 136-story channel and driver manifests. +- All macOS, Windows, and Win32 JavaScript bundles include the runtime bridge. + +### Stage 1 Phase 3: Complete + +- Finalized strict static plan, selector, action, assertion, capability, + platform, result, test, step, and artifact contracts under `/authoring`. +- Added deterministic filtering and sharding plus complete fake-host execution + for clicks, text entry, key/action sequences, scrolling, waits, Storybook + args, screenshots, source, and semantic assertions. +- Added the sanctioned `/wdio` API and typed browser commands for listing, + opening, resetting, asserting, and running story plans without Appium. +- Added confined atomic artifacts, host metadata, `run.json`, and automatic + screenshot, source, and compact-tree failure evidence. +- Added the bounded `/agent` API for listing, explaining, inspecting, finding, + acting, checking, capturing, and running the same story plans. +- Added the JSON `desktop-driver` CLI for fake serving, story list/explain/run, + sharding, evidence, and bounded agent describe/screenshot operations. +- Added manifest-derived fake elements and repeatable per-test state reset so + real component plans can run repeatedly in Stage 1. +- Added typed, statically extractable `desktop-e2e` plans to Button, Checkbox, + and Input. All three extract from real CSF and pass repeatedly through the + sanctioned WebdriverIO runner. +- Evaluated MCP integration and deferred a composed executable adapter until + Stage 2 proves the native command and security model; a schema-only claim is + explicitly insufficient. +- Updated package, Storybook, runtime, app, component, skill, and agent + documentation for the final Stage 1 responsibilities. + +### Remaining work + +Stage 1 is complete; no Phase 1, Phase 2, or Phase 3 deliverables are left +incomplete. + +- Stage 2 remains: implement Windows/Win32 and macOS native host providers and + replace the Stage 1 fake target in native runs. +- Stage 3 remains: release hardening, native artifact ownership, security + review, and CI promotion. +- On-device bridge execution is intentionally deferred to Stage 2; Phase 2 is + validated through the fake host, runtime/server contract tests, live + same-process services, and production bundles. + +## Outcome + +Create a public `@fluentui-react-native/desktop-driver` package that: + +- implements a useful, explicitly documented subset of the W3C WebDriver + Classic protocol without Appium; +- drives React Native macOS, React Native Windows Fabric, and React Native + Win32 Paper applications; +- exposes platform-neutral window, accessibility-tree, input, screenshot, and + diagnostics APIs; +- formalizes the device contracts required by desktop Storybook applications; +- lets component authors declare portable tests alongside stories; +- runs those tests as end-to-end automation or exposes the same operations to + validation agents; +- reuses the existing Storybook channel server for story discovery, selection, + and render events; +- avoids requiring another long-running Node server process for Storybook. + +## Non-goals + +- Do not expose Appium client APIs, Appium capabilities, or the Appium CLI. +- Do not emulate browser-only behavior such as navigation, cookies, frames, + shadow roots, JavaScript execution, prompts, or printing. +- Do not make Storybook a dependency of the generic driver. +- Do not require Jest or a particular agent protocol for authored story tests. + WebdriverIO is the sanctioned high-level automation API. +- Do not use visible text, layout order, or native class names as the stable + selector contract. +- Do not include visual-regression baseline comparison or migration of the + legacy E2E harness in the initial effort; both are future considerations. + +## Architectural decisions + +### Package topology + +Create one new public package: + +```text +@fluentui-react-native/desktop-driver +``` + +Do not initially create either `desktop-driver-server` or +`storybook-desktop-server`. + +`desktop-driver` owns both an embeddable W3C remote end and a standalone CLI. +The server is central to the package rather than an independently useful +product boundary. Keep internal `protocol`, `server`, `client`, and `host` +seams so a server package can be extracted later without changing public +contracts. + +`storybook-desktop` remains the owner of Storybook configuration, the channel +server, Metro and native app lifecycle, platform selection, generated +manifests, and Storybook-specific orchestration. It depends on +`desktop-driver`, registers a Storybook target and orchestration adapter, and +starts the embedded driver listener. + +`storybook-desktop-runtime` remains React Native-only. It exposes the native +story root and sends versioned readiness/error messages over the existing +Storybook channel. It does not host WebDriver or import Node APIs. + +```text +component story + -- type-only --> desktop-driver/authoring + +desktop-driver + -- no dependency --> Storybook, React, React Native, or private apps + +storybook-desktop + --> desktop-driver + +storybook-desktop-runtime + --> Storybook channel only + +apps/storybook + --> storybook-desktop + --> storybook-desktop-runtime +``` + +### Server and process model + +The existing Storybook server remains the Storybook control plane: + +- story index and documentation; +- WebSocket channel; +- story selection; +- Storybook events; +- existing MCP endpoint. + +The W3C remote end uses a separate loopback port because the current upstream +channel server constructs and owns its HTTP server and handles unmatched +requests itself. Mounting WebDriver routes into that listener would couple the +driver to upstream internals and risk conflicting responses. + +Both listeners should run in the same `storybook-desktop` Node process: + +```text +storybook-desktop supervisor process + |- Storybook HTTP/WebSocket/MCP listener + |- WebDriver HTTP listener + |- Metro child process, when needed + |- native host transport, when needed + `- owned application process or attached application lease +``` + +This meets the goal of avoiding another long-running server process while +keeping the two protocols isolated. A non-Storybook application can run the +same WebDriver remote end through the standalone `desktop-driver` CLI. + +Create a separate server package only if one of these triggers occurs: + +1. a consumer needs the server without the client, authoring, and testing APIs; +2. remote host deployment requires a release cadence independent of the + package; +3. native artifacts make the server install materially heavier than the + client; +4. authentication, TLS, or fleet management becomes a separate product + concern. + +### Target registration + +Clients select a server-registered target: + +```json +{ + "capabilities": { + "alwaysMatch": { + "browserName": "furn-native-desktop", + "platformName": "windows", + "furn:target": "agentic-storybook-windows", + "furn:launchMode": "attach" + } + } +} +``` + +Capabilities must not accept arbitrary executable paths, command arguments, +environment variables, manifest paths, or artifact roots. Target definitions +are registered when the server starts and resolve to controlled launch/attach +providers and a confined artifact root. + +An explicit local-development mode may allow ad hoc targets later, but it must +be disabled by default and unavailable to agent-facing APIs. + +### V1 support scope + +V1 supports: + +- Windows 11 x64; +- macOS 14 on Apple Silicon; +- Windows Fabric, Win32 Paper, and macOS Storybook endpoints; +- exactly one active session per physical target. + +Broader operating-system, architecture, and concurrency support is deferred. + +## Package responsibilities + +### `desktop-driver` + +Own: + +- W3C routing, response envelopes, errors, and capability processing; +- session, timeout, window, input, and element state; +- server-side target registry; +- platform-neutral host contract; +- native host transport protocol; +- WebDriver element identity and staleness; +- typed low-level client; +- sanctioned WebdriverIO runner, configuration, matchers, and custom commands; +- generic serializable story-test schema and runner primitives; +- screenshots, artifacts, logs, and diagnostics; +- token-efficient agent operations; +- deterministic fake host and protocol conformance harness. + +Do not depend at runtime on: + +- Appium; +- Storybook; +- React or React Native; +- a private application package. + +### `storybook-desktop` + +Own: + +- a generated platform-specific Story Manifest; +- a Storybook implementation of the driver's `StoryOrchestrator` interface; +- authenticated/correlated channel messages; +- Storybook extension commands; +- one supervisor for channel, Metro, driver, app, and test lifecycle; +- driver port allocation in the existing per-enlistment instance identity; +- Storybook test-plan extraction and digest generation; +- machine-readable readiness output; +- `driver`, `test`, and `agent` CLI flows. + +### `storybook-desktop-runtime` + +Own: + +- a stable native application/root marker; +- a stable native story-canvas marker; +- native-observable current story and preview generation; +- runtime hello, story-ready, story-error, and reset acknowledgements; +- a per-test remount boundary keyed by run ID; +- render-error forwarding. + +### Consuming Storybook app + +Own: + +- target registration and native identity; +- story package discovery and platform exclusions; +- exceptional launch/run commands; +- artifact root; +- concrete `testID` prefix; +- pilot story tests; +- cross-package contract tests. + +## W3C remote-end contract + +Describe the package as a **W3C WebDriver Classic-compatible native desktop +remote end**, not a conforming browser remote end. Unsupported browser commands +return `unsupported operation`; they never return fabricated success values. + +### Initial standard endpoints + +Implement: + +- `GET /status`; +- `POST /session` and `DELETE /session/{id}`; +- `GET|POST /session/{id}/timeouts`; +- current window, window handles, switch window, close window; +- get/set window rectangle where the host reports support; +- find element(s) from the window or an element; +- active element; +- element name/role, text, attributes, properties, rectangle, enabled, and + selected state where supported; +- click, clear, and send keys; +- perform and release actions; +- window screenshot and element screenshot; +- normalized accessibility source. + +Return `unsupported operation` for: + +- navigation and history; +- cookies; +- frames and shadow roots; +- arbitrary JavaScript execution; +- browser prompts; +- printing; +- CSS values; +- new-window creation until native semantics are specified. + +Do not repurpose the standard `pageLoad` timeout for story readiness. Add +namespaced driver timeouts: + +```ts +type DesktopTimeouts = { + appLaunch: number; + nativeCommand: number; + storyRender: number; + stableLayout: number; +}; +``` + +### Capability negotiation + +Implement W3C `alwaysMatch` and ordered `firstMatch` processing, including: + +- extension capability names containing `:`; +- rejection of duplicate keys during merge; +- ordered candidate evaluation; +- `session not created` when no target/provider can satisfy a candidate; +- truthful returned capabilities based on the selected host. + +Use: + +- `platformName: "macos"` or `"windows"` for the operating system; +- `furn:endpoint: "macos" | "windows" | "win32"` for the repository endpoint; +- `furn:renderer: "fabric" | "paper"` for renderer semantics; +- `furn:target` for the registered target; +- `furn:clickMode: "physical" | "accessibility" | "auto"` for environment- + appropriate element-click behavior; +- `furn:features` for negotiated input, tree, state, screenshot, and window + capabilities. + +Return a standard capability only when its semantics are implemented. + +### Errors + +Map native failures to specific WebDriver errors: + +| Condition | WebDriver error | +| --------------------------------------------- | --------------------------- | +| target cannot launch or attach | `session not created` | +| missing/closed session | `invalid session id` | +| missing/closed window | `no such window` | +| lookup does not resolve | `no such element` | +| retained native node is detached/replaced | `stale element reference` | +| malformed locator | `invalid selector` | +| disabled, unfocusable, or empty-bounds target | `element not interactable` | +| another node owns the hit-tested point | `element click intercepted` | +| capture backend fails | `unable to capture screen` | +| deadline expires | `timeout` | +| capability/property/operation is unavailable | `unsupported operation` | + +Error `data` may contain redacted native error codes, operation names, and +artifact IDs. It must not expose environment variables, arbitrary paths, or +private window content. + +### Element identity and staleness + +Expose only session-generated UUIDs under the standard key: + +```text +element-6066-11e4-a52e-4f735466cecf +``` + +Never expose UIA runtime IDs, AX references, React tags, HWNDs, or accessibility +paths as public element IDs. + +Each stored element records: + +- native handle; +- application and window; +- logical scope: `application`, `chrome`, `preview`, or `secondary-window`; +- preview generation, when applicable; +- diagnostic locator fingerprint. + +Every element command performs a cheap liveness check. A story reset increments +the preview generation and invalidates preview elements only. Storybook chrome +and still-live secondary-window elements remain valid. + +Do not reconstruct a missing native object from a role/index path. Re-resolving +to a different object must produce staleness rather than silently changing the +meaning of an existing WebDriver reference. + +### Selectors + +The portable authoring API exposes: + +```ts +by.testId('button-primary'); +by.role('button', { name: 'Save' }); +by.accessibleName('Save'); +by.text('Saved'); +``` + +Wire strategies in the first release: + +- `accessibility id` as a documented extension mapping to `testID`; +- `tag name` mapping to normalized native role; +- `link text` and `partial link text` mapping to accessible name only where + those standard semantics are meaningful. + +Defer CSS and XPath. Do not redefine CSS for a non-DOM tree, and do not add the +cost and brittleness of normalized XML/XPath until a concrete client need is +demonstrated. + +Deterministic authored tests use `testID`. Role and accessible name are +important for accessibility validation and agent exploration, but are not a +replacement for stable IDs. + +### State + +Native platforms expose different state sets. Absence must never become a +false-shaped passing assertion. + +```ts +type SupportedValue = { supported: true; value: T } | { supported: false; reason: string }; +``` + +Normalize, when supported: + +- automation ID; +- accessible name and help; +- role and native role; +- value/text; +- enabled; +- focused/focusable; +- selected, checked/mixed, and expanded; +- visible/offscreen; +- logical rectangle; +- supported accessibility actions/patterns. + +The runner checks declared capabilities before a test. Unsupported required +state produces an explicit skip or unsupported result, not a passing +assertion. + +### Input + +Standard `element.click()` uses the session's negotiated click mode: + +```ts +type ClickMode = 'physical' | 'accessibility' | 'auto'; +``` + +- `physical` performs real pointer input and is the default for local + component validation; +- `accessibility` invokes the native accessibility action and is intended for + environments such as CI where physical input is blocked; +- `auto` prefers physical input and falls back to accessibility activation + only when the host reports that physical input is unavailable. + +The selected mode is returned in `furn:features`. Session creation fails when +the requested mode is unsupported, rather than silently changing interaction +semantics. Accessibility mode is necessarily capability-limited because not +every React Native control projects an activation action. + +Physical click executes: + +1. validate liveness; +2. scroll into view when supported; +3. activate the owning window; +4. refresh bounds; +5. compute an in-view point; +6. hit-test the point; +7. reject interception; +8. send pointer down/up. + +An explicit extension command may invoke accessibility activation regardless +of the session default for accessibility-focused validation. + +Implement W3C Actions with: + +- key, mouse pointer, wheel, and null sources; +- tick grouping and duration; +- viewport, pointer, and element origins; +- depressed key/button tracking; +- Release Actions on normal teardown, timeout, cancellation, and host failure. + +There is one global input mutex per physical desktop. V1 permits exactly one +active session per physical target. + +Public rectangles use logical points/DIPs relative to the current window client +area. Hosts privately convert to screen pixels using window origin, frame +insets, Windows DPI, Retina backing scale, and virtual-desktop origin. Capture +metadata records both logical and pixel dimensions and the scale factor. + +### Screenshots + +Standard screenshot commands return Base64 PNG: + +- session screenshot: current native window content; +- element screenshot: current window capture cropped to the visible element + bounds; +- window decorations excluded by default. + +Extensions may request: + +- window frame inclusion; +- a named artifact; +- all windows, returned as an artifact manifest; +- display capture for diagnostics. + +The platform-neutral stage uses fake captures to establish protocol and +artifact behavior. Real screenshot support arrives with the later native-host +stage. Windows native work includes occlusion-independent HWND capture before +the Windows screenshot capability is advertised. + +### Diagnostics and artifacts + +Provide namespaced commands for: + +- compact JSON accessibility tree; +- full normalized tree/source; +- host and permission diagnostics; +- recent driver, host, story, input, and device events; +- named screenshots and evidence bundles; +- Storybook manifest and current story state. + +Suggested failure bundle: + +```text +artifacts/desktop-driver// + run.json + host.json + sessions// + commands.ndjson + windows.json + source.xml + tree.json + screenshots/ + logs/ + stories/// + result.json + before.png + failure.png +``` + +Result status distinguishes: + +- passed; +- assertion failed; +- skipped unsupported capability; +- timed out; +- cancelled; +- app crashed; +- driver/host failed; +- configuration failed; +- permission failed. + +## Platform-neutral host contract + +The protocol layer depends only on an injected host: + +```ts +interface DesktopHost { + readonly endpoint: 'macos' | 'windows' | 'win32'; + + probe(): Promise; + launch(target: RegisteredTarget): Promise; + attach(target: RegisteredTarget): Promise; + + windows(app: ApplicationLease): Promise; + activate(window: DesktopWindow): Promise; + getWindowRect(window: DesktopWindow): Promise; + setWindowRect(window: DesktopWindow, rect: Partial): Promise; + + find(root: DesktopRoot, selector: NativeSelector, options: FindOptions): Promise; + snapshot(element: NativeElement): Promise; + isAlive(element: NativeElement): Promise; + hitTest(window: DesktopWindow, point: Point): Promise; + + performActions(actions: readonly NativeActionTick[]): Promise; + releaseActions(): Promise; + + captureWindow(window: DesktopWindow): Promise; + captureRect(window: DesktopWindow, rect: Rect): Promise; + + subscribe(listener: DesktopHostEventListener): Disposable; + dispose(): Promise; +} +``` + +`ApplicationLease` records: + +- `launched` or `attached` ownership; +- PID and process creation time; +- target identity; +- known windows; +- graceful close behavior. + +Attached apps are preserved by default. Cleanup uses exact owned resource +records, never process-name matching. + +Host events should include: + +- structure changed; +- focus/property changed; +- window opened/closed; +- app exited; +- host transport failed. + +The host transport begins with a versioned handshake containing protocol and +helper versions, endpoint, architecture, capabilities, and permission state. +A host crash invalidates the session and produces an infrastructure failure; it +is not silently restarted during a test. + +## Native implementation staging + +Keep the transport replaceable behind `DesktopHost`. Do not make an unproven +FFI library or an unsigned native binary a permanent API decision. + +The initial stage contains no Windows or macOS native code. It delivers the +complete protocol, fake host, Storybook integration, WebdriverIO authoring +surface, agent API, and native-host contract using TypeScript/Node only. + +Native platform providers are a separate second delivery stage: + +- Windows 11 x64 and Win32 Paper share a Windows provider built around UI + Automation, configurable physical/accessibility interaction, app/window + ownership, and occlusion-independent Windows Graphics Capture; +- macOS 14 on Apple Silicon receives a provider selected to preserve the + required local and hosted-CI authority while implementing the same host + contract; +- native build, signing, notarization, and artifact distribution are scoped to + that stage rather than prerequisites for the platform-neutral package. + +### Current constraints + +- package installation scripts are disabled; +- new dependencies must satisfy the repository age policy; +- public packages are built and packed on Linux; +- the current publish pipeline does not build, sign, or notarize Windows/macOS + native artifacts; +- current macOS E2E uses a Mac2/XCTest substrate on hosted macOS CI; +- current Win32 Storybook smoke uses in-box Windows UI Automation from + PowerShell on hosted Windows CI. + +### Native-stage feasibility gates + +Evaluate at least these options against the same host contract: + +| Endpoint | Candidate | Purpose | +| ------------- | ---------------------------------------------------- | -------------------------------------------------------- | +| Windows/Win32 | long-lived PowerShell UIA worker with P/Invoke input | zero-published-binary baseline | +| Windows/Win32 | C++/WinRT helper using UIA, SendInput, and WGC | highest-fidelity capture and typed native implementation | +| macOS local | direct AX/CGEvent transport | fast developer attach loop, requires TCC | +| macOS CI | first-party XCTest-based transport | preserve hosted-CI automation without Appium | +| macOS | Swift helper using AX, CGEvent, and ScreenCaptureKit | stable native implementation if build/signing is funded | + +Before native implementation begins, the stage must answer: + +- Can the candidate be built, packaged, and invoked through declared repository + scripts without install-time compilation? +- What identity receives Accessibility and Screen Recording permission? +- Can hosted CI grant or inherit the required authority? +- Can it enumerate and interact with current app windows? +- Does physical pointer/keyboard input reach React Native controls? +- Can it capture composited window content when occluded, scaled, and spread + across monitors? Occlusion-independent capture is required for the Windows + provider in this stage. +- Can it capture secondary Callout windows? +- What is its cold-start and command latency? +- How are native errors and events represented? + +Current direction: + +- keep these platform experiments out of the initial implementation; +- start the native Windows/Win32 stage with a long-lived PowerShell UIA worker + as a contract probe because the current tree proves that substrate can + inspect Win32 on hosted CI; +- implement Windows.Graphics.Capture or equivalent direct HWND capture as part + of that same native stage before advertising full screenshot support; +- retain an XCTest-backed macOS CI transport unless a non-Appium replacement + proves the same hosted-runner authority; +- allow a raw AX/CGEvent macOS provider for local attach workflows; +- introduce signed Swift/C++ helpers only within the native stage and only + after build/sign/notarization and artifact-package ownership are approved. + +Using XCTest as an internal host transport does not make Appium part of the +authoring or wire contract; the package still owns the W3C server, sessions, +capabilities, errors, and public APIs. + +## Storybook device contract + +### Instance manifest + +Extend the current per-enlistment instance model with one generated manifest: + +```ts +type DesktopStorybookDriverManifest = { + schemaVersion: 1; + instanceId: string; + endpoint: 'macos' | 'windows' | 'win32'; + renderer: 'fabric' | 'paper'; + targetId: string; + appName: string; + displayName: string; + testIDPrefix: string; + storybookPort: number; + metroPort: number; + driverPort: number; + platformManifestDigest: string; + portablePlanDigest: string; + bridgeNonce: string; +}; +``` + +This generated projection becomes the single source for the runtime, +supervisor, server target, and smoke/test commands. Its `testIDPrefix` +originates from the consuming app's custom `app.json` Storybook identity, so +the app does not maintain a second identity file or duplicate runtime setting. + +Use two digests: + +1. `platformManifestDigest` covers the exact platform catalog and plans; +2. `portablePlanDigest` covers only explicitly portable stories/tests and + excludes physical paths and platform-only metadata. + +Manifests contain package names and package-relative POSIX paths. Absolute +package roots stay in process memory and are excluded from digests and agent +output. + +### Required native markers + +Every desktop Storybook endpoint must expose: + +1. a stable application/root marker; +2. a stable story canvas/root `testID`; +3. the active story ID in native-observable state; +4. the preview generation or run ID in native-observable state. + +Only the story root is a universal chrome contract. Do not require macOS or +Windows to fork upstream LiteUI merely to expose the Win32-specific sidebar, +addon, or resize-handle IDs. + +### Runtime bridge + +The runtime sends versioned channel events: + +```ts +type DesktopBridgeEvent = + | { + type: 'furn:desktop:hello'; + version: 1; + instanceId: string; + endpoint: 'macos' | 'windows' | 'win32'; + targetId: string; + platformManifestDigest: string; + nonce: string; + } + | { + type: 'furn:desktop:story-ready'; + requestId: string; + runId: string; + storyId: string; + previewGeneration: number; + portablePlanDigest: string; + } + | { + type: 'furn:desktop:story-error'; + requestId: string; + runId: string; + storyId: string; + message: string; + }; +``` + +The supervisor rejects wrong instance, endpoint, target, digest, nonce, +duplicate bridge, and stale reconnect messages. + +Story selection: + +1. authenticate the runtime hello; +2. validate the story against the platform manifest; +3. create request and run IDs; +4. issue selection through the existing Storybook channel; +5. await the correlated runtime `story-ready`; +6. verify the native story marker and preview generation; +7. wait for the native canvas root; +8. invalidate prior preview element references; +9. optionally wait for stable layout. + +Each test receives a fresh run ID and remount boundary. The runtime resets local +story state and, when requested, Storybook args before acknowledging readiness. + +### Story Manifest + +`storybook-desktop` generates a platform-specific manifest from the same story +configuration used by the app. It must preserve data not guaranteed by the +current `/index.json` response: + +- canonical story ID; +- package name and package-relative source path; +- platform membership; +- authored tags; +- extracted `parameters.desktopDriver`; +- capability requirements; +- exact-platform and portable-plan digests. + +Static extraction fails loudly with file and location when a test plan is not +serializable. It must never silently omit an authored plan. + +Pass the manifest to the embedded driver in memory or through an owned +generated file. Do not introduce a fourth manifest HTTP listener. + +## Component-authored story tests + +The primary contract is a versioned, statically serializable plan in story +parameters: + +```tsx +import type { DesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; + +export const Default: Story = { + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'pointer-and-keyboard-focus', + title: 'Supports pointer and keyboard focus', + requires: ['pointer', 'keyboard', 'focus'], + steps: [ + { + expect: { + target: { testId: 'button-primary' }, + state: 'enabled', + }, + }, + { + action: 'click', + target: { testId: 'button-primary' }, + }, + { + expect: { + target: { testId: 'button-primary' }, + state: 'focused', + }, + }, + { + action: 'keys', + value: ['TAB'], + }, + { + action: 'screenshot', + name: 'keyboard-focus', + }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; +``` + +Initial actions: + +- wait for target/state/stable layout; +- click and double-click; +- clear and type; +- key and W3C action sequences; +- scroll; +- update Storybook args; +- screenshot; +- capture tree/source; +- annotate evidence. + +Initial assertions: + +- exists/count; +- displayed; +- enabled; +- focused/focusable; +- selected/checked/mixed/expanded; +- accessible name/help; +- role; +- value/text; +- bounds; +- active element. + +Rules: + +- no platform branches inside a portable plan; +- differences use declarative `requires`, platform inclusion, and explicit skip + reasons; +- selectors use stable IDs for deterministic interaction; +- plans are hashable, listable before app launch, shardable, and + agent-readable; +- the test runner distinguishes unsupported capability from failed assertion. + +Reserve a later imperative escape hatch for cases the DSL cannot express. It +must be an explicitly referenced React Native-free module, marked nonportable +or less agent-readable, and must not enter component package build/publish +output accidentally. Do not add it until real authored tests demonstrate the +need. + +## Public APIs + +### Typed client + +```ts +const client = await createDesktopDriverClient({ url: ready.webdriverUrl }); +const session = await client.newSession({ + platformName: 'windows', + 'furn:target': 'agentic-storybook-windows', +}); + +const story = await session.storybook.open('components-button--default'); +const button = await story.find(by.testId('button-primary')); + +await button.click(); +await button.waitFor({ focused: true }); +await story.screenshot({ name: 'focused-button' }); +await story.runTest('pointer-and-keyboard-focus'); +await session.delete(); +``` + +The high-level client calls the same W3C and extension routes available to +external clients. + +### WebdriverIO automation API + +WebdriverIO is the sanctioned high-level test API for component authors and +automation suites. The package provides supported configuration, typed custom +commands, selectors, matchers, lifecycle integration, and Storybook commands. +The serializable story-plan DSL runs through this same WebdriverIO integration. + +The W3C server remains client-neutral and does not require Appium. Validate it +with: + +- a raw HTTP protocol suite; +- the low-level typed client; +- the sanctioned WebdriverIO remote client and runner. + +WebdriverIO is a supported dependency of the high-level testing surface, while +the protocol server modules remain independent of it. + +### Agent API + +Expose coarse, JSON-safe operations: + +```ts +agent.listStories(); +agent.openStory(storyId); +agent.describe({ scope: 'canvas', depth: 3 }); +agent.find({ testId: 'button-primary' }); +agent.click({ testId: 'button-primary' }); +agent.type({ testId: 'input', text: 'hello' }); +agent.check({ testId: 'button-primary', role: 'button', enabled: true }); +agent.screenshot({ scope: 'window', name: 'button' }); +agent.runStoryTest(storyId, testId); +agent.getArtifacts(); +agent.dispose(); +``` + +`describe` returns a bounded projection containing role, name, `testID`, +supported state, bounds, and child count. Lookup failures may include bounded +nearest-ID suggestions. + +Ship the typed API and JSON CLI first. MCP requires an actual executable +adapter, not only a tool-schema file. Later, add an MCP route to the driver +listener or a composed adapter owned by `storybook-desktop`; do not create a +separate MCP package. + +### CLI and supervisor + +`desktop-driver`: + +```text +desktop-driver serve +desktop-driver doctor --target --json +desktop-driver tree --session --json +desktop-driver screenshot --session --output +``` + +`storybook-desktop`: + +```text +storybook-desktop driver --windows +storybook-desktop test --windows [--story ] [--tag ] +storybook-desktop agent --windows +storybook-desktop manifest --windows +storybook-desktop instance --windows --json +``` + +The Storybook supervisor: + +1. resolves platform and instance identity; +2. generates manifests; +3. starts the channel server and embedded driver listener; +4. starts Metro when needed; +5. registers the exact target; +6. launches or attaches the app; +7. authenticates the runtime bridge; +8. runs tests or writes agent-ready connection data; +9. releases input and tears down only owned resources. + +## Implemented package shape + +```text +packages/agentic/desktop-driver/ + AGENTS.md + PLAN.md + README.md + SPEC.md + package.json + tsconfig.json + jest.config.cjs + config/ + cli.cjs + src/ + index.ts + authoring/ + index.ts + results.ts + storyTests.ts + artifacts/ + ArtifactManager.ts + index.ts + client/ + DesktopDriverClient.ts + index.ts + cli/ + createDesktopDriverCommand.ts + index.ts + protocol/ + actions.ts + capabilities.ts + constants.ts + errors.ts + timeouts.ts + types.ts + server/ + createDesktopDriverServer.ts + index.ts + SessionManager.ts + TargetRegistry.ts + host/ + types.ts + hosts/ + fake/FakeDesktopHost.ts + runner/ + index.ts + StoryTestRunner.ts + wdio/ + DesktopWebdriver.ts + index.ts + agent/ + DesktopAgent.ts + index.ts + testing/ + FakeStoryOrchestrator.ts + fakeStoryElements.ts + index.ts + protocolHarness.ts +``` + +The later native stage adds `hosts/windows` and `hosts/macos`, plus any +platform artifact packages approved by the native distribution design. + +Potential subpath exports: + +- `.`; +- `./authoring`; +- `./artifacts`; +- `./client`; +- `./cli`; +- `./server`; +- `./agent`; +- `./runner`; +- `./testing`; +- `./wdio`; +- `./package.json`. + +Use explicit named exports. Keep platform code under `hosts`. The package +`AGENTS.md` should require no Storybook imports, no platform branching outside +host providers, exact ownership cleanup, and declared-script validation. + +When implementation starts, conform to repository package rules: + +- `build` is `tsc -b`; +- composite TypeScript output and build info are configured; +- workspace dependencies and project references match; +- the root project references the package; +- dependencies satisfy catalog and package-age policy; +- publishing checks and a changeset are included. + +## Milestones + +### Stage 1: Platform-neutral foundation + +Stage 1 intentionally contains no Windows or macOS native code. + +#### Phase 1: W3C core and fake host - Complete + +Deliver: + +- package skeleton; +- W3C router and response/error model; +- capability negotiation; +- target/session/window/element stores; +- timeouts and input state machine; +- deterministic fake host; +- raw HTTP, typed-client, and WebdriverIO contract tests. + +Exit: + +- a client creates and deletes a session; +- portable element, action, wait, and screenshot tests pass against the fake + host; +- unsupported routes return explicit W3C errors. + +#### Phase 2: Storybook manifests, bridge, and supervisor - Complete + +Deliver: + +- instance/driver manifest and driver port; +- Story Manifest and static test-plan extraction; +- `StoryOrchestrator` adapter; +- correlated runtime bridge and native story marker; +- per-test remount/reset; +- `storybook-desktop` supervisor; +- Storybook extension commands. + +Exit: + +- a fake host can select/reset a story and run a declarative plan; +- stale preview elements are deterministic; +- exact-platform and portable-plan digests are checked; +- no additional Node server process is required. + +#### Phase 3: WebdriverIO, authoring, and agent surface - Complete + +Deliver: + +- public serializable story-plan schema; +- representative plans validated against the fake host; +- sanctioned WebdriverIO configuration, runner, custom commands, and matchers; +- low-level typed client; +- JSON CLI and bounded agent API; +- standardized reports and failure bundles; +- optional real MCP adapter evaluation. + +Exit: + +- an author can declare, list, shard, and run a plan through WebdriverIO against + the fake host; +- an agent can list, explain, execute, and diagnose the same plan; +- protocol, authoring, artifacts, and agent APIs are stable before platform + code is introduced. + +### Stage 2: Native desktop providers + +Stage 2 implements the platform contracts proven in Stage 1. + +#### Phase 4: Windows and Win32 native provider - Not started + +Deliver: + +- selected Windows host transport; +- UI Automation tree and event support; +- configurable physical and accessibility click modes; +- physical keyboard, pointer, and wheel actions; +- app attach/launch leases; +- occlusion-independent HWND capture through Windows Graphics Capture or an + equivalent native implementation; +- multi-window and Callout handling. + +Exit: + +- one unchanged Button, Checkbox, Input, scrolling, screenshot, and + secondary-window suite passes on Windows Fabric and Win32 Paper; +- attached apps survive teardown; +- exact owned resources are cleaned; +- artifacts distinguish assertion, app, and host failures. + +Land the first platform jobs as non-required until reliability and artifact +quality are established. + +#### Phase 5: macOS native provider - Not started + +Deliver: + +- selected local and CI transport(s); +- accessibility tree and events; +- configurable physical and accessibility click modes; +- keyboard and pointer actions; +- bundle-identity launch/attach; +- direct window capture; +- permission diagnostics; +- XCTest-backed provider if required to preserve hosted CI. + +Exit: + +- the unchanged portable WebdriverIO suite passes on macOS 14 Apple Silicon; +- missing authority fails before session creation with actionable diagnostics; +- attached apps survive teardown; +- the chosen CI environment is repeatable. + +### Stage 3: Release hardening + +#### Phase 6: Release readiness - Not started + +Deliver: + +- protocol compatibility suite; +- security review; +- performance/timeout budgets; +- clean-install and package-pack validation; +- package-size review; +- helper signing/notarization and artifact packages if selected; +- documentation, changeset, and CI promotion criteria. + +Exit: + +- public package contents are reproducible; +- native artifacts have an owned build/signing pipeline; +- no required install scripts are needed; +- supported platform jobs are promotable to required gates. + +## Validation matrix + +| Capability | macOS | Windows Fabric | Win32 Paper | +| ------------------------- | ---------------------------- | ---------------------------- | -------------------------------------- | +| attach and preserve | bundle/window identity | process/AUMID/HWND | process/HWND | +| launch and owned cleanup | provider-defined | packaged activation | prebuilt-host provider | +| `testID` lookup | verify AX mapping | verify UIA mapping | current UIA smoke establishes baseline | +| role/name/state | AX/XCTest | UIA | UIA | +| pointer input | CGEvent/XCTest | SendInput | SendInput | +| keyboard/Unicode | CGEvent/XCTest | SendInput | SendInput | +| wheel/scroll | provider capability | SendInput/UIA | SendInput/UIA | +| window screenshot | SCK/XCTest/provider | WGC/provider | WGC/provider | +| element screenshot | crop with scale | crop with DPI | crop with DPI | +| multiple windows | app windows | HWNDs | REX/Callout HWNDs | +| story select/reset | channel bridge | channel bridge | channel bridge | +| stale preview detection | generation + native liveness | generation + native liveness | generation + native liveness | +| app/render error | bridge + process watch | bridge + process watch | bridge + process watch | +| permission/desktop doctor | TCC/test authority | interactive session/UIPI | interactive session/UIPI | + +Also validate: + +- Windows 11 x64; +- macOS 14 on Apple Silicon; +- Windows 100%, 150%, and 200% scaling; +- Retina and non-Retina macOS where supported; +- multiple monitors and non-primary virtual-desktop origins; +- light, dark, and high-contrast themes; +- foreground, background, minimized, and occluded windows; +- denied macOS permissions; +- elevated Windows targets; +- locked/disconnected desktops; +- duplicate matching windows; +- secondary Callout windows; +- channel reconnect, duplicate runtime, stale nonce, and wrong digest; +- app and host failure during a command; +- port collision and parallel enlistments; +- raw HTTP, first-party, and WebdriverIO clients. + +Pilot stories: + +- Button pointer and keyboard focus; +- Checkbox checked/indeterminate state; +- Input type and clear; +- scrollable content; +- a secondary Callout window; +- a controlled failure for artifact verification. + +## Security and reliability + +- Bind loopback only by default. +- Reject browser-origin requests; do not enable permissive CORS. +- Require explicit authentication and configuration for any non-loopback bind. +- Use server-registered targets, not client-supplied commands. +- Scope trees and screenshots to registered target windows. +- Cap request body size, tree depth/node count, screenshot dimensions, command + deadlines, and retained logs. +- Confine artifact paths beneath an owned run root; reject absolute paths, + traversal, Windows device/alternate-stream paths, and symlink escapes. +- Redact environment variables and physical roots from public diagnostics. +- Record PID plus process creation time. +- Never kill by process name or fixed port. +- Preserve attached applications. +- Release all input state on every teardown path. +- Run automation only in an interactive desktop session. +- Treat screenshots and accessibility trees as potentially sensitive evidence. + +## Principal risks + +| Risk | Mitigation | +| ------------------------------------------------------------ | --------------------------------------------------------------- | +| platform state projection differs | capability-gated assertions; unsupported is not false | +| native element identity changes on remount | session UUIDs, liveness checks, preview generations | +| physical input is global and flaky | one input owner, foreground verification, serialized actions | +| macOS authority differs locally and in CI | native-stage dual-transport evaluation and fail-fast doctor | +| composited-window capture is backend-specific | direct capture gate before advertising screenshots | +| native artifacts cannot be built by current publish pipeline | keep binaries off critical path until an owned pipeline exists | +| Storybook channel is broadcast-oriented | nonce/instance/digest handshake plus native marker verification | +| static test extraction misses dynamic values | literal schema and loud file/location errors | +| authored DSL becomes too limited | add an imperative escape hatch only from demonstrated cases | +| agent/server can control a real desktop | loopback, origin rejection, target registry, bounded APIs | +| Storybook upgrades change channel behavior | isolate behind adapter and contract tests | +| sanctioned WebdriverIO surface drifts from raw W3C behavior | run the same contract cases through raw HTTP and WebdriverIO | + +## Open questions + +1. **Imperative test escape hatch:** What concrete scenarios must the initial + serializable DSL support before an executable sidecar is justified? +2. **MCP:** Is typed API plus JSON CLI enough for the initial agent experience, + or is a real MCP endpoint required for the first release? +3. **CI promotion:** What duration and pass-rate threshold should move new + desktop-driver jobs from advisory to required? + +## Future considerations + +### Visual testing + +V1 captures screenshots and complete scale/window/story metadata as evidence. +Baseline storage, image comparison, tolerances, approval workflows, and +cross-platform visual-diff policy are deferred until native capture fidelity +has been proven on all endpoints. + +### Legacy E2E migration + +The initial effort does not migrate or retire the existing Appium E2E harness. +After the new driver reaches platform and scenario parity, evaluate incremental +migration, dual-running duration, and retirement criteria as a separate +project. + +### Additional platforms and concurrency + +V1 supports Windows 11 x64 and macOS 14 on Apple Silicon, with one active +session per physical target. Windows 10, Windows ARM64, Intel/universal macOS, +and concurrent sessions are future expansion work. + +## References + +- [W3C WebDriver](https://www.w3.org/TR/webdriver2/) +- [Apple Accessibility for macOS](https://developer.apple.com/library/archive/documentation/Accessibility/Conceptual/AccessibilityMacOSX/) +- [AXUIElement](https://developer.apple.com/documentation/applicationservices/axuielement) +- [Quartz Event Services](https://developer.apple.com/documentation/coregraphics/quartz-event-services) +- [ScreenCaptureKit](https://developer.apple.com/documentation/screencapturekit) +- [XCUIApplication](https://developer.apple.com/documentation/xcuiautomation/xcuiapplication) +- [Microsoft UI Automation](https://learn.microsoft.com/en-us/windows/win32/winauto/entry-uiauto-win32) +- [UI Automation control patterns](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-controlpatternsoverview) +- [SendInput](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendinput) +- [Windows screen capture](https://learn.microsoft.com/en-us/windows/apps/develop/media-authoring-processing/screen-capture) diff --git a/packages/agentic/desktop-driver/README.md b/packages/agentic/desktop-driver/README.md new file mode 100644 index 00000000000..42c425b8d79 --- /dev/null +++ b/packages/agentic/desktop-driver/README.md @@ -0,0 +1,223 @@ +# React Native Desktop Driver + +`@fluentui-react-native/desktop-driver` is a W3C WebDriver-compatible remote end +for React Native desktop applications. It does not use Appium. + +Stage 1 provides the complete platform-neutral protocol, sanctioned WebdriverIO +API, serializable story-test contract, deterministic fake host, evidence +reports, JSON CLI, and bounded agent API. Native Windows, Win32, and macOS host +providers are separate Stage 2 work described in [PLAN.md](PLAN.md). + +## Package boundaries + +| Surface | Responsibility | +| --------------------------------------- | ----------------------------------------------------------- | +| `@fluentui-react-native/desktop-driver` | Public types and common APIs | +| `/authoring` | Serializable story plans, selectors, and result types | +| `/wdio` | Sanctioned WebdriverIO connection, commands, and runner | +| `/agent` | Bounded JSON-safe inspection and action API | +| `/client` | Low-level typed W3C client | +| `/server` | Embeddable W3C remote end and target/session state | +| `/artifacts` | Confined atomic evidence persistence | +| `/testing` | Fake host, fake Storybook orchestration, and test harnesses | + +The protocol server remains client-neutral. WebdriverIO is a dependency of the +high-level `/wdio` surface, not an implementation primitive for routing, +capability negotiation, or native hosts. + +## Authoring story tests + +Plans are inline static data under `parameters.desktopDriver`. Storybook can +extract and shard them without importing a React Native story module, and +agents can explain the same steps before execution. + +```tsx +import type { DesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; + +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'pointer-focus', + title: 'Responds to activation and receives focus', + requires: ['focus', 'screenshot'], + steps: [ + { action: 'wait', target: { testId: 'my-button' } }, + { expect: { state: 'role', target: { testId: 'my-button' }, value: 'button' } }, + { action: 'click', target: { testId: 'my-button' } }, + { expect: { state: 'focused', target: { testId: 'my-button' }, value: true } }, + { action: 'screenshot', name: 'focused-button', target: { testId: 'my-button' } }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; +``` + +Static extraction accepts JSON literals wrapped by TypeScript `satisfies` or +`as`. Do not hide plans behind variables, functions, spreads, computed +properties, or runtime platform branches. Invalid or dynamic plans fail +manifest generation with their source file, story, and line. + +Portable selectors are: + +- `{ testId }` for deterministic interaction; +- `{ role, name? }` for semantic lookup; +- `{ accessibleName }`; +- `{ text }`. + +Supported actions include click, double-click, clear, type, keys, W3C actions, +scroll, Storybook arg updates, waits, screenshots, source capture, and notes. +Assertions cover existence/count, role, accessible name, text/value, displayed, +enabled, focused, selected, checked/mixed, and expanded state. + +Use `requires` for capabilities such as `keyboard`, `focus`, `wheel`, or +`screenshot`. Missing capabilities produce an explicit skipped result rather +than a false pass. Platform divergence belongs in declarative `platforms` or +capability requirements, not branches inside a plan. + +## Sanctioned WebdriverIO API + +```ts +import { connectDesktopWebdriver } from '@fluentui-react-native/desktop-driver/wdio'; + +const desktop = await connectDesktopWebdriver({ + platformName: 'windows', + targetId: 'agenticstorybook-windows', + url: 'http://127.0.0.1:39859', +}); + +try { + const manifest = await desktop.browser.desktopListStories(); + await desktop.browser.desktopOpenStory('components-button--default'); + await desktop.browser.desktopExpect({ + state: 'enabled', + target: { testId: 'agentic-storybook-button' }, + value: true, + }); + const result = await desktop.browser.desktopRunStoryTests({ + artifactsRoot: 'artifacts/windows/desktop-driver', + selection: { tag: 'desktop-e2e', shardCount: 2, shardIndex: 0 }, + }); +} finally { + await desktop.delete(); +} +``` + +Registered browser commands are: + +- `desktopListStories()`; +- `desktopOpenStory(storyId, runId?)`; +- `desktopResetStory(storyId, runId?)`; +- `desktopExpect(expectation)`; +- `desktopRunStoryTests(options?)`. + +The runner filters by story, test, and tag, shards the sorted +`storyId/testId` list deterministically, checks required capabilities, resets +the preview for every run, and distinguishes assertion, timeout, cancellation, +skip, and infrastructure outcomes. + +The server serializes commands per session and all input globally. Timeout +paths abort host work before releasing input, while runner cancellation drains +its in-flight request before cleanup, so a later test cannot inherit a late key +or pointer action. + +## Evidence + +Supplying `artifactsRoot` writes atomically beneath that root: + +```text +artifactsRoot/ + host.json + run.json + tests/ + -/ + + failure.png + failure-source.xml + failure-tree.json +``` + +Artifact names are sanitized and confined beneath the configured root. +Failures attempt screenshot, source, and compact-tree capture; evidence-capture +errors are reported without replacing the original test failure. + +## JSON CLI + +The CLI prints structured JSON for automation: + +```sh +desktop-driver serve --manifest story-manifest.windows.json --target fake-windows + +desktop-driver stories list \ + --url http://127.0.0.1:4444 \ + --target fake-windows + +desktop-driver stories explain components-button--default \ + --url http://127.0.0.1:4444 \ + --target fake-windows + +desktop-driver stories run \ + --url http://127.0.0.1:4444 \ + --target fake-windows \ + --tag desktop-e2e \ + --artifacts artifacts/windows/desktop-driver + +desktop-driver agent describe \ + --url http://127.0.0.1:4444 \ + --target fake-windows \ + --scope story \ + --artifacts artifacts/windows/desktop-driver +``` + +`serve` is the Stage 1 fake target. It is not a native-provider substitute. + +## Agent API + +```ts +import { connectDesktopAgent } from '@fluentui-react-native/desktop-driver/agent'; + +const agent = await connectDesktopAgent({ + artifactsRoot: 'artifacts/windows/desktop-driver', + platformName: 'windows', + targetId: 'agenticstorybook-windows', + url: 'http://127.0.0.1:39859', +}); + +try { + await agent.listStories(); + await agent.openStory('components-button--default'); + await agent.describe({ scope: 'story', depth: 3, maxNodes: 100 }); + await agent.click({ testId: 'agentic-storybook-button' }); + await agent.screenshot('button'); + await agent.runStoryTest('components-button--default', 'pointer-focus'); +} finally { + await agent.delete(); +} +``` + +The API intentionally exposes coarse operations and bounded tree projections. +It does not reveal native handles or permit arbitrary process, environment, or +artifact-path capabilities. + +## MCP decision + +Phase 3 does not add another MCP listener. The Storybook MCP remains the +documentation and story-metadata surface; the typed agent API and JSON CLI are +the executable native-validation surface. Reconsider a composed MCP adapter +after Stage 2 proves the native commands and their security model. A tool-schema +file without an executable adapter is not considered an MCP integration. + +## Protocol and security + +See [SPEC.md](SPEC.md) for implemented W3C routes, extension commands, +capabilities, device contracts, and unsupported browser behavior. + +The server binds loopback, rejects browser-origin requests, accepts only +server-registered targets, permits one session per physical target, applies +host command deadlines, and preserves attached applications. Never expose it +remotely without a separately designed authentication and transport policy. diff --git a/packages/agentic/desktop-driver/SPEC.md b/packages/agentic/desktop-driver/SPEC.md new file mode 100644 index 00000000000..d9e366f03d0 --- /dev/null +++ b/packages/agentic/desktop-driver/SPEC.md @@ -0,0 +1,105 @@ +# Desktop Driver Contract + +## Scope + +Desktop Driver is a W3C WebDriver Classic-compatible native desktop remote end. +It intentionally implements native application semantics rather than +pretending to be a browser. + +V1 platform names are `macos` and `windows`; endpoint names are `macos`, +`windows`, and `win32`. + +## Sessions and targets + +- Targets are registered by the server. +- Capabilities select a target with `furn:target`. +- Capabilities never carry executable paths, environment variables, or output + directories. +- Exactly one active session may own a physical target. +- Commands are serialized per session, and input operations share one global + mutex. +- A target records whether the application was launched or attached. +- Deleting an attached session preserves the application. +- Cleanup uses owned process identity, never process-name matching. + +Supported extension capabilities: + +- `furn:target`; +- `furn:endpoint`; +- `furn:renderer`; +- `furn:clickMode`: `physical`, `accessibility`, or `auto`; +- returned `furn:features`. + +## W3C routes + +Implemented standard behavior: + +- status and new/delete session; +- get/set timeouts; +- current window, handles, switching, closing, and rectangles; +- element lookup from a window or element; +- active element; +- element name, text, attribute, property, rectangle, enabled, selected, and + displayed queries; +- click, clear, and send keys; +- perform/release actions; +- window and element screenshots; +- accessibility source. + +Implemented `furn` extensions: + +- `GET /session/{id}/furn/manifest`; +- `GET|POST /session/{id}/furn/story`; +- `POST /session/{id}/furn/story/reset`; +- `POST /session/{id}/furn/story/args`; +- `GET /session/{id}/furn/tree`. + +Navigation, cookies, frames, shadow roots, prompts, printing, arbitrary +JavaScript execution, and browser CSS behavior return `unsupported operation`. + +## Elements + +Public references use only the standard +`element-6066-11e4-a52e-4f735466cecf` key with session UUIDs. Native handles, +UIA runtime IDs, AX references, React tags, and HWNDs are private. + +Every command validates native liveness. Story reset increments the preview +generation and invalidates preview elements while preserving live application +and Storybook chrome references. + +Pointer and wheel actions may use WebDriver element origins. The server resolves +the public session element UUID, validates preview generation and native +liveness, and passes only `{ elementId: }` to the host contract. +Public WebDriver element references never cross into platform providers. + +Every host operation receives an `AbortSignal`. On timeout, the server aborts +the host operation before input release, session teardown, or a subsequent +command. Providers must stop side effects and settle promptly; they may never +apply late input after cleanup. + +Portable lookup uses accessibility ID, normalized role, accessible name, and +the namespaced `-furn:text` strategy. Deterministic authored interactions use +`testID`. + +## Storybook readiness + +The runtime and server validate: + +- instance, target, endpoint, and catalog identity; +- a private bridge nonce; +- request and run IDs; +- portable-plan digest; +- preview generation; +- the native story-root marker. + +Only the authenticated bridge client can complete or fail a selection. Every +test receives a new run ID and keyed preview remount. + +## Results + +Run status is `passed` or `failed`. Tests distinguish `passed`, `failed`, +`skipped`, `timed-out`, `cancelled`, and `infrastructure-error`. Steps record +status, duration, error, and artifacts. + +Unsupported required capabilities are skipped explicitly. An unavailable +native property never becomes a false-shaped passing assertion. diff --git a/packages/agentic/desktop-driver/config/cli.cjs b/packages/agentic/desktop-driver/config/cli.cjs new file mode 100644 index 00000000000..ce66ceda209 --- /dev/null +++ b/packages/agentic/desktop-driver/config/cli.cjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import('../lib/cli/index.js') + .then(({ runDesktopDriverCli }) => runDesktopDriverCli()) + .catch((error) => { + process.stderr.write(`${error.stack || error.message}\n`); + process.exitCode = 1; + }); diff --git a/packages/agentic/desktop-driver/jest.config.cjs b/packages/agentic/desktop-driver/jest.config.cjs new file mode 100644 index 00000000000..d6535664e2e --- /dev/null +++ b/packages/agentic/desktop-driver/jest.config.cjs @@ -0,0 +1,9 @@ +const config = require('@fluentui-react-native/scripts/jest-config'); + +module.exports = { + ...config, + moduleNameMapper: { + ...config.moduleNameMapper, + '^(\\.{1,2}/.*)\\.js$': '$1', + }, +}; diff --git a/packages/agentic/desktop-driver/package.json b/packages/agentic/desktop-driver/package.json new file mode 100644 index 00000000000..cf7d0cc1617 --- /dev/null +++ b/packages/agentic/desktop-driver/package.json @@ -0,0 +1,96 @@ +{ + "name": "@fluentui-react-native/desktop-driver", + "version": "0.1.0", + "description": "W3C WebDriver-compatible automation for React Native desktop applications", + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui-react-native.git", + "directory": "packages/agentic/desktop-driver" + }, + "bin": "./config/cli.cjs", + "type": "module", + "main": "lib/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "default": "./src/index.ts" + }, + "./client": { + "types": "./lib/client/index.d.ts", + "import": "./lib/client/index.js", + "default": "./src/client/index.ts" + }, + "./cli": { + "types": "./lib/cli/index.d.ts", + "import": "./lib/cli/index.js", + "default": "./src/cli/index.ts" + }, + "./authoring": { + "types": "./lib/authoring/index.d.ts", + "import": "./lib/authoring/index.js", + "default": "./src/authoring/index.ts" + }, + "./agent": { + "types": "./lib/agent/index.d.ts", + "import": "./lib/agent/index.js", + "default": "./src/agent/index.ts" + }, + "./artifacts": { + "types": "./lib/artifacts/index.d.ts", + "import": "./lib/artifacts/index.js", + "default": "./src/artifacts/index.ts" + }, + "./server": { + "types": "./lib/server/index.d.ts", + "import": "./lib/server/index.js", + "default": "./src/server/index.ts" + }, + "./storybook": { + "types": "./lib/storybook.d.ts", + "import": "./lib/storybook.js", + "default": "./src/storybook.ts" + }, + "./testing": { + "types": "./lib/testing/index.d.ts", + "import": "./lib/testing/index.js", + "default": "./src/testing/index.ts" + }, + "./wdio": { + "types": "./lib/wdio/index.d.ts", + "import": "./lib/wdio/index.js", + "default": "./src/wdio/index.ts" + }, + "./runner": { + "types": "./lib/runner/index.d.ts", + "import": "./lib/runner/index.js", + "default": "./src/runner/index.ts" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc -b", + "clean": "fluentui-scripts clean", + "format": "fluentui-scripts format", + "lint": "fluentui-scripts lint", + "test": "fluentui-scripts jest" + }, + "dependencies": { + "commander": "^14.0.2", + "webdriverio": "catalog:" + }, + "devDependencies": { + "@fluentui-react-native/scripts": "workspace:*" + }, + "furn": { + "jestPlatform": "react" + }, + "rnx-kit": { + "kitType": "library", + "extends": "@fluentui-react-native/scripts/kit-config" + } +} diff --git a/packages/agentic/desktop-driver/src/agent/DesktopAgent.test.ts b/packages/agentic/desktop-driver/src/agent/DesktopAgent.test.ts new file mode 100644 index 00000000000..6603cf37c7d --- /dev/null +++ b/packages/agentic/desktop-driver/src/agent/DesktopAgent.test.ts @@ -0,0 +1,102 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createDesktopDriverClient } from '../client/DesktopDriverClient.js'; +import type { DesktopStoryManifest } from '../storybook.js'; +import { createDesktopDriverTestHarness } from '../testing/protocolHarness.js'; +import { createDesktopDriverStoryHarness } from '../testing/protocolHarness.js'; +import { connectDesktopAgent } from './DesktopAgent.js'; + +describe('DesktopAgent', () => { + test('lists, explains, inspects, acts, checks, captures, and runs the same authored plan', async () => { + const manifest: DesktopStoryManifest = { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/button/button.stories.tsx', + tags: ['e2e', 'story'], + tests: { + version: 1, + tests: [ + { + id: 'agent-plan', + steps: [{ expect: { state: 'enabled', target: { testId: 'button-primary' }, value: true } }], + }, + ], + }, + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform-digest', + portablePlanDigest: 'portable-digest', + schemaVersion: 1, + }; + const harness = await createDesktopDriverStoryHarness(manifest); + const artifactsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-agent-')); + try { + const response = await runContract(harness.server.url, harness.target.id, artifactsRoot); + expect(response).toMatchObject({ + check: { passed: true }, + run: { status: 'passed', tests: [{ testId: 'agent-plan' }] }, + screenshot: { kind: 'screenshot', name: 'agent-button' }, + stories: 1, + tree: 1, + }); + expect(fs.existsSync(path.join(artifactsRoot, 'run.json'))).toBe(true); + } finally { + await harness.close(); + fs.rmSync(artifactsRoot, { force: true, recursive: true }); + } + }); + + test('validates the artifact root before reserving a target session', async () => { + const harness = await createDesktopDriverTestHarness(); + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-agent-invalid-')); + const invalidRoot = path.join(temporaryDirectory, 'file'); + fs.writeFileSync(invalidRoot, 'not a directory'); + try { + await expect( + connectDesktopAgent({ + artifactsRoot: invalidRoot, + platformName: 'windows', + targetId: harness.target.id, + url: harness.server.url, + }), + ).rejects.toThrow(); + + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + await session.delete(); + } finally { + await harness.close(); + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }); +}); + +function runContract(url: string, targetId: string, artifactsRoot: string): Promise> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(__dirname, 'agent.contract.cjs'), url, targetId, artifactsRoot], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.once('error', reject); + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || `Desktop agent exited with code ${code}.`)); + return; + } + resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')) as Record); + }); + }); +} diff --git a/packages/agentic/desktop-driver/src/agent/DesktopAgent.ts b/packages/agentic/desktop-driver/src/agent/DesktopAgent.ts new file mode 100644 index 00000000000..d75d7188a83 --- /dev/null +++ b/packages/agentic/desktop-driver/src/agent/DesktopAgent.ts @@ -0,0 +1,170 @@ +import type { DesktopArtifact, DesktopStoryRunResult } from '../authoring/results.js'; +import type { DesktopStoryExpectation, DesktopStorySelector } from '../authoring/storyTests.js'; +import { ArtifactManager } from '../artifacts/ArtifactManager.js'; +import type { DesktopTreeNode } from '../host/types.js'; +import { DesktopAssertionError, assertDesktopExpectation, findDesktopElement } from '../runner/StoryTestRunner.js'; +import { WebDriverError } from '../protocol/errors.js'; +import type { DesktopStoryManifestEntry } from '../storybook.js'; +import { connectDesktopWebdriver } from '../wdio/DesktopWebdriver.js'; +import type { DesktopWebdriverOptions, DesktopWebdriverSession } from '../wdio/DesktopWebdriver.js'; + +export type DesktopAgentOptions = DesktopWebdriverOptions & { + artifactsRoot: string; +}; + +export type DesktopAgentStory = Pick & { + tests: readonly { id: string; title: string }[]; +}; + +export type DesktopAgentElement = { + accessibleName: unknown; + enabled: boolean; + id: string; + role: string; + selected: boolean; + text: string; +}; + +export type DesktopAgentCheckResult = { + message?: string; + passed: boolean; +}; + +export type DesktopAgentDescribeOptions = { + depth?: number; + maxNodes?: number; + scope?: 'application' | 'story'; +}; + +export class DesktopAgent { + private readonly artifacts: ArtifactManager; + private readonly desktop: DesktopWebdriverSession; + private readonly captured: DesktopArtifact[] = []; + + constructor(desktop: DesktopWebdriverSession, artifacts: ArtifactManager) { + this.desktop = desktop; + this.artifacts = artifacts; + } + + async listStories(): Promise { + const manifest = await this.desktop.listStories(); + return manifest.entries.map((entry) => ({ + id: entry.id, + name: entry.name, + tags: entry.tags, + tests: (entry.tests?.tests ?? []).map((test) => ({ id: test.id, title: test.title ?? test.id })), + title: entry.title, + })); + } + + async explainStory(storyId: string): Promise { + const story = (await this.listStories()).find(({ id }) => id === storyId); + if (!story) { + throw new Error(`Story "${storyId}" is not present in the active platform manifest.`); + } + return story; + } + + openStory(storyId: string): Promise<{ previewGeneration: number; runId: string; storyId: string }> { + return this.desktop.openStory(storyId); + } + + async describe(options: DesktopAgentDescribeOptions = {}): Promise { + const depth = options.depth ?? 3; + const maxNodes = options.maxNodes ?? 100; + if (!Number.isInteger(depth) || depth < 0 || !Number.isInteger(maxNodes) || maxNodes < 1) { + throw new TypeError('Agent tree depth must be non-negative and maxNodes must be positive.'); + } + const roots = await this.desktop.session.getTree(); + const scoped = + options.scope === 'story' + ? roots.flatMap((root) => + findTreeNodes(root, (node) => node.testId === 'story-root' || (node.testId?.endsWith('-story-root') ?? false)), + ) + : roots; + let remaining = maxNodes; + return scoped.flatMap((root) => { + const projected = projectTree(root, depth, () => remaining-- > 0); + return projected ? [projected] : []; + }); + } + + async find(selector: DesktopStorySelector): Promise { + const element = await findDesktopElement(this.desktop.session, selector); + return { + accessibleName: await element.getAttribute('name'), + enabled: await element.isEnabled(), + id: element.id, + role: await element.getTagName(), + selected: await element.isSelected(), + text: await element.getText(), + }; + } + + async click(selector: DesktopStorySelector): Promise { + await (await findDesktopElement(this.desktop.session, selector)).click(); + } + + async type(selector: DesktopStorySelector, text: string): Promise { + await (await findDesktopElement(this.desktop.session, selector)).sendKeys(text); + } + + async check(expectation: DesktopStoryExpectation): Promise { + try { + await assertDesktopExpectation(this.desktop.session, expectation); + return { passed: true }; + } catch (error) { + if (error instanceof DesktopAssertionError || (error instanceof WebDriverError && error.code === 'no such element')) { + return { message: error.message, passed: false }; + } + throw error; + } + } + + async screenshot(name: string): Promise { + const artifact = this.artifacts.writeScreenshot('agent', name, await this.desktop.session.takeScreenshot()); + this.captured.push(artifact); + return artifact; + } + + runStoryTest(storyId: string, testId: string): Promise { + return this.desktop.runStoryTests({ + artifactsRoot: this.artifacts.root, + selection: { story: storyId, test: testId }, + }); + } + + getArtifacts(): readonly DesktopArtifact[] { + return [...this.captured]; + } + + delete(): Promise { + return this.desktop.delete(); + } +} + +export async function connectDesktopAgent(options: DesktopAgentOptions): Promise { + const artifacts = new ArtifactManager(options.artifactsRoot); + const desktop = await connectDesktopWebdriver(options); + return new DesktopAgent(desktop, artifacts); +} + +function findTreeNodes(node: DesktopTreeNode, predicate: (node: DesktopTreeNode) => boolean): DesktopTreeNode[] { + return [...(predicate(node) ? [node] : []), ...node.children.flatMap((child) => findTreeNodes(child, predicate))]; +} + +function projectTree(node: DesktopTreeNode, depth: number, take: () => boolean): DesktopTreeNode | undefined { + if (!take()) { + return undefined; + } + return { + ...node, + children: + depth === 0 + ? [] + : node.children.flatMap((child) => { + const projected = projectTree(child, depth - 1, take); + return projected ? [projected] : []; + }), + }; +} diff --git a/packages/agentic/desktop-driver/src/agent/agent.contract.cjs b/packages/agentic/desktop-driver/src/agent/agent.contract.cjs new file mode 100644 index 00000000000..598c30d741d --- /dev/null +++ b/packages/agentic/desktop-driver/src/agent/agent.contract.cjs @@ -0,0 +1,32 @@ +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +async function main() { + const moduleUrl = pathToFileURL(path.resolve(__dirname, '..', '..', 'lib', 'agent', 'index.js')).href; + const { connectDesktopAgent } = await import(moduleUrl); + const [url, targetId, artifactsRoot] = process.argv.slice(2); + const agent = await connectDesktopAgent({ + artifactsRoot, + platformName: 'windows', + targetId, + url, + }); + try { + const stories = await agent.listStories(); + const story = await agent.explainStory('components-button--default'); + await agent.openStory(story.id); + const tree = await agent.describe({ depth: 2, maxNodes: 10, scope: 'story' }); + await agent.click({ testId: 'button-primary' }); + const check = await agent.check({ state: 'focused', target: { testId: 'button-primary' }, value: true }); + const screenshot = await agent.screenshot('agent-button'); + const run = await agent.runStoryTest(story.id, 'agent-plan'); + process.stdout.write(JSON.stringify({ check, run, screenshot, stories: stories.length, tree: tree.length })); + } finally { + await agent.delete(); + } +} + +main().catch((error) => { + process.stderr.write(error.stack ?? error.message); + process.exitCode = 1; +}); diff --git a/packages/agentic/desktop-driver/src/agent/index.ts b/packages/agentic/desktop-driver/src/agent/index.ts new file mode 100644 index 00000000000..5814430af05 --- /dev/null +++ b/packages/agentic/desktop-driver/src/agent/index.ts @@ -0,0 +1,8 @@ +export { connectDesktopAgent, DesktopAgent } from './DesktopAgent.js'; +export type { + DesktopAgentCheckResult, + DesktopAgentDescribeOptions, + DesktopAgentElement, + DesktopAgentOptions, + DesktopAgentStory, +} from './DesktopAgent.js'; diff --git a/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.test.ts b/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.test.ts new file mode 100644 index 00000000000..12d890351f2 --- /dev/null +++ b/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.test.ts @@ -0,0 +1,20 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { ArtifactManager } from './ArtifactManager.js'; + +describe('ArtifactManager', () => { + test('confines and atomically writes artifacts beneath the run root', () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-artifacts-')); + try { + const manager = new ArtifactManager(temporaryDirectory); + const artifact = manager.writeSource('../story:id', '../source', ''); + + expect(artifact.path).toBe('tests/-story-id/-source.xml'); + expect(fs.readFileSync(path.join(temporaryDirectory, ...artifact.path.split('/')), 'utf8')).toBe(''); + } finally { + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.ts b/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.ts new file mode 100644 index 00000000000..336e5b5cc59 --- /dev/null +++ b/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.ts @@ -0,0 +1,105 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { DesktopArtifact, DesktopStoryRunResult } from '../authoring/results.js'; + +export class ArtifactManager { + readonly root: string; + + constructor(root: string) { + if (!root) { + throw new TypeError('ArtifactManager requires a non-empty root path.'); + } + fs.mkdirSync(root, { recursive: true }); + this.root = fs.realpathSync.native(root); + } + + writeScreenshot(testDirectory: string, name: string, base64: string): DesktopArtifact { + return this.write(testDirectory, name, 'png', Buffer.from(base64, 'base64'), 'screenshot'); + } + + writeSource(testDirectory: string, name: string, source: string): DesktopArtifact { + return this.write(testDirectory, name, 'xml', source, 'source'); + } + + writeTree(testDirectory: string, name: string, tree: unknown): DesktopArtifact { + return this.write(testDirectory, name, 'json', `${JSON.stringify(tree, null, 2)}\n`, 'tree'); + } + + writeRunResult(result: DesktopStoryRunResult): string { + const outputPath = this.resolvePath('run.json'); + writeAtomic(outputPath, `${JSON.stringify(result, null, 2)}\n`); + return outputPath; + } + + writeMetadata(name: string, value: unknown): string { + const outputPath = this.resolvePath(`${sanitizeSegment(name)}.json`); + writeAtomic(outputPath, `${JSON.stringify(value, null, 2)}\n`); + return outputPath; + } + + private write( + testDirectory: string, + name: string, + extension: string, + data: string | Uint8Array, + kind: DesktopArtifact['kind'], + ): DesktopArtifact { + const safeDirectory = sanitizeSegment(testDirectory); + const safeName = sanitizeSegment(name); + const relativePath = path.join('tests', safeDirectory, `${safeName}.${extension}`); + const outputPath = this.resolvePath(relativePath); + writeAtomic(outputPath, data); + return { + kind, + name, + path: relativePath.split(path.sep).join('/'), + }; + } + + private resolvePath(relativePath: string): string { + if (path.isAbsolute(relativePath)) { + throw new Error('Artifact paths must be relative to the configured root.'); + } + const outputPath = path.resolve(this.root, relativePath); + const relative = path.relative(this.root, outputPath); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Artifact path escapes the configured root: ${relativePath}`); + } + const parent = path.dirname(outputPath); + fs.mkdirSync(parent, { recursive: true }); + const realParent = fs.realpathSync.native(parent); + const realRelative = path.relative(this.root, realParent); + if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) { + throw new Error(`Artifact path traverses outside the configured root: ${relativePath}`); + } + return path.join(realParent, path.basename(outputPath)); + } +} + +function sanitizeSegment(value: string): string { + let sanitized = value + .replaceAll(/[^A-Za-z0-9._-]/g, '-') + .replaceAll(/-+/g, '-') + .replace(/^\.+/, ''); + if (/^(?:aux|con|nul|prn|com[1-9]|lpt[1-9])(?:\.|$)/i.test(sanitized)) { + sanitized = `_${sanitized}`; + } + if (!sanitized || sanitized === '.' || sanitized === '..') { + throw new Error(`Invalid artifact name "${value}".`); + } + return sanitized; +} + +function writeAtomic(outputPath: string, data: string | Uint8Array): void { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const temporaryPath = `${outputPath}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporaryPath, data); + fs.renameSync(temporaryPath, outputPath); + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + throw error; + } +} diff --git a/packages/agentic/desktop-driver/src/artifacts/index.ts b/packages/agentic/desktop-driver/src/artifacts/index.ts new file mode 100644 index 00000000000..44e40a744e3 --- /dev/null +++ b/packages/agentic/desktop-driver/src/artifacts/index.ts @@ -0,0 +1 @@ +export { ArtifactManager } from './ArtifactManager.js'; diff --git a/packages/agentic/desktop-driver/src/authoring/index.ts b/packages/agentic/desktop-driver/src/authoring/index.ts new file mode 100644 index 00000000000..abc515c0d9a --- /dev/null +++ b/packages/agentic/desktop-driver/src/authoring/index.ts @@ -0,0 +1,26 @@ +export type { + DesktopArtifact, + DesktopRunStatus, + DesktopStepStatus, + DesktopStoryRunResult, + DesktopStoryStepResult, + DesktopStoryTestResult, + DesktopTestStatus, +} from './results.js'; +export { + defineDesktopStoryTests, + desktopBy, + desktopStoryCapabilities, + desktopStoryPlatforms, + validateDesktopStoryTests, +} from './storyTests.js'; +export type { + DesktopStoryCapability, + DesktopStoryExpectation, + DesktopStoryPlatform, + DesktopStorySelector, + DesktopStoryState, + DesktopStoryStep, + DesktopStoryTest, + DesktopStoryTests, +} from './storyTests.js'; diff --git a/packages/agentic/desktop-driver/src/authoring/results.ts b/packages/agentic/desktop-driver/src/authoring/results.ts new file mode 100644 index 00000000000..9376b04a668 --- /dev/null +++ b/packages/agentic/desktop-driver/src/authoring/results.ts @@ -0,0 +1,47 @@ +import type { DesktopEndpoint, DesktopPlatformName } from '../protocol/types.js'; + +export type DesktopArtifact = { + kind: 'screenshot' | 'source' | 'tree'; + name: string; + path: string; +}; + +export type DesktopStepStatus = 'failed' | 'passed' | 'skipped'; +export type DesktopTestStatus = 'cancelled' | 'failed' | 'infrastructure-error' | 'passed' | 'skipped' | 'timed-out'; +export type DesktopRunStatus = 'failed' | 'passed'; + +export type DesktopStoryStepResult = { + artifacts: readonly DesktopArtifact[]; + durationMs: number; + error?: string; + index: number; + status: DesktopStepStatus; +}; + +export type DesktopStoryTestResult = { + artifacts: readonly DesktopArtifact[]; + durationMs: number; + error?: string; + skipReason?: string; + status: DesktopTestStatus; + steps: readonly DesktopStoryStepResult[]; + storyId: string; + testId: string; + title: string; +}; + +export type DesktopStoryRunResult = { + endpoint: DesktopEndpoint; + finishedAt: string; + manifest: { + platform: string; + portable: string; + }; + platformName: DesktopPlatformName; + runId: string; + schemaVersion: 1; + startedAt: string; + status: DesktopRunStatus; + targetId: string; + tests: readonly DesktopStoryTestResult[]; +}; diff --git a/packages/agentic/desktop-driver/src/authoring/storyTests.test.ts b/packages/agentic/desktop-driver/src/authoring/storyTests.test.ts new file mode 100644 index 00000000000..63da1367f8f --- /dev/null +++ b/packages/agentic/desktop-driver/src/authoring/storyTests.test.ts @@ -0,0 +1,58 @@ +import { validateDesktopStoryTests } from './storyTests.js'; + +describe('validateDesktopStoryTests', () => { + test('accepts a serializable versioned plan', () => { + const plan = { + version: 1, + tests: [{ id: 'click', steps: [{ action: 'click', target: { testId: 'button' } }] }], + } as const; + + expect(validateDesktopStoryTests(plan)).toBe(plan); + }); + + test('rejects duplicate ids and executable values', () => { + expect(() => + validateDesktopStoryTests({ + version: 1, + tests: [ + { id: 'duplicate', steps: [{ action: 'note', message: 'first' }] }, + { id: 'duplicate', steps: [{ action: 'note', message: 'second' }] }, + ], + }), + ).toThrow('duplicate test id'); + expect(() => + validateDesktopStoryTests({ + version: 1, + tests: [{ id: 'function', steps: [{ action: 'setArgs', args: { run: () => undefined } }] }], + }), + ).toThrow('JSON-serializable'); + }); + + test('rejects non-finite numbers before digest serialization', () => { + expect(() => + validateDesktopStoryTests({ + version: 1, + tests: [ + { + id: 'non-finite', + steps: [{ action: 'setArgs', args: { invalid: Number.POSITIVE_INFINITY } }], + }, + ], + }), + ).toThrow('finite numbers'); + }); + + test('rejects malformed authored W3C action sequences', () => { + expect(() => + validateDesktopStoryTests({ + version: 1, + tests: [ + { + id: 'invalid-actions', + steps: [{ action: 'actions', sequences: [{ id: 'bad', type: 'key', actions: [{ type: 'pointerDown' }] }] }], + }, + ], + }), + ).toThrow('sequences are invalid'); + }); +}); diff --git a/packages/agentic/desktop-driver/src/authoring/storyTests.ts b/packages/agentic/desktop-driver/src/authoring/storyTests.ts new file mode 100644 index 00000000000..dd97aa0b328 --- /dev/null +++ b/packages/agentic/desktop-driver/src/authoring/storyTests.ts @@ -0,0 +1,345 @@ +import type { WebDriverActionSequence } from '../protocol/types.js'; +import { createInputState, parseActionSequences } from '../protocol/actions.js'; + +export const desktopStoryPlatforms = ['macos', 'windows', 'win32'] as const; +export const desktopStoryCapabilities = [ + 'accessibility-click', + 'element-screenshot', + 'focus', + 'keyboard', + 'physical-click', + 'screenshot', + 'wheel', +] as const; + +export type DesktopStoryPlatform = (typeof desktopStoryPlatforms)[number]; +export type DesktopStoryCapability = (typeof desktopStoryCapabilities)[number]; + +export type DesktopStorySelector = { testId: string } | { role: string; name?: string } | { accessibleName: string } | { text: string }; + +export type DesktopStoryState = + | 'accessibleName' + | 'checked' + | 'count' + | 'displayed' + | 'enabled' + | 'expanded' + | 'exists' + | 'focused' + | 'role' + | 'selected' + | 'text' + | 'value'; + +export type DesktopStoryExpectation = { + state: DesktopStoryState; + target: DesktopStorySelector; + value?: boolean | number | string; +}; + +export type DesktopStoryStep = + | { action: 'actions'; sequences: readonly WebDriverActionSequence[] } + | { action: 'clear' | 'click' | 'doubleClick'; target: DesktopStorySelector } + | { action: 'keys'; value: readonly string[] } + | { action: 'note'; message: string } + | { action: 'screenshot'; name: string; target?: DesktopStorySelector } + | { action: 'scroll'; deltaX?: number; deltaY: number; target?: DesktopStorySelector } + | { action: 'setArgs'; args: Readonly> } + | { action: 'source'; name: string } + | { action: 'type'; target: DesktopStorySelector; text: string } + | { action: 'wait'; target?: DesktopStorySelector; timeoutMs?: number; until?: DesktopStoryExpectation } + | { expect: DesktopStoryExpectation }; + +export type DesktopStoryTest = { + id: string; + platforms?: readonly DesktopStoryPlatform[]; + requires?: readonly DesktopStoryCapability[]; + steps: readonly DesktopStoryStep[]; + title?: string; +}; + +export type DesktopStoryTests = { + portable?: boolean; + tests: readonly DesktopStoryTest[]; + version: 1; +}; + +export const desktopBy = { + accessibleName: (accessibleName: string): DesktopStorySelector => ({ accessibleName }), + role: (role: string, name?: string): DesktopStorySelector => ({ role, ...(name ? { name } : {}) }), + testId: (testId: string): DesktopStorySelector => ({ testId }), + text: (text: string): DesktopStorySelector => ({ text }), +}; + +export function defineDesktopStoryTests(plan: DesktopStoryTests): DesktopStoryTests { + return validateDesktopStoryTests(plan); +} + +export function validateDesktopStoryTests(value: unknown, source = 'desktopDriver'): DesktopStoryTests { + const plan = requireObject(value, source); + requireExactKeys(plan, ['portable', 'tests', 'version'], source); + if (plan.version !== 1) { + throw new TypeError(`${source}.version must be 1.`); + } + if (plan.portable !== undefined && typeof plan.portable !== 'boolean') { + throw new TypeError(`${source}.portable must be a boolean when provided.`); + } + if (!Array.isArray(plan.tests)) { + throw new TypeError(`${source}.tests must be an array.`); + } + + const ids = new Set(); + for (const [index, value] of plan.tests.entries()) { + validateTest(value, `${source}.tests[${index}]`, ids); + } + assertJsonValue(value, source); + return value as DesktopStoryTests; +} + +function validateTest(value: unknown, source: string, ids: Set): void { + const test = requireObject(value, source); + requireExactKeys(test, ['id', 'platforms', 'requires', 'steps', 'title'], source); + if (typeof test.id !== 'string' || !test.id) { + throw new TypeError(`${source}.id must be a non-empty string.`); + } + if (ids.has(test.id)) { + throw new TypeError(`${source} contains duplicate test id "${test.id}".`); + } + ids.add(test.id); + if (test.title !== undefined && (typeof test.title !== 'string' || !test.title)) { + throw new TypeError(`${source}.title must be a non-empty string when provided.`); + } + validateEnumArray(test.platforms, desktopStoryPlatforms, `${source}.platforms`); + validateEnumArray(test.requires, desktopStoryCapabilities, `${source}.requires`); + if (!Array.isArray(test.steps) || test.steps.length === 0) { + throw new TypeError(`${source}.steps must be a non-empty array.`); + } + for (const [index, step] of test.steps.entries()) { + validateStep(step, `${source}.steps[${index}]`); + } +} + +function validateStep(value: unknown, source: string): void { + const step = requireObject(value, source); + if ('expect' in step) { + requireExactKeys(step, ['expect'], source); + validateExpectation(step.expect, `${source}.expect`); + return; + } + if (typeof step.action !== 'string') { + throw new TypeError(`${source}.action must be a supported action string.`); + } + switch (step.action) { + case 'actions': + requireExactKeys(step, ['action', 'sequences'], source); + if (!Array.isArray(step.sequences) || step.sequences.length === 0) { + throw new TypeError(`${source}.sequences must be a non-empty array.`); + } + try { + parseActionSequences(step.sequences, createInputState()); + } catch (error) { + throw new TypeError(`${source}.sequences are invalid: ${(error as Error).message}`, { cause: error }); + } + return; + case 'clear': + case 'click': + case 'doubleClick': + requireExactKeys(step, ['action', 'target'], source); + validateSelector(step.target, `${source}.target`); + return; + case 'keys': + requireExactKeys(step, ['action', 'value'], source); + if (!Array.isArray(step.value) || !step.value.every((key) => typeof key === 'string' && key.length > 0)) { + throw new TypeError(`${source}.value must be a non-empty string array.`); + } + return; + case 'note': + requireExactKeys(step, ['action', 'message'], source); + requireNonEmptyString(step.message, `${source}.message`); + return; + case 'screenshot': + requireExactKeys(step, ['action', 'name', 'target'], source); + requireNonEmptyString(step.name, `${source}.name`); + if (step.target !== undefined) { + validateSelector(step.target, `${source}.target`); + } + return; + case 'scroll': + requireExactKeys(step, ['action', 'deltaX', 'deltaY', 'target'], source); + requireFiniteNumber(step.deltaY, `${source}.deltaY`); + if (step.deltaX !== undefined) { + requireFiniteNumber(step.deltaX, `${source}.deltaX`); + } + if (step.target !== undefined) { + validateSelector(step.target, `${source}.target`); + } + return; + case 'setArgs': + requireExactKeys(step, ['action', 'args'], source); + requireObject(step.args, `${source}.args`); + return; + case 'source': + requireExactKeys(step, ['action', 'name'], source); + requireNonEmptyString(step.name, `${source}.name`); + return; + case 'type': + requireExactKeys(step, ['action', 'target', 'text'], source); + validateSelector(step.target, `${source}.target`); + if (typeof step.text !== 'string') { + throw new TypeError(`${source}.text must be a string.`); + } + return; + case 'wait': + requireExactKeys(step, ['action', 'target', 'timeoutMs', 'until'], source); + if (step.target !== undefined) { + validateSelector(step.target, `${source}.target`); + } + if (step.until !== undefined) { + validateExpectation(step.until, `${source}.until`); + } + if (step.target === undefined && step.until === undefined) { + throw new TypeError(`${source} requires "target" or "until".`); + } + if (step.timeoutMs !== undefined && (!Number.isInteger(step.timeoutMs) || (step.timeoutMs as number) < 0)) { + throw new TypeError(`${source}.timeoutMs must be a non-negative integer.`); + } + return; + default: + throw new TypeError(`${source}.action "${step.action}" is not supported.`); + } +} + +function validateExpectation(value: unknown, source: string): void { + const expectation = requireObject(value, source); + requireExactKeys(expectation, ['state', 'target', 'value'], source); + if ( + typeof expectation.state !== 'string' || + !( + [ + 'accessibleName', + 'checked', + 'count', + 'displayed', + 'enabled', + 'expanded', + 'exists', + 'focused', + 'role', + 'selected', + 'text', + 'value', + ] as const + ).includes(expectation.state as DesktopStoryState) + ) { + throw new TypeError(`${source}.state is not supported.`); + } + validateSelector(expectation.target, `${source}.target`); + if (expectation.value !== undefined && !['boolean', 'number', 'string'].includes(typeof expectation.value)) { + throw new TypeError(`${source}.value must be a boolean, number, or string.`); + } + if (typeof expectation.value === 'number' && !Number.isFinite(expectation.value)) { + throw new TypeError(`${source}.value must be finite.`); + } + if (expectation.state === 'count' && typeof expectation.value !== 'number') { + throw new TypeError(`${source}.value must be a number for a count assertion.`); + } + if ( + (expectation.state === 'accessibleName' || + expectation.state === 'role' || + expectation.state === 'text' || + expectation.state === 'value') && + typeof expectation.value !== 'string' + ) { + throw new TypeError(`${source}.value must be a string for a ${expectation.state} assertion.`); + } + if ( + (expectation.state === 'checked' || + expectation.state === 'displayed' || + expectation.state === 'enabled' || + expectation.state === 'expanded' || + expectation.state === 'exists' || + expectation.state === 'focused' || + expectation.state === 'selected') && + expectation.value !== undefined && + typeof expectation.value !== 'boolean' && + !(expectation.state === 'checked' && expectation.value === 'mixed') + ) { + throw new TypeError(`${source}.value must be a boolean for a ${expectation.state} assertion.`); + } +} + +function validateSelector(value: unknown, source: string): void { + const selector = requireObject(value, source); + const selectorKeys = ['accessibleName', 'role', 'testId', 'text'].filter((key) => selector[key] !== undefined); + if (selectorKeys.length !== 1) { + throw new TypeError(`${source} must define exactly one selector strategy.`); + } + const strategy = selectorKeys[0]; + requireNonEmptyString(selector[strategy], `${source}.${strategy}`); + if (strategy === 'role') { + requireExactKeys(selector, ['name', 'role'], source); + if (selector.name !== undefined) { + requireNonEmptyString(selector.name, `${source}.name`); + } + } else { + requireExactKeys(selector, [strategy], source); + } +} + +function validateEnumArray(value: unknown, allowed: readonly T[], source: string): void { + if (value === undefined) { + return; + } + if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && allowed.includes(item as T))) { + throw new TypeError(`${source} contains an unsupported value.`); + } +} + +function requireExactKeys(value: Record, allowed: readonly string[], source: string): void { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + throw new TypeError(`${source} contains unknown field "${unknown[0]}".`); + } +} + +function requireNonEmptyString(value: unknown, source: string): asserts value is string { + if (typeof value !== 'string' || !value) { + throw new TypeError(`${source} must be a non-empty string.`); + } +} + +function requireFiniteNumber(value: unknown, source: string): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError(`${source} must be a finite number.`); + } +} + +function requireObject(value: unknown, source: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${source} must be an object.`); + } + return value as Record; +} + +function assertJsonValue(value: unknown, source: string): void { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new TypeError(`${source} must contain only finite numbers.`); + } + return; + } + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => assertJsonValue(item, `${source}[${index}]`)); + return; + } + if (typeof value === 'object') { + for (const [key, item] of Object.entries(value as Record)) { + assertJsonValue(item, `${source}.${key}`); + } + return; + } + throw new TypeError(`${source} must contain only JSON-serializable values.`); +} diff --git a/packages/agentic/desktop-driver/src/cli/DesktopDriverCli.test.ts b/packages/agentic/desktop-driver/src/cli/DesktopDriverCli.test.ts new file mode 100644 index 00000000000..2d291c62424 --- /dev/null +++ b/packages/agentic/desktop-driver/src/cli/DesktopDriverCli.test.ts @@ -0,0 +1,150 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { createServer } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +import type { DesktopStoryManifest } from '../storybook.js'; + +jest.setTimeout(30_000); + +describe('desktop-driver CLI', () => { + test('serves, lists, runs, and describes a fake authored plan as JSON', async () => { + const port = await getAvailablePort(); + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-cli-')); + const artifactsRoot = path.join(temporaryDirectory, 'artifacts'); + const manifestPath = path.join(temporaryDirectory, 'manifest.json'); + const manifest: DesktopStoryManifest = { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/button/button.stories.tsx', + tags: ['e2e', 'story'], + tests: { + version: 1, + tests: [ + { + id: 'cli-plan', + steps: [{ expect: { state: 'enabled', target: { testId: 'button-primary' }, value: true } }], + }, + ], + }, + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform-digest', + portablePlanDigest: 'portable-digest', + schemaVersion: 1, + }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const server = spawnCli(['serve', '--port', String(port), '--target', 'cli-target', '--manifest', manifestPath]); + try { + await waitForResponse(loopbackUrl(port, '/status')); + const url = loopbackUrl(port, ''); + const stories = await runCli(['stories', 'list', '--url', url, '--target', 'cli-target']); + expect(stories).toMatchObject({ entries: [{ id: 'components-button--default' }] }); + + const result = await runCli([ + 'stories', + 'run', + '--url', + url, + '--target', + 'cli-target', + '--artifacts', + artifactsRoot, + '--test', + 'cli-plan', + ]); + expect(result).toMatchObject({ status: 'passed', tests: [{ testId: 'cli-plan' }] }); + + const tree = await runCli([ + 'agent', + 'describe', + '--url', + url, + '--target', + 'cli-target', + '--artifacts', + artifactsRoot, + '--scope', + 'story', + ]); + expect(tree).toMatchObject([{ testId: 'story-root' }]); + } finally { + server.kill(); + await waitForExit(server); + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }); +}); + +function spawnCli(args: readonly string[]) { + return spawn(process.execPath, [path.resolve(__dirname, '../../config/cli.cjs'), ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function runCli(args: readonly string[]): Promise> { + return new Promise((resolve, reject) => { + const child = spawnCli(args); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.once('error', reject); + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || `Desktop Driver CLI exited with code ${code}.`)); + return; + } + resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')) as Record); + }); + }); +} + +function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Could not allocate a loopback port.')); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +async function waitForResponse(url: string): Promise { + const deadline = Date.now() + 20_000; + do { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Retry until the bounded startup deadline. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (Date.now() < deadline); + throw new Error(`Timed out waiting for ${url}.`); +} + +function waitForExit(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(); + } + return new Promise((resolve) => child.once('exit', () => resolve())); +} + +function loopbackUrl(port: number, pathname: string): string { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback service + return `http://127.0.0.1:${port}${pathname}`; +} diff --git a/packages/agentic/desktop-driver/src/cli/createDesktopDriverCommand.ts b/packages/agentic/desktop-driver/src/cli/createDesktopDriverCommand.ts new file mode 100644 index 00000000000..664fc4ccfc7 --- /dev/null +++ b/packages/agentic/desktop-driver/src/cli/createDesktopDriverCommand.ts @@ -0,0 +1,290 @@ +import fs from 'node:fs'; + +import { Command, Option } from 'commander'; + +import { connectDesktopAgent } from '../agent/DesktopAgent.js'; +import { validateDesktopStoryTests } from '../authoring/storyTests.js'; +import type { DesktopStoryManifest } from '../storybook.js'; +import { FakeDesktopHost } from '../hosts/fake/FakeDesktopHost.js'; +import type { DesktopEndpoint, DesktopPlatformName, DesktopRenderer } from '../protocol/types.js'; +import { createDesktopDriverServer } from '../server/createDesktopDriverServer.js'; +import { FakeStoryOrchestrator } from '../testing/FakeStoryOrchestrator.js'; +import { createFakeStoryWindows } from '../testing/fakeStoryElements.js'; +import { connectDesktopWebdriver } from '../wdio/DesktopWebdriver.js'; + +type ConnectionFlags = { + artifacts?: string; + platform: DesktopPlatformName; + target: string; + url: string; +}; + +type SelectionFlags = ConnectionFlags & { + shardCount?: number; + shardIndex?: number; + story?: string; + tag?: string; + test?: string; +}; + +export type CreateDesktopDriverCommandOptions = { + stderr?: Pick; + stdout?: Pick; +}; + +export function createDesktopDriverCommand(options: CreateDesktopDriverCommandOptions = {}): Command { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const program = new Command() + .name('desktop-driver') + .description('Drive React Native desktop applications through W3C WebDriver without Appium.') + .configureOutput({ + writeErr: (value) => stderr.write(value), + writeOut: (value) => stdout.write(value), + }); + + program + .command('serve') + .description('Start a deterministic fake Desktop Driver target.') + .option('--host ', 'loopback host', '127.0.0.1') + .option('--port ', 'listener port; defaults to an available port', parsePort) + .option('--target ', 'registered target id', 'desktop-driver-fake') + .addOption(new Option('--platform ', 'WebDriver platform name').choices(['macos', 'windows']).default('windows')) + .addOption(new Option('--endpoint ', 'desktop endpoint').choices(['macos', 'windows', 'win32']).default('windows')) + .addOption(new Option('--renderer ', 'React Native renderer').choices(['fabric', 'paper']).default('fabric')) + .option('--manifest ', 'optional Story Manifest for fake story orchestration') + .action(async (flags) => { + const manifest = flags.manifest ? readManifest(flags.manifest) : undefined; + const host = new FakeDesktopHost({ + endpoint: flags.endpoint as DesktopEndpoint, + platformName: flags.platform as DesktopPlatformName, + ...(manifest ? { storyRootTestId: 'story-root' } : {}), + ...(manifest ? { windows: createFakeStoryWindows(manifest) } : {}), + }); + const storyOrchestrator = manifest ? new FakeStoryOrchestrator(manifest, host) : undefined; + const server = await createDesktopDriverServer({ + host: flags.host, + port: flags.port, + targets: [ + { + endpoint: flags.endpoint as DesktopEndpoint, + host, + id: flags.target, + platformName: flags.platform as DesktopPlatformName, + renderer: flags.renderer as DesktopRenderer, + ...(storyOrchestrator ? { storyOrchestrator, storyRootTestId: 'story-root' } : {}), + }, + ], + }); + writeJson(stdout, { server: server.url, targetId: flags.target }); + try { + await waitForSignal(); + } finally { + await server.close(); + } + }); + + const stories = program.command('stories').description('Inspect or run authored desktop story tests.'); + addConnectionOptions( + stories + .command('list') + .description('List stories and tests from the active platform manifest.') + .action(async (flags: ConnectionFlags) => { + const desktop = await connect(flags); + try { + writeJson(stdout, await desktop.listStories()); + } finally { + await desktop.delete(); + } + }), + ); + addConnectionOptions( + stories + .command('explain') + .description('Print one story and its authored test plans.') + .argument('') + .action(async (storyId: string, flags: ConnectionFlags) => { + const desktop = await connect(flags); + try { + const story = (await desktop.listStories()).entries.find(({ id }) => id === storyId); + if (!story) { + throw new Error(`Story "${storyId}" is not present in the active manifest.`); + } + writeJson(stdout, story); + } finally { + await desktop.delete(); + } + }), + ); + addSelectionOptions( + addConnectionOptions( + stories + .command('run') + .description('Run selected authored plans through WebdriverIO.') + .requiredOption('--artifacts ', 'artifact and report root') + .action(async (flags: SelectionFlags) => { + const artifactsRoot = requireOption(flags.artifacts, '--artifacts'); + const desktop = await connect(flags); + try { + const result = await desktop.runStoryTests({ + artifactsRoot, + selection: { + shardCount: flags.shardCount, + shardIndex: flags.shardIndex, + story: flags.story, + tag: flags.tag, + test: flags.test, + }, + }); + writeJson(stdout, result); + if (result.status !== 'passed') { + process.exitCode = 1; + } + } finally { + await desktop.delete(); + } + }), + ), + ); + + const agent = program.command('agent').description('Use bounded JSON-safe agent operations.'); + addConnectionOptions( + agent + .command('describe') + .description('Print a bounded accessibility-tree projection.') + .requiredOption('--artifacts ', 'artifact root') + .option('--depth ', 'maximum tree depth', parseNonNegativeInteger, 3) + .option('--max-nodes ', 'maximum returned nodes', parsePositiveInteger, 100) + .addOption(new Option('--scope ', 'tree scope').choices(['application', 'story']).default('story')) + .action(async (flags: ConnectionFlags & { depth: number; maxNodes: number; scope: 'application' | 'story' }) => { + const artifactsRoot = requireOption(flags.artifacts, '--artifacts'); + const desktopAgent = await connectDesktopAgent({ + artifactsRoot, + platformName: flags.platform, + targetId: flags.target, + url: flags.url, + }); + try { + writeJson(stdout, await desktopAgent.describe({ depth: flags.depth, maxNodes: flags.maxNodes, scope: flags.scope })); + } finally { + await desktopAgent.delete(); + } + }), + ); + addConnectionOptions( + agent + .command('screenshot') + .description('Capture a named screenshot artifact.') + .requiredOption('--artifacts ', 'artifact root') + .requiredOption('--name ', 'artifact name') + .action(async (flags: ConnectionFlags & { name: string }) => { + const artifactsRoot = requireOption(flags.artifacts, '--artifacts'); + const desktopAgent = await connectDesktopAgent({ + artifactsRoot, + platformName: flags.platform, + targetId: flags.target, + url: flags.url, + }); + try { + writeJson(stdout, await desktopAgent.screenshot(flags.name)); + } finally { + await desktopAgent.delete(); + } + }), + ); + + return program; +} + +export async function runDesktopDriverCli(argv: readonly string[] = process.argv): Promise { + await createDesktopDriverCommand().parseAsync([...argv]); +} + +function addConnectionOptions(command: T): T { + return command + .requiredOption('--url ', 'Desktop Driver server URL') + .requiredOption('--target ', 'registered target id') + .addOption(new Option('--platform ', 'WebDriver platform name').choices(['macos', 'windows']).default('windows')) as T; +} + +function addSelectionOptions(command: T): T { + return command + .option('--story ', 'story id glob') + .option('--test ', 'test id glob') + .option('--tag ', 'required story tag') + .option('--shard-index ', 'zero-based shard index', parseNonNegativeInteger) + .option('--shard-count ', 'number of shards', parsePositiveInteger) as T; +} + +function connect(flags: ConnectionFlags) { + return connectDesktopWebdriver({ + platformName: flags.platform, + targetId: flags.target, + url: flags.url, + }); +} + +function readManifest(manifestPath: string): DesktopStoryManifest { + const value = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as DesktopStoryManifest; + if ( + value.schemaVersion !== 1 || + !Array.isArray(value.entries) || + (value.endpoint !== 'macos' && value.endpoint !== 'windows' && value.endpoint !== 'win32') || + typeof value.platformManifestDigest !== 'string' || + typeof value.portablePlanDigest !== 'string' + ) { + throw new Error(`Invalid Desktop Story Manifest at ${manifestPath}.`); + } + for (const entry of value.entries) { + if (!entry || typeof entry.id !== 'string' || !Array.isArray(entry.tags)) { + throw new Error(`Invalid Desktop Story Manifest entry at ${manifestPath}.`); + } + if (entry.tests) { + validateDesktopStoryTests(entry.tests, `${manifestPath}#${entry.id}`); + } + } + return value; +} + +function writeJson(output: Pick, value: unknown): void { + output.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function waitForSignal(): Promise { + return new Promise((resolve) => { + process.once('SIGINT', resolve); + process.once('SIGTERM', resolve); + }); +} + +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new TypeError(`Port must be an integer between 0 and 65535. Received "${value}".`); + } + + return port; +} + +function requireOption(value: string | undefined, name: string): string { + if (!value) { + throw new TypeError(`${name} requires a non-empty value.`); + } + return value; +} + +function parseNonNegativeInteger(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new TypeError(`Expected a non-negative integer. Received "${value}".`); + } + return parsed; +} + +function parsePositiveInteger(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new TypeError(`Expected a positive integer. Received "${value}".`); + } + return parsed; +} diff --git a/packages/agentic/desktop-driver/src/cli/index.ts b/packages/agentic/desktop-driver/src/cli/index.ts new file mode 100644 index 00000000000..b9c1774e679 --- /dev/null +++ b/packages/agentic/desktop-driver/src/cli/index.ts @@ -0,0 +1,2 @@ +export { createDesktopDriverCommand, runDesktopDriverCli } from './createDesktopDriverCommand.js'; +export type { CreateDesktopDriverCommandOptions } from './createDesktopDriverCommand.js'; diff --git a/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts b/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts new file mode 100644 index 00000000000..a3eb6856a47 --- /dev/null +++ b/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts @@ -0,0 +1,218 @@ +import { randomUUID } from 'node:crypto'; + +import { WebDriverError } from '../protocol/errors.js'; +import { webElementIdentifier } from '../protocol/constants.js'; +import type { DesktopTreeNode } from '../host/types.js'; +import type { + NewSessionCapabilities, + WebDriverActionSequence, + WebDriverElement, + WebDriverErrorResponse, + WebDriverResponse, + WebDriverTimeouts, +} from '../protocol/types.js'; +import type { DesktopStoryManifest, StoryReadyResult } from '../storybook.js'; + +export type DesktopDriverClientOptions = { + fetch?: typeof globalThis.fetch; + url: string; +}; + +export class DesktopDriverClient { + private readonly fetch: typeof globalThis.fetch; + private readonly url: string; + + constructor({ fetch = globalThis.fetch, url }: DesktopDriverClientOptions) { + this.fetch = fetch; + this.url = url.replace(/\/$/, ''); + } + + status(): Promise> { + return this.request('GET', '/status'); + } + + async newSession(capabilities: NewSessionCapabilities): Promise { + const value = await this.request<{ capabilities: Record; sessionId: string }>('POST', '/session', { + capabilities, + }); + return new DesktopSessionClient(this, value.sessionId, value.capabilities); + } + + async request(method: string, path: string, body?: unknown): Promise { + const response = await this.fetch(`${this.url}${path}`, { + method, + ...(body === undefined + ? {} + : { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }), + }); + const payload = (await response.json()) as WebDriverResponse | WebDriverErrorResponse; + if (!response.ok) { + const error = payload.value as WebDriverErrorResponse['value']; + throw new WebDriverError(error.error as ConstructorParameters[0], error.message, error.data); + } + return payload.value as T; + } +} + +export class DesktopSessionClient { + private readonly driver: DesktopDriverClient; + readonly id: string; + readonly capabilities: Readonly>; + + constructor(driver: DesktopDriverClient, id: string, capabilities: Readonly>) { + this.driver = driver; + this.id = id; + this.capabilities = capabilities; + } + + delete(): Promise { + return this.command('DELETE', ''); + } + + getTimeouts(): Promise { + return this.command('GET', '/timeouts'); + } + + setTimeouts(timeouts: Partial): Promise { + return this.command('POST', '/timeouts', timeouts); + } + + getWindowHandle(): Promise { + return this.command('GET', '/window'); + } + + getWindowHandles(): Promise { + return this.command('GET', '/window/handles'); + } + + switchToWindow(handle: string): Promise { + return this.command('POST', '/window', { handle }); + } + + findElement(using: string, value: string): Promise { + return this.command('POST', '/element', { using, value }).then( + (element) => new DesktopElementClient(this, element[webElementIdentifier]), + ); + } + + findElements(using: string, value: string): Promise { + return this.command('POST', '/elements', { using, value }).then((elements) => + elements.map((element) => new DesktopElementClient(this, element[webElementIdentifier])), + ); + } + + getActiveElement(): Promise { + return this.command('GET', '/element/active').then((element) => + element ? new DesktopElementClient(this, element[webElementIdentifier]) : null, + ); + } + + performActions(actions: readonly WebDriverActionSequence[]): Promise { + return this.command('POST', '/actions', { actions }); + } + + releaseActions(): Promise { + return this.command('DELETE', '/actions'); + } + + takeScreenshot(): Promise { + return this.command('GET', '/screenshot'); + } + + getPageSource(): Promise { + return this.command('GET', '/source'); + } + + getTree(): Promise { + return this.command('GET', '/furn/tree'); + } + + getStoryManifest(): Promise { + return this.command('GET', '/furn/manifest'); + } + + getCurrentStory(): Promise { + return this.command('GET', '/furn/story'); + } + + selectStory(storyId: string, runId: string = randomUUID()): Promise { + return this.command('POST', '/furn/story', { requestId: randomUUID(), runId, storyId }); + } + + resetStory(storyId: string, runId: string = randomUUID()): Promise { + return this.command('POST', '/furn/story/reset', { requestId: randomUUID(), runId, storyId }); + } + + updateStoryArgs(storyId: string, args: Readonly>): Promise { + return this.command('POST', '/furn/story/args', { args, storyId }); + } + + command(method: string, path: string, body?: unknown): Promise { + return this.driver.request(method, `/session/${this.id}${path}`, body); + } +} + +export class DesktopElementClient { + private readonly session: DesktopSessionClient; + readonly id: string; + + constructor(session: DesktopSessionClient, id: string) { + this.session = session; + this.id = id; + } + + click(): Promise { + return this.command('POST', '/click', {}); + } + + clear(): Promise { + return this.command('POST', '/clear', {}); + } + + sendKeys(text: string): Promise { + return this.command('POST', '/value', { text }); + } + + getText(): Promise { + return this.command('GET', '/text'); + } + + getTagName(): Promise { + return this.command('GET', '/name'); + } + + isEnabled(): Promise { + return this.command('GET', '/enabled'); + } + + isDisplayed(): Promise { + return this.command('GET', '/displayed'); + } + + isSelected(): Promise { + return this.command('GET', '/selected'); + } + + getAttribute(name: string): Promise { + return this.command('GET', `/attribute/${encodeURIComponent(name)}`); + } + + getProperty(name: string): Promise { + return this.command('GET', `/property/${encodeURIComponent(name)}`); + } + + takeScreenshot(): Promise { + return this.command('GET', '/screenshot'); + } + + private command(method: string, path: string, body?: unknown): Promise { + return this.session.command(method, `/element/${this.id}${path}`, body); + } +} + +export function createDesktopDriverClient(options: DesktopDriverClientOptions): DesktopDriverClient { + return new DesktopDriverClient(options); +} diff --git a/packages/agentic/desktop-driver/src/client/index.ts b/packages/agentic/desktop-driver/src/client/index.ts new file mode 100644 index 00000000000..21f97f2fa4f --- /dev/null +++ b/packages/agentic/desktop-driver/src/client/index.ts @@ -0,0 +1,2 @@ +export { createDesktopDriverClient, DesktopDriverClient, DesktopElementClient, DesktopSessionClient } from './DesktopDriverClient.js'; +export type { DesktopDriverClientOptions } from './DesktopDriverClient.js'; diff --git a/packages/agentic/desktop-driver/src/host/types.ts b/packages/agentic/desktop-driver/src/host/types.ts new file mode 100644 index 00000000000..8b482646be2 --- /dev/null +++ b/packages/agentic/desktop-driver/src/host/types.ts @@ -0,0 +1,158 @@ +import type { DesktopClickMode, DesktopEndpoint, DesktopPlatformName, DesktopRenderer } from '../protocol/types.js'; +import type { WebDriverAction, WebDriverActionSequence } from '../protocol/types.js'; +import type { StoryOrchestrator } from '../storybook.js'; + +export type Rect = { + height: number; + width: number; + x: number; + y: number; +}; + +export type SupportedValue = { supported: true; value: T } | { supported: false; reason: string }; + +export type DesktopHostFeatures = { + accessibilityClick: boolean; + elementScreenshot: boolean; + focus: boolean; + keyboard: boolean; + physicalClick: boolean; + screenshot: boolean; + setWindowRect: boolean; + wheel: boolean; +}; + +export type DesktopHostInfo = { + endpoint: DesktopEndpoint; + features: DesktopHostFeatures; + platformName: DesktopPlatformName; + protocolVersion: 1; +}; + +export type ApplicationLease = { + id: string; + ownership: 'attached' | 'launched'; + processId?: number; + processStartedAt?: string; +}; + +export type DesktopWindow = { + id: string; + rect: Rect; + title: string; +}; + +export type NativeElementScope = 'application' | 'chrome' | 'preview' | 'secondary-window'; + +export type NativeElementSnapshot = { + id: string; + automationId?: string; + checked: SupportedValue; + enabled: SupportedValue; + expanded: SupportedValue; + focused: SupportedValue; + name?: string; + parentId?: string; + rect: Rect; + role: string; + scope: NativeElementScope; + selected: SupportedValue; + text?: string; + value?: string; + visible: SupportedValue; + windowId: string; +}; + +export type NativeSelector = { + strategy: '-furn:text' | 'accessibility id' | 'link text' | 'partial link text' | 'tag name'; + value: string; +}; + +export type NativeSearchRoot = { + elementId?: string; + windowId: string; +}; + +export type NativeImage = { + data: Uint8Array; + height: number; + mimeType: 'image/png'; + scaleFactor: number; + width: number; +}; + +export type NativeActionOrigin = 'pointer' | 'viewport' | { elementId: string }; +export type NativeAction = WebDriverAction & { + origin?: NativeActionOrigin; +}; +export type NativeActionSequence = Omit & { + actions: NativeAction[]; +}; + +export type DesktopTreeNode = { + children: readonly DesktopTreeNode[]; + name?: string; + rect: Rect; + role: string; + states: { + checked?: boolean | 'mixed'; + enabled?: boolean; + expanded?: boolean; + focused?: boolean; + selected?: boolean; + visible?: boolean; + }; + testId?: string; + text?: string; + value?: string; +}; + +export type DesktopTarget = { + endpoint: DesktopEndpoint; + host: DesktopHost; + id: string; + platformName: DesktopPlatformName; + renderer: DesktopRenderer; + storyRootTestId?: string; + storyOrchestrator?: StoryOrchestrator; +}; + +export type DesktopHostEvent = + | { type: 'application-exited'; applicationId: string } + | { type: 'structure-changed'; windowId: string } + | { type: 'window-closed'; windowId: string } + | { type: 'window-opened'; windowId: string }; + +export interface DesktopHost { + readonly endpoint: DesktopEndpoint; + + probe(signal?: AbortSignal): Promise; + launch(target: DesktopTarget, signal?: AbortSignal): Promise; + attach(target: DesktopTarget, signal?: AbortSignal): Promise; + closeApplication(lease: ApplicationLease, signal?: AbortSignal): Promise; + + windows(lease: ApplicationLease, signal?: AbortSignal): Promise; + closeWindow(windowId: string, signal?: AbortSignal): Promise; + activate(windowId: string, signal?: AbortSignal): Promise; + getWindowRect(windowId: string, signal?: AbortSignal): Promise; + setWindowRect(windowId: string, rect: Partial, signal?: AbortSignal): Promise; + + find(root: NativeSearchRoot, selector: NativeSelector, signal?: AbortSignal): Promise; + snapshot(elementId: string, signal?: AbortSignal): Promise; + activeElement(windowId: string, signal?: AbortSignal): Promise; + hitTest(windowId: string, x: number, y: number, signal?: AbortSignal): Promise; + + click(elementId: string, mode: DesktopClickMode, signal?: AbortSignal): Promise; + clear(elementId: string, signal?: AbortSignal): Promise; + sendKeys(elementId: string, text: string, signal?: AbortSignal): Promise; + performActions(actions: readonly NativeActionSequence[], signal?: AbortSignal): Promise; + releaseActions(signal?: AbortSignal): Promise; + + captureWindow(windowId: string, signal?: AbortSignal): Promise; + captureElement(elementId: string, signal?: AbortSignal): Promise; + source(windowId: string, signal?: AbortSignal): Promise; + tree(windowId: string, signal?: AbortSignal): Promise; + + subscribe?(listener: (event: DesktopHostEvent) => void): () => void; + dispose(signal?: AbortSignal): Promise; +} diff --git a/packages/agentic/desktop-driver/src/hosts/fake/FakeDesktopHost.ts b/packages/agentic/desktop-driver/src/hosts/fake/FakeDesktopHost.ts new file mode 100644 index 00000000000..547e7d813da --- /dev/null +++ b/packages/agentic/desktop-driver/src/hosts/fake/FakeDesktopHost.ts @@ -0,0 +1,558 @@ +import { randomUUID } from 'node:crypto'; + +import type { + ApplicationLease, + DesktopHost, + DesktopHostEvent, + DesktopHostFeatures, + DesktopHostInfo, + DesktopTarget, + DesktopWindow, + NativeElementSnapshot, + NativeImage, + NativeActionSequence, + NativeSearchRoot, + NativeSelector, + Rect, +} from '../../host/types.js'; +import { HostStaleError, HostUnsupportedError } from '../../protocol/errors.js'; +import type { DesktopClickMode, DesktopEndpoint, DesktopPlatformName } from '../../protocol/types.js'; + +export type FakeDesktopElement = Omit & { + checked?: boolean | 'mixed'; + enabled?: boolean; + expanded?: boolean; + focused?: boolean; + selected?: boolean; + visible?: boolean; +}; + +export type FakeDesktopWindow = Omit & { + elements: readonly FakeDesktopElement[]; + rect?: Rect; +}; + +export type FakeDesktopHostOptions = { + actionDelayMs?: number; + closeDelayMs?: number; + endpoint?: DesktopEndpoint; + features?: Partial; + launchDelayMs?: number; + platformName?: DesktopPlatformName; + screenshot?: Uint8Array; + storyRootTestId?: string; + windows?: readonly FakeDesktopWindow[]; +}; + +const onePixelPng = Uint8Array.from( + Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), +); + +const defaultRect = { x: 0, y: 0, width: 800, height: 600 }; +const defaultFeatures: DesktopHostFeatures = { + accessibilityClick: true, + elementScreenshot: true, + focus: true, + keyboard: true, + physicalClick: true, + screenshot: true, + setWindowRect: true, + wheel: true, +}; + +export class FakeDesktopHost implements DesktopHost { + readonly endpoint: DesktopEndpoint; + readonly actions: Record[] = []; + + private readonly platformName: DesktopPlatformName; + private readonly actionDelayMs: number; + private readonly closeDelayMs: number; + private readonly features: DesktopHostFeatures; + private readonly launchDelayMs: number; + private readonly screenshot: Uint8Array; + private readonly windowsById = new Map(); + private readonly elements = new Map(); + private readonly initialElements = new Map(); + private readonly listeners = new Set<(event: DesktopHostEvent) => void>(); + + constructor(options: FakeDesktopHostOptions = {}) { + this.endpoint = options.endpoint ?? 'windows'; + this.actionDelayMs = options.actionDelayMs ?? 0; + this.closeDelayMs = options.closeDelayMs ?? 0; + this.platformName = options.platformName ?? (this.endpoint === 'macos' ? 'macos' : 'windows'); + this.features = { ...defaultFeatures, ...options.features }; + this.launchDelayMs = options.launchDelayMs ?? 0; + this.screenshot = options.screenshot ?? onePixelPng; + + const windows = options.windows ?? [createDefaultWindow(options.storyRootTestId)]; + for (const window of windows) { + this.windowsById.set(window.id, { + id: window.id, + title: window.title, + rect: { ...defaultRect, ...window.rect }, + }); + for (const element of window.elements) { + this.elements.set(element.id, toSnapshot(element)); + } + } + for (const [id, element] of this.elements) { + this.initialElements.set(id, cloneElement(element)); + } + } + + setElementName(elementId: string, name: string): void { + this.requireElement(elementId).name = name; + } + + setElementStateUnsupported( + elementId: string, + state: 'checked' | 'enabled' | 'expanded' | 'focused' | 'selected' | 'visible', + reason: string, + ): void { + const update = (element: NativeElementSnapshot) => { + switch (state) { + case 'checked': + element.checked = { supported: false, reason }; + break; + case 'enabled': + element.enabled = { supported: false, reason }; + break; + case 'expanded': + element.expanded = { supported: false, reason }; + break; + case 'focused': + element.focused = { supported: false, reason }; + break; + case 'selected': + element.selected = { supported: false, reason }; + break; + case 'visible': + element.visible = { supported: false, reason }; + break; + } + }; + update(this.requireElement(elementId)); + const initial = this.initialElements.get(elementId); + if (initial) { + update(initial); + } + } + + async probe(signal?: AbortSignal): Promise { + throwIfAborted(signal); + return { + endpoint: this.endpoint, + features: { ...this.features }, + platformName: this.platformName, + protocolVersion: 1, + }; + } + + async launch(target: DesktopTarget, signal?: AbortSignal): Promise { + this.actions.push({ type: 'launch', target: target.id }); + await delay(this.launchDelayMs, signal); + return { id: randomUUID(), ownership: 'launched', processId: 1000, processStartedAt: new Date(0).toISOString() }; + } + + async attach(target: DesktopTarget, signal?: AbortSignal): Promise { + this.actions.push({ type: 'attach', target: target.id }); + await delay(this.launchDelayMs, signal); + return { id: randomUUID(), ownership: 'attached', processId: 1000, processStartedAt: new Date(0).toISOString() }; + } + + async closeApplication(lease: ApplicationLease, signal?: AbortSignal): Promise { + throwIfAborted(signal); + this.actions.push({ type: 'close-application-start', lease: lease.id }); + await delay(this.closeDelayMs, signal); + this.actions.push({ type: 'close-application', lease: lease.id, ownership: lease.ownership }); + } + + async windows(_lease: ApplicationLease): Promise { + return [...this.windowsById.values()].map(cloneWindow); + } + + async closeWindow(windowId: string): Promise { + if (!this.windowsById.delete(windowId)) { + throw new HostStaleError(`Window "${windowId}" is no longer available.`); + } + for (const [id, element] of this.elements) { + if (element.windowId === windowId) { + this.elements.delete(id); + } + } + for (const listener of this.listeners) { + listener({ type: 'window-closed', windowId }); + } + } + + async activate(windowId: string): Promise { + this.requireWindow(windowId); + this.actions.push({ type: 'activate', windowId }); + } + + async getWindowRect(windowId: string): Promise { + return { ...this.requireWindow(windowId).rect }; + } + + async setWindowRect(windowId: string, rect: Partial): Promise { + if (!this.features.setWindowRect) { + throw new HostUnsupportedError('The fake target does not support setting the window rectangle.'); + } + const window = this.requireWindow(windowId); + window.rect = { ...window.rect, ...rect }; + return { ...window.rect }; + } + + async find(root: NativeSearchRoot, selector: NativeSelector): Promise { + this.requireWindow(root.windowId); + if (root.elementId) { + this.requireElement(root.elementId); + } + + return [...this.elements.values()] + .filter((element) => element.windowId === root.windowId) + .filter((element) => !root.elementId || isDescendantOf(element, root.elementId, this.elements)) + .filter((element) => matches(element, selector)) + .map(cloneElement); + } + + async snapshot(elementId: string): Promise { + return cloneElement(this.requireElement(elementId)); + } + + async activeElement(windowId: string): Promise { + this.requireWindow(windowId); + const focused = [...this.elements.values()].find( + (element) => element.windowId === windowId && element.focused.supported && element.focused.value, + ); + return focused ? cloneElement(focused) : null; + } + + async hitTest(windowId: string, x: number, y: number): Promise { + this.requireWindow(windowId); + const matchesPoint = [...this.elements.values()].filter( + (element) => + element.windowId === windowId && + isSupportedTrue(element.visible) && + x >= element.rect.x && + y >= element.rect.y && + x <= element.rect.x + element.rect.width && + y <= element.rect.y + element.rect.height, + ); + const element = matchesPoint.at(-1); + return element ? cloneElement(element) : null; + } + + async click(elementId: string, mode: DesktopClickMode, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const element = this.requireElement(elementId); + const resolvedMode = + mode === 'auto' ? (this.features.physicalClick ? 'physical' : this.features.accessibilityClick ? 'accessibility' : undefined) : mode; + if ( + !resolvedMode || + (resolvedMode === 'physical' && !this.features.physicalClick) || + (resolvedMode === 'accessibility' && !this.features.accessibilityClick) + ) { + throw new HostUnsupportedError(`Click mode "${mode}" is unavailable.`); + } + for (const current of this.elements.values()) { + if (current.windowId === element.windowId && current.focused.supported) { + current.focused = { supported: true, value: current.id === element.id }; + } + } + if (element.role === 'checkbox' && element.checked.supported) { + element.checked = { supported: true, value: element.checked.value === true ? false : true }; + } + this.actions.push({ type: 'click', elementId, mode: resolvedMode }); + } + + async clear(elementId: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const element = this.requireElement(elementId); + element.value = ''; + element.text = ''; + this.actions.push({ type: 'clear', elementId }); + } + + async sendKeys(elementId: string, text: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const element = this.requireElement(elementId); + element.value = `${element.value ?? ''}${text}`; + element.text = element.value; + this.actions.push({ type: 'send-keys', elementId, text }); + } + + async performActions(actions: readonly NativeActionSequence[], signal?: AbortSignal): Promise { + this.actions.push({ type: 'actions-start' }); + await delay(this.actionDelayMs, signal); + this.actions.push({ type: 'actions', actions }); + } + + async releaseActions(signal?: AbortSignal): Promise { + throwIfAborted(signal); + this.actions.push({ type: 'release-actions' }); + } + + async captureWindow(windowId: string): Promise { + this.requireWindow(windowId); + if (!this.features.screenshot) { + throw new HostUnsupportedError('The fake target does not support screenshots.'); + } + return createImage(this.screenshot); + } + + async captureElement(elementId: string): Promise { + this.requireElement(elementId); + if (!this.features.elementScreenshot) { + throw new HostUnsupportedError('The fake target does not support element screenshots.'); + } + return createImage(this.screenshot); + } + + async source(windowId: string): Promise { + this.requireWindow(windowId); + const roots = [...this.elements.values()].filter((element) => element.windowId === windowId && !element.parentId); + return `${roots.map((element) => serializeElement(element, this.elements)).join('')}`; + } + + async tree(windowId: string): Promise { + this.requireWindow(windowId); + return [...this.elements.values()].filter((element) => element.windowId === windowId).map(cloneElement); + } + + subscribe(listener: (event: DesktopHostEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async dispose(signal?: AbortSignal): Promise { + throwIfAborted(signal); + this.listeners.clear(); + this.actions.push({ type: 'dispose' }); + } + + removeElement(elementId: string): void { + if (this.elements.delete(elementId)) { + for (const [id, element] of this.elements) { + if (element.parentId === elementId) { + this.removeElement(id); + } + } + } + } + + replacePreview(elements: readonly FakeDesktopElement[]): void { + for (const [id, element] of this.elements) { + if (element.scope === 'preview') { + this.elements.delete(id); + } + } + for (const element of elements) { + this.elements.set(element.id, toSnapshot(element)); + } + for (const windowId of new Set(elements.map((element) => element.windowId))) { + for (const listener of this.listeners) { + listener({ type: 'structure-changed', windowId }); + } + } + } + + resetPreview(): void { + for (const [id, element] of this.elements) { + if (element.scope === 'preview') { + this.elements.delete(id); + } + } + for (const [id, element] of this.initialElements) { + if (element.scope === 'preview') { + this.elements.set(id, cloneElement(element)); + } + } + } + + private requireWindow(windowId: string): DesktopWindow { + const window = this.windowsById.get(windowId); + if (!window) { + throw new HostStaleError(`Window "${windowId}" is no longer available.`); + } + return window; + } + + private requireElement(elementId: string): NativeElementSnapshot { + const element = this.elements.get(elementId); + if (!element) { + throw new HostStaleError(`Element "${elementId}" is no longer available.`); + } + return element; + } +} + +function createDefaultWindow(storyRootTestId = 'story-root'): FakeDesktopWindow { + return { + id: 'window-1', + title: 'Fake Desktop App', + elements: [ + { + id: 'root', + automationId: 'app-root', + enabled: true, + focused: false, + rect: defaultRect, + role: 'application', + scope: 'application', + selected: false, + visible: true, + windowId: 'window-1', + }, + { + id: 'story-root', + automationId: storyRootTestId, + enabled: true, + focused: false, + name: JSON.stringify({ previewGeneration: 0, storyId: 'initial--story' }), + parentId: 'root', + rect: defaultRect, + role: 'group', + scope: 'preview', + selected: false, + visible: true, + windowId: 'window-1', + }, + { + id: 'button', + automationId: 'button-primary', + enabled: true, + focused: false, + name: 'Primary', + parentId: 'root', + rect: { x: 10, y: 10, width: 120, height: 40 }, + role: 'button', + scope: 'preview', + selected: false, + text: 'Primary', + visible: true, + windowId: 'window-1', + }, + { + id: 'input', + automationId: 'input-name', + enabled: true, + focused: false, + name: 'Name', + parentId: 'root', + rect: { x: 10, y: 60, width: 200, height: 40 }, + role: 'textbox', + scope: 'preview', + selected: false, + value: '', + visible: true, + windowId: 'window-1', + }, + ], + }; +} + +function toSnapshot(element: FakeDesktopElement): NativeElementSnapshot { + return { + ...element, + rect: { ...element.rect }, + checked: { supported: true, value: element.checked ?? false }, + enabled: { supported: true, value: element.enabled ?? true }, + expanded: { supported: true, value: element.expanded ?? false }, + focused: { supported: true, value: element.focused ?? false }, + selected: { supported: true, value: element.selected ?? false }, + visible: { supported: true, value: element.visible ?? true }, + }; +} + +function cloneElement(element: NativeElementSnapshot): NativeElementSnapshot { + return { + ...element, + rect: { ...element.rect }, + checked: { ...element.checked }, + enabled: { ...element.enabled }, + expanded: { ...element.expanded }, + focused: { ...element.focused }, + selected: { ...element.selected }, + visible: { ...element.visible }, + }; +} + +function cloneWindow(window: DesktopWindow): DesktopWindow { + return { ...window, rect: { ...window.rect } }; +} + +function isDescendantOf(element: NativeElementSnapshot, ancestorId: string, elements: ReadonlyMap): boolean { + let parentId = element.parentId; + while (parentId) { + if (parentId === ancestorId) { + return true; + } + parentId = elements.get(parentId)?.parentId; + } + return false; +} + +function matches(element: NativeElementSnapshot, selector: NativeSelector): boolean { + switch (selector.strategy) { + case '-furn:text': + return element.text?.includes(selector.value) ?? element.value?.includes(selector.value) ?? false; + case 'accessibility id': + return element.automationId === selector.value; + case 'tag name': + return element.role === selector.value; + case 'link text': + return element.name === selector.value; + case 'partial link text': + return element.name?.includes(selector.value) ?? false; + } +} + +function isSupportedTrue(value: NativeElementSnapshot['visible']): boolean { + return value.supported && value.value; +} + +function createImage(data: Uint8Array): NativeImage { + return { data: Uint8Array.from(data), height: 1, mimeType: 'image/png', scaleFactor: 1, width: 1 }; +} + +function serializeElement(element: NativeElementSnapshot, elements: ReadonlyMap): string { + const attributes = [ + `id="${escapeXml(element.automationId ?? '')}"`, + `name="${escapeXml(element.name ?? '')}"`, + `role="${escapeXml(element.role)}"`, + ].join(' '); + const children = [...elements.values()] + .filter((candidate) => candidate.parentId === element.id) + .map((child) => serializeElement(child, elements)) + .join(''); + return `${children}`; +} + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error('Fake desktop host operation was aborted.'); + } +} + +function delay(milliseconds: number, signal?: AbortSignal): Promise { + throwIfAborted(signal); + if (!signal) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason instanceof Error ? signal.reason : new Error('Fake desktop host operation was aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/packages/agentic/desktop-driver/src/index.ts b/packages/agentic/desktop-driver/src/index.ts new file mode 100644 index 00000000000..3c68b8d5bd8 --- /dev/null +++ b/packages/agentic/desktop-driver/src/index.ts @@ -0,0 +1,101 @@ +export { + createDesktopDriverClient, + DesktopDriverClient, + DesktopElementClient, + DesktopSessionClient, +} from './client/DesktopDriverClient.js'; +export type { DesktopDriverClientOptions } from './client/DesktopDriverClient.js'; +export { connectDesktopAgent, DesktopAgent } from './agent/DesktopAgent.js'; +export type { + DesktopAgentCheckResult, + DesktopAgentDescribeOptions, + DesktopAgentElement, + DesktopAgentOptions, + DesktopAgentStory, +} from './agent/DesktopAgent.js'; +export { ArtifactManager } from './artifacts/ArtifactManager.js'; +export { createDesktopDriverCommand, runDesktopDriverCli } from './cli/createDesktopDriverCommand.js'; +export type { CreateDesktopDriverCommandOptions } from './cli/createDesktopDriverCommand.js'; +export type { + DesktopArtifact, + DesktopRunStatus, + DesktopStepStatus, + DesktopStoryRunResult, + DesktopStoryStepResult, + DesktopStoryTestResult, + DesktopTestStatus, +} from './authoring/results.js'; +export { + defineDesktopStoryTests, + desktopBy, + desktopStoryCapabilities, + desktopStoryPlatforms, + validateDesktopStoryTests, +} from './authoring/storyTests.js'; +export type { + DesktopStoryCapability, + DesktopStoryExpectation, + DesktopStoryPlatform, + DesktopStorySelector, + DesktopStoryState, + DesktopStoryStep, + DesktopStoryTest, + DesktopStoryTests, +} from './authoring/storyTests.js'; +export type { + ApplicationLease, + DesktopHost, + DesktopHostFeatures, + DesktopHostInfo, + DesktopTarget, + DesktopTreeNode, + DesktopWindow, + NativeElementScope, + NativeElementSnapshot, + NativeImage, + NativeAction, + NativeActionOrigin, + NativeActionSequence, + NativeSearchRoot, + NativeSelector, + Rect, + SupportedValue, +} from './host/types.js'; +export { webElementIdentifier } from './protocol/constants.js'; +export { HostStaleError, HostUnsupportedError, WebDriverError } from './protocol/errors.js'; +export type { WebDriverErrorCode } from './protocol/errors.js'; +export { + type DesktopClickMode, + type DesktopEndpoint, + type DesktopPlatformName, + type DesktopRenderer, + type DesktopTimeouts, + type NewSessionCapabilities, + type NewSessionRequest, + type WebDriverAction, + type WebDriverActionSequence, + type WebDriverElement, + type WebDriverErrorResponse, + type WebDriverResponse, + type WebDriverTimeouts, +} from './protocol/types.js'; +export { createDesktopDriverServer, SessionManager, TargetRegistry } from './server/index.js'; +export type { DesktopDriverServer, DesktopDriverServerOptions, DesktopSession, ElementRecord } from './server/index.js'; +export type { + DesktopStoryManifest, + DesktopStoryManifestEntry, + StoryOrchestrator, + StoryReadyResult, + StorySelectionRequest, +} from './storybook.js'; +export { + assertDesktopExpectation, + DesktopAssertionError, + findDesktopElement, + findDesktopElements, + runDesktopStoryTests, + selectDesktopStoryTests, +} from './runner/StoryTestRunner.js'; +export type { DesktopStoryTestRunnerOptions, DesktopStoryTestSelection } from './runner/StoryTestRunner.js'; +export { connectDesktopWebdriver, DesktopWebdriverSession } from './wdio/DesktopWebdriver.js'; +export type { DesktopWebdriverOptions, DesktopWebdriverRunOptions } from './wdio/DesktopWebdriver.js'; diff --git a/packages/agentic/desktop-driver/src/protocol/actions.ts b/packages/agentic/desktop-driver/src/protocol/actions.ts new file mode 100644 index 00000000000..667baf6426f --- /dev/null +++ b/packages/agentic/desktop-driver/src/protocol/actions.ts @@ -0,0 +1,163 @@ +import { invalidArgument } from './errors.js'; +import type { WebDriverAction, WebDriverActionSequence } from './types.js'; + +export type WebDriverInputState = { + pressedButtons: Set; + pressedKeys: Set; +}; + +export function createInputState(): WebDriverInputState { + return { pressedButtons: new Set(), pressedKeys: new Set() }; +} + +export function parseActionSequences( + value: unknown, + current: WebDriverInputState, +): { + actions: WebDriverActionSequence[]; + nextState: WebDriverInputState; +} { + if (!Array.isArray(value)) { + throw invalidArgument('"actions" must be an array.'); + } + const nextState = { + pressedButtons: new Set(current.pressedButtons), + pressedKeys: new Set(current.pressedKeys), + }; + const sourceIds = new Set(); + const actions = value.map((item, index) => parseSource(item, index, sourceIds, nextState)); + return { actions, nextState }; +} + +function parseSource(value: unknown, index: number, sourceIds: Set, state: WebDriverInputState): WebDriverActionSequence { + const source = requireObject(value, `actions[${index}]`); + if (typeof source.id !== 'string' || !source.id) { + throw invalidArgument(`actions[${index}].id must be a non-empty string.`); + } + if (sourceIds.has(source.id)) { + throw invalidArgument(`Action source id "${source.id}" is duplicated.`); + } + sourceIds.add(source.id); + if (source.type !== 'key' && source.type !== 'none' && source.type !== 'pointer' && source.type !== 'wheel') { + throw invalidArgument(`actions[${index}].type is not a supported input source.`); + } + if (!Array.isArray(source.actions)) { + throw invalidArgument(`actions[${index}].actions must be an array.`); + } + if (source.type === 'pointer' && source.parameters !== undefined) { + const parameters = requireObject(source.parameters, `actions[${index}].parameters`); + if ( + parameters.pointerType !== undefined && + parameters.pointerType !== 'mouse' && + parameters.pointerType !== 'pen' && + parameters.pointerType !== 'touch' + ) { + throw invalidArgument(`actions[${index}].parameters.pointerType is invalid.`); + } + } + const parsedActions = source.actions.map((action, actionIndex) => + parseAction(action, source.type as WebDriverActionSequence['type'], `actions[${index}].actions[${actionIndex}]`, state), + ); + return { + id: source.id, + type: source.type, + ...(source.parameters === undefined ? {} : { parameters: source.parameters as Record }), + actions: parsedActions, + }; +} + +function parseAction( + value: unknown, + sourceType: WebDriverActionSequence['type'], + path: string, + state: WebDriverInputState, +): WebDriverAction { + const action = requireObject(value, path); + if (typeof action.type !== 'string') { + throw invalidArgument(`${path}.type must be a string.`); + } + if (action.type === 'pause') { + validateDuration(action.duration, path); + return action as WebDriverAction; + } + if (sourceType === 'none') { + throw invalidArgument(`${path}.type must be "pause" for a none input source.`); + } + if (sourceType === 'key') { + if (action.type !== 'keyDown' && action.type !== 'keyUp') { + throw invalidArgument(`${path}.type is invalid for a key input source.`); + } + if (typeof action.value !== 'string' || action.value.length === 0) { + throw invalidArgument(`${path}.value must be a non-empty string.`); + } + if (action.type === 'keyDown') { + state.pressedKeys.add(action.value); + } else { + state.pressedKeys.delete(action.value); + } + return action as WebDriverAction; + } + if (sourceType === 'pointer') { + if (action.type === 'pointerCancel') { + state.pressedButtons.clear(); + return action as WebDriverAction; + } + if (action.type === 'pointerDown' || action.type === 'pointerUp') { + if (!Number.isInteger(action.button) || (action.button as number) < 0) { + throw invalidArgument(`${path}.button must be a non-negative integer.`); + } + if (action.type === 'pointerDown') { + state.pressedButtons.add(action.button as number); + } else { + state.pressedButtons.delete(action.button as number); + } + return action as WebDriverAction; + } + if (action.type === 'pointerMove') { + validateCoordinates(action, path); + validateDuration(action.duration, path); + validateOrigin(action.origin, path); + return action as WebDriverAction; + } + throw invalidArgument(`${path}.type is invalid for a pointer input source.`); + } + if (action.type !== 'scroll') { + throw invalidArgument(`${path}.type is invalid for a wheel input source.`); + } + validateCoordinates(action, path); + if (typeof action.deltaX !== 'number' || typeof action.deltaY !== 'number') { + throw invalidArgument(`${path}.deltaX and deltaY must be numbers.`); + } + validateDuration(action.duration, path); + validateOrigin(action.origin, path); + return action as WebDriverAction; +} + +function validateCoordinates(action: Record, path: string): void { + if (typeof action.x !== 'number' || typeof action.y !== 'number') { + throw invalidArgument(`${path}.x and y must be numbers.`); + } +} + +function validateDuration(value: unknown, path: string): void { + if (value !== undefined && (!Number.isInteger(value) || (value as number) < 0)) { + throw invalidArgument(`${path}.duration must be a non-negative integer.`); + } +} + +function validateOrigin(value: unknown, path: string): void { + if (value === undefined || value === 'viewport' || value === 'pointer') { + return; + } + if (value && typeof value === 'object' && typeof (value as Record)['element-6066-11e4-a52e-4f735466cecf'] === 'string') { + return; + } + throw invalidArgument(`${path}.origin must be "viewport", "pointer", or a WebDriver element reference.`); +} + +function requireObject(value: unknown, path: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidArgument(`${path} must be an object.`); + } + return value as Record; +} diff --git a/packages/agentic/desktop-driver/src/protocol/capabilities.ts b/packages/agentic/desktop-driver/src/protocol/capabilities.ts new file mode 100644 index 00000000000..46aa3c1e7f6 --- /dev/null +++ b/packages/agentic/desktop-driver/src/protocol/capabilities.ts @@ -0,0 +1,129 @@ +import type { DesktopHostInfo, DesktopTarget } from '../host/types.js'; +import { invalidArgument, WebDriverError } from './errors.js'; +import { withCommandTimeout } from './timeouts.js'; +import type { DesktopClickMode, NewSessionCapabilities } from './types.js'; + +const standardCapabilities = new Set([ + 'acceptInsecureCerts', + 'browserName', + 'browserVersion', + 'pageLoadStrategy', + 'platformName', + 'proxy', + 'setWindowRect', + 'strictFileInteractability', + 'timeouts', + 'unhandledPromptBehavior', + 'webSocketUrl', +]); + +export type MatchedCapabilities = { + clickMode: DesktopClickMode; + requested: Record; + target: DesktopTarget; +}; + +export function getCapabilityCandidates(capabilities: NewSessionCapabilities): Record[] { + if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) { + throw invalidArgument('"capabilities" must be an object.'); + } + const alwaysMatch = validateCapabilityObject(capabilities.alwaysMatch ?? {}, 'alwaysMatch'); + const firstMatch = capabilities.firstMatch ?? [{}]; + if (!Array.isArray(firstMatch) || firstMatch.length === 0) { + throw invalidArgument('"firstMatch" must be a non-empty array when provided.'); + } + + return firstMatch.map((entry, index) => { + const candidate = validateCapabilityObject(entry, `firstMatch[${index}]`); + for (const key of Object.keys(candidate)) { + if (key in alwaysMatch) { + throw invalidArgument(`Capability "${key}" appears in both alwaysMatch and firstMatch[${index}].`); + } + } + return { ...alwaysMatch, ...candidate }; + }); +} + +export async function matchCapabilities( + capabilities: NewSessionCapabilities, + targets: readonly DesktopTarget[], +): Promise { + const candidates = getCapabilityCandidates(capabilities); + for (const requested of candidates) { + validateCapabilityNames(requested); + const targetId = requested['furn:target']; + const candidatesForTarget = + typeof targetId === 'string' ? targets.filter((target) => target.id === targetId) : targets.length === 1 ? targets : []; + + for (const target of candidatesForTarget) { + if (requested.browserName !== undefined && requested.browserName !== 'furn-native-desktop') { + continue; + } + if (requested.platformName !== undefined && requested.platformName !== target.platformName) { + continue; + } + if (requested['furn:endpoint'] !== undefined && requested['furn:endpoint'] !== target.endpoint) { + continue; + } + if (requested['furn:renderer'] !== undefined && requested['furn:renderer'] !== target.renderer) { + continue; + } + + const host = await withCommandTimeout((signal) => target.host.probe(signal), 10_000, `Probing target "${target.id}"`); + const clickMode = resolveClickMode(requested['furn:clickMode'], host); + return { clickMode, requested, target }; + } + } + + throw new WebDriverError('session not created', 'No registered desktop target matched the requested capabilities.'); +} + +export function createReturnedCapabilities( + matched: MatchedCapabilities, + host: DesktopHostInfo, + timeouts: Record, +): Record { + return { + browserName: 'furn-native-desktop', + platformName: matched.target.platformName, + setWindowRect: host.features.setWindowRect, + timeouts, + 'furn:clickMode': matched.clickMode, + 'furn:endpoint': matched.target.endpoint, + 'furn:features': host.features, + 'furn:renderer': matched.target.renderer, + 'furn:target': matched.target.id, + }; +} + +function validateCapabilityObject(value: unknown, name: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidArgument(`"${name}" must be an object.`); + } + return value as Record; +} + +function validateCapabilityNames(capabilities: Record): void { + for (const name of Object.keys(capabilities)) { + if (!standardCapabilities.has(name) && !name.includes(':')) { + throw invalidArgument(`Extension capability "${name}" must contain a vendor prefix followed by ":".`); + } + } +} + +function resolveClickMode(requested: unknown, host: DesktopHostInfo): DesktopClickMode { + const clickMode = requested ?? 'auto'; + if (clickMode !== 'auto' && clickMode !== 'physical' && clickMode !== 'accessibility') { + throw invalidArgument('"furn:clickMode" must be "auto", "physical", or "accessibility".'); + } + if (clickMode === 'physical' && !host.features.physicalClick) { + throw new WebDriverError('session not created', 'The target does not support physical click input.'); + } + if (clickMode === 'accessibility' && !host.features.accessibilityClick) { + throw new WebDriverError('session not created', 'The target does not support accessibility click input.'); + } + if (clickMode === 'auto' && !host.features.physicalClick && !host.features.accessibilityClick) { + throw new WebDriverError('session not created', 'The target does not support any click input mode.'); + } + return clickMode === 'auto' ? (host.features.physicalClick ? 'physical' : 'accessibility') : clickMode; +} diff --git a/packages/agentic/desktop-driver/src/protocol/constants.ts b/packages/agentic/desktop-driver/src/protocol/constants.ts new file mode 100644 index 00000000000..9fda2f3e224 --- /dev/null +++ b/packages/agentic/desktop-driver/src/protocol/constants.ts @@ -0,0 +1 @@ +export const webElementIdentifier = 'element-6066-11e4-a52e-4f735466cecf' as const; diff --git a/packages/agentic/desktop-driver/src/protocol/errors.ts b/packages/agentic/desktop-driver/src/protocol/errors.ts new file mode 100644 index 00000000000..b3edd35e28b --- /dev/null +++ b/packages/agentic/desktop-driver/src/protocol/errors.ts @@ -0,0 +1,76 @@ +import type { WebDriverErrorValue } from './types.js'; + +const errorStatuses = { + 'element click intercepted': 400, + 'element not interactable': 400, + 'invalid argument': 400, + 'invalid selector': 400, + 'invalid session id': 404, + 'javascript error': 500, + 'no such element': 404, + 'no such window': 404, + 'session not created': 500, + 'stale element reference': 404, + timeout: 500, + 'unable to capture screen': 500, + 'unknown command': 404, + 'unknown error': 500, + 'unknown method': 405, + 'unsupported operation': 500, +} as const; + +export type WebDriverErrorCode = keyof typeof errorStatuses; + +export class WebDriverError extends Error { + readonly code: WebDriverErrorCode; + readonly data?: Record; + readonly status: number; + + constructor(code: WebDriverErrorCode, message: string, data?: Record) { + super(message); + this.name = 'WebDriverError'; + this.code = code; + this.data = data; + this.status = errorStatuses[code]; + } + + toJSON(): WebDriverErrorValue { + return { + error: this.code, + message: this.message, + stacktrace: this.stack ?? '', + ...(this.data ? { data: this.data } : {}), + }; + } +} + +export function invalidArgument(message: string): WebDriverError { + return new WebDriverError('invalid argument', message); +} + +export function toWebDriverError(error: unknown): WebDriverError { + if (error instanceof WebDriverError) { + return error; + } + if (error instanceof HostStaleError) { + return new WebDriverError('stale element reference', error.message); + } + if (error instanceof HostUnsupportedError) { + return new WebDriverError('unsupported operation', error.message); + } + return new WebDriverError('unknown error', error instanceof Error ? error.message : String(error)); +} + +export class HostStaleError extends Error { + constructor(message = 'The native element is no longer available.') { + super(message); + this.name = 'HostStaleError'; + } +} + +export class HostUnsupportedError extends Error { + constructor(message: string) { + super(message); + this.name = 'HostUnsupportedError'; + } +} diff --git a/packages/agentic/desktop-driver/src/protocol/timeouts.ts b/packages/agentic/desktop-driver/src/protocol/timeouts.ts new file mode 100644 index 00000000000..958dd513b1b --- /dev/null +++ b/packages/agentic/desktop-driver/src/protocol/timeouts.ts @@ -0,0 +1,26 @@ +import { WebDriverError } from './errors.js'; + +export async function withCommandTimeout( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number, + description: string, +): Promise { + let timer: ReturnType | undefined; + const controller = new AbortController(); + try { + return await Promise.race([ + operation(controller.signal), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new WebDriverError('timeout', `${description} exceeded ${timeoutMs}ms.`); + controller.abort(error); + reject(error); + }, timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} diff --git a/packages/agentic/desktop-driver/src/protocol/types.ts b/packages/agentic/desktop-driver/src/protocol/types.ts new file mode 100644 index 00000000000..0b12b727078 --- /dev/null +++ b/packages/agentic/desktop-driver/src/protocol/types.ts @@ -0,0 +1,55 @@ +export type DesktopEndpoint = 'macos' | 'windows' | 'win32'; +export type DesktopPlatformName = 'macos' | 'windows'; +export type DesktopRenderer = 'fabric' | 'paper'; +export type DesktopClickMode = 'physical' | 'accessibility' | 'auto'; + +export type WebDriverTimeouts = { + implicit: number; + pageLoad: number; + script: number; +}; + +export type DesktopTimeouts = { + appLaunch: number; + nativeCommand: number; + stableLayout: number; + storyRender: number; +}; + +export type NewSessionCapabilities = { + alwaysMatch?: Record; + firstMatch?: Record[]; +}; + +export type NewSessionRequest = { + capabilities: NewSessionCapabilities; +}; + +export type WebDriverElement = { + 'element-6066-11e4-a52e-4f735466cecf': string; +}; + +export type WebDriverResponse = { + value: T; +}; + +export type WebDriverErrorValue = { + error: string; + message: string; + stacktrace: string; + data?: Record; +}; + +export type WebDriverErrorResponse = WebDriverResponse; + +export type WebDriverAction = { + type: string; + [key: string]: unknown; +}; + +export type WebDriverActionSequence = { + id: string; + type: 'key' | 'none' | 'pointer' | 'wheel'; + parameters?: Record; + actions: WebDriverAction[]; +}; diff --git a/packages/agentic/desktop-driver/src/runner/StoryTestRunner.test.ts b/packages/agentic/desktop-driver/src/runner/StoryTestRunner.test.ts new file mode 100644 index 00000000000..57d355630b1 --- /dev/null +++ b/packages/agentic/desktop-driver/src/runner/StoryTestRunner.test.ts @@ -0,0 +1,362 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { DesktopStoryTests } from '../authoring/storyTests.js'; +import { ArtifactManager } from '../artifacts/ArtifactManager.js'; +import { createDesktopDriverClient } from '../client/DesktopDriverClient.js'; +import { createDesktopDriverStoryHarness } from '../testing/protocolHarness.js'; +import type { DesktopStoryManifest } from '../storybook.js'; +import { runDesktopStoryTests, selectDesktopStoryTests } from './StoryTestRunner.js'; +import { findDesktopElements } from './StoryTestRunner.js'; + +const passingPlan: DesktopStoryTests = { + version: 1, + tests: [ + { + id: 'button-contract', + requires: ['focus', 'keyboard', 'screenshot', 'wheel'], + steps: [ + { action: 'wait', target: { testId: 'button-primary' }, timeoutMs: 100 }, + { expect: { state: 'role', target: { testId: 'button-primary' }, value: 'button' } }, + { expect: { state: 'enabled', target: { testId: 'button-primary' }, value: true } }, + { action: 'click', target: { testId: 'button-primary' } }, + { expect: { state: 'focused', target: { testId: 'button-primary' }, value: true } }, + { action: 'type', target: { testId: 'input-name' }, text: 'Ada' }, + { expect: { state: 'value', target: { testId: 'input-name' }, value: 'Ada' } }, + { action: 'keys', value: ['\uE004'] }, + { action: 'scroll', deltaY: 120 }, + { action: 'setArgs', args: { disabled: false } }, + { action: 'screenshot', name: 'button' }, + { action: 'source', name: 'source' }, + ], + title: 'Button contract', + }, + ], +}; + +describe('runDesktopStoryTests', () => { + test('runs a portable plan and writes a stable evidence report', async () => { + const manifest = makeManifest(passingPlan); + const harness = await createDesktopDriverStoryHarness(manifest); + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-run-')); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + const result = await runDesktopStoryTests({ + artifacts: new ArtifactManager(temporaryDirectory), + endpoint: 'windows', + manifest, + platformName: 'windows', + runId: 'run', + session, + targetId: harness.target.id, + }); + + expect(result).toMatchObject({ + status: 'passed', + tests: [ + { + status: 'passed', + steps: expect.arrayContaining([expect.objectContaining({ status: 'passed' })]), + }, + ], + }); + expect(result.tests[0].artifacts.map(({ kind }) => kind)).toEqual(['screenshot', 'source']); + expect(harness.storyOrchestrator?.argUpdates).toEqual([{ args: { disabled: false }, storyId: 'components-button--default' }]); + expect(JSON.parse(fs.readFileSync(path.join(temporaryDirectory, 'run.json'), 'utf8'))).toMatchObject({ + runId: 'run', + status: 'passed', + }); + expect(JSON.parse(fs.readFileSync(path.join(temporaryDirectory, 'host.json'), 'utf8'))).toMatchObject({ + endpoint: 'windows', + targetId: harness.target.id, + }); + await session.delete(); + } finally { + await harness.close(); + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }); + + test('distinguishes assertion failures and captures failure evidence', async () => { + const manifest = makeManifest({ + version: 1, + tests: [ + { + id: 'fails', + steps: [{ expect: { state: 'role', target: { testId: 'button-primary' }, value: 'checkbox' } }], + }, + ], + }); + const harness = await createDesktopDriverStoryHarness(manifest); + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-failure-')); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + const result = await runDesktopStoryTests({ + artifacts: new ArtifactManager(temporaryDirectory), + endpoint: 'windows', + manifest, + platformName: 'windows', + session, + targetId: harness.target.id, + }); + + expect(result.status).toBe('failed'); + expect(result.tests[0]).toMatchObject({ + status: 'failed', + error: expect.stringContaining('checkbox'), + }); + expect(result.tests[0].artifacts.map(({ name }) => name)).toEqual(['failure', 'failure-source', 'failure-tree']); + await session.delete(); + } finally { + await harness.close(); + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }); + + test('filters and deterministically shards plans', () => { + const manifest = makeManifest({ + version: 1, + tests: [ + { id: 'one', steps: [{ action: 'note', message: 'one' }] }, + { id: 'two', steps: [{ action: 'note', message: 'two' }] }, + { id: 'three', steps: [{ action: 'note', message: 'three' }] }, + { id: 'four', steps: [{ action: 'note', message: 'four' }] }, + ], + }); + + const first = selectDesktopStoryTests(manifest, 'windows', { shardCount: 2, shardIndex: 0 }); + const second = selectDesktopStoryTests(manifest, 'windows', { shardCount: 2, shardIndex: 1 }); + expect([...first, ...second].map(({ test }) => test.id).sort()).toEqual(['four', 'one', 'three', 'two']); + expect(first).toHaveLength(2); + expect(second).toHaveLength(2); + }); + + test('does not turn an unsupported runtime property into a passing skip', async () => { + const manifest = makeManifest({ + version: 1, + tests: [ + { + id: 'unsupported-checked', + steps: [{ expect: { state: 'checked', target: { testId: 'button-primary' }, value: true } }], + }, + ], + }); + const harness = await createDesktopDriverStoryHarness(manifest); + harness.host.setElementStateUnsupported('button', 'checked', 'not exposed by this platform'); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + const result = await runDesktopStoryTests({ + endpoint: 'windows', + manifest, + platformName: 'windows', + session, + targetId: harness.target.id, + }); + + expect(result).toMatchObject({ + status: 'failed', + tests: [{ status: 'failed', error: expect.stringContaining('not exposed') }], + }); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('matches text independently from accessible name', async () => { + const harness = await createDesktopDriverStoryHarness(makeManifest(passingPlan), { + windows: [ + { + id: 'window-1', + title: 'Text selector', + elements: [ + { + id: 'label', + rect: { height: 20, width: 100, x: 0, y: 0 }, + role: 'text', + scope: 'preview', + text: 'Visible text', + windowId: 'window-1', + }, + { + id: 'story-root', + automationId: 'story-root', + name: JSON.stringify({ previewGeneration: 0, storyId: 'initial--story' }), + rect: { height: 100, width: 100, x: 0, y: 0 }, + role: 'group', + scope: 'preview', + windowId: 'window-1', + }, + ], + }, + ], + }); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + await expect(findDesktopElements(session, { text: 'Visible' })).resolves.toHaveLength(1); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('cancels an in-flight wait and releases input before the next test', async () => { + const manifest = makeManifest({ + version: 1, + tests: [ + { + id: 'cancelled-wait', + steps: [{ action: 'wait', target: { testId: 'missing' }, timeoutMs: 1000 }], + }, + ], + }); + const harness = await createDesktopDriverStoryHarness(manifest); + const controller = new AbortController(); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + setTimeout(() => controller.abort(), 20); + const started = Date.now(); + const result = await runDesktopStoryTests({ + endpoint: 'windows', + manifest, + platformName: 'windows', + session, + signal: controller.signal, + targetId: harness.target.id, + }); + + expect(Date.now() - started).toBeLessThan(500); + expect(result).toMatchObject({ status: 'failed', tests: [{ status: 'cancelled' }] }); + expect(harness.host.actions).toContainEqual({ type: 'release-actions' }); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('skips a declared focus requirement when focused state is unavailable', async () => { + const manifest = makeManifest({ + version: 1, + tests: [ + { + id: 'focus-required', + requires: ['focus'], + steps: [{ expect: { state: 'focused', target: { testId: 'button-primary' }, value: true } }], + }, + ], + }); + const harness = await createDesktopDriverStoryHarness(manifest, { features: { focus: false } }); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + const result = await runDesktopStoryTests({ + endpoint: 'windows', + manifest, + platformName: 'windows', + session, + targetId: harness.target.id, + }); + + expect(result).toMatchObject({ + status: 'passed', + tests: [{ skipReason: 'Unsupported capabilities: focus', status: 'skipped', steps: [] }], + }); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('drains an aborted native action before releasing input', async () => { + const manifest = makeManifest({ + version: 1, + tests: [ + { + id: 'cancelled-action', + steps: [ + { + action: 'actions', + sequences: [{ id: 'key', type: 'key', actions: [{ type: 'keyDown', value: 'A' }] }], + }, + ], + }, + ], + }); + const harness = await createDesktopDriverStoryHarness(manifest, { actionDelayMs: 50 }); + const controller = new AbortController(); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + const running = runDesktopStoryTests({ + endpoint: 'windows', + manifest, + platformName: 'windows', + session, + signal: controller.signal, + targetId: harness.target.id, + }); + await waitUntil(() => harness.host.actions.some(({ type }) => type === 'actions-start')); + controller.abort(); + const result = await running; + + expect(result).toMatchObject({ status: 'failed', tests: [{ status: 'cancelled' }] }); + const completed = harness.host.actions.findIndex(({ type }) => type === 'actions'); + const released = harness.host.actions.findIndex(({ type }) => type === 'release-actions'); + expect(completed).toBeGreaterThan(-1); + expect(released).toBeGreaterThan(completed); + await session.delete(); + } finally { + await harness.close(); + } + }); +}); + +function makeManifest(tests: DesktopStoryTests): DesktopStoryManifest { + return { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/button/button.stories.tsx', + tags: ['e2e', 'story'], + tests, + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform-digest', + portablePlanDigest: 'portable-digest', + schemaVersion: 1, + }; +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 1000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for the expected test condition.'); + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} diff --git a/packages/agentic/desktop-driver/src/runner/StoryTestRunner.ts b/packages/agentic/desktop-driver/src/runner/StoryTestRunner.ts new file mode 100644 index 00000000000..4ba432b2b06 --- /dev/null +++ b/packages/agentic/desktop-driver/src/runner/StoryTestRunner.ts @@ -0,0 +1,624 @@ +import { randomUUID } from 'node:crypto'; + +import type { + DesktopArtifact, + DesktopStoryRunResult, + DesktopStoryStepResult, + DesktopStoryTestResult, + DesktopTestStatus, +} from '../authoring/results.js'; +import type { + DesktopStoryCapability, + DesktopStoryExpectation, + DesktopStorySelector, + DesktopStoryStep, + DesktopStoryTest, +} from '../authoring/storyTests.js'; +import type { ArtifactManager } from '../artifacts/ArtifactManager.js'; +import type { DesktopElementClient, DesktopSessionClient } from '../client/DesktopDriverClient.js'; +import { WebDriverError } from '../protocol/errors.js'; +import { webElementIdentifier } from '../protocol/constants.js'; +import type { DesktopEndpoint, DesktopPlatformName, WebDriverActionSequence } from '../protocol/types.js'; +import type { DesktopStoryManifest, DesktopStoryManifestEntry } from '../storybook.js'; + +export type DesktopStoryTestSelection = { + shardCount?: number; + shardIndex?: number; + story?: string; + tag?: string; + test?: string; +}; + +export type DesktopStoryTestRunnerOptions = { + artifacts?: ArtifactManager; + endpoint: DesktopEndpoint; + manifest: DesktopStoryManifest; + platformName: DesktopPlatformName; + runId?: string; + selection?: DesktopStoryTestSelection; + session: DesktopSessionClient; + signal?: AbortSignal; + targetId: string; +}; + +type SelectedTest = { + entry: DesktopStoryManifestEntry; + test: DesktopStoryTest; +}; + +export async function runDesktopStoryTests({ + artifacts, + endpoint, + manifest, + platformName, + runId = randomUUID(), + selection, + session, + signal, + targetId, +}: DesktopStoryTestRunnerOptions): Promise { + const startedAt = new Date(); + const selected = selectDesktopStoryTests(manifest, endpoint, selection); + if (selected.length === 0) { + throw new Error('No desktop story tests matched the requested selection.'); + } + const results: DesktopStoryTestResult[] = []; + artifacts?.writeMetadata('host', { + capabilities: session.capabilities, + endpoint, + platformName, + targetId, + }); + + for (const [index, item] of selected.entries()) { + if (signal?.aborted) { + results.push(cancelledResult(item, 'Run cancelled before the test started.')); + continue; + } + results.push( + await runTest({ + artifacts, + item, + runId: `${runId}-${index + 1}`, + session, + signal, + }), + ); + } + + const result: DesktopStoryRunResult = { + endpoint, + finishedAt: new Date().toISOString(), + manifest: { + platform: manifest.platformManifestDigest, + portable: manifest.portablePlanDigest, + }, + platformName, + runId, + schemaVersion: 1, + startedAt: startedAt.toISOString(), + status: results.every(({ status }) => status === 'passed' || status === 'skipped') ? 'passed' : 'failed', + targetId, + tests: results, + }; + artifacts?.writeRunResult(result); + return result; +} + +export function selectDesktopStoryTests( + manifest: DesktopStoryManifest, + endpoint: DesktopEndpoint, + selection: DesktopStoryTestSelection = {}, +): SelectedTest[] { + validateShard(selection); + const selected = manifest.entries + .filter((entry) => !selection.story || matchesPattern(entry.id, selection.story)) + .filter((entry) => !selection.tag || entry.tags.includes(selection.tag)) + .flatMap((entry) => + (entry.tests?.tests ?? []) + .filter((test) => !test.platforms || test.platforms.includes(endpoint)) + .filter((test) => !selection.test || matchesPattern(test.id, selection.test)) + .map((test) => ({ entry, test })), + ) + .sort((left, right) => `${left.entry.id}/${left.test.id}`.localeCompare(`${right.entry.id}/${right.test.id}`)); + + const shardCount = selection.shardCount; + const shardIndex = selection.shardIndex; + if (shardCount === undefined || shardIndex === undefined) { + return selected; + } + return selected.filter((_item, index) => index % shardCount === shardIndex); +} + +async function runTest({ + artifacts, + item, + runId, + session, + signal, +}: { + artifacts?: ArtifactManager; + item: SelectedTest; + runId: string; + session: DesktopSessionClient; + signal?: AbortSignal; +}): Promise { + const started = Date.now(); + const title = item.test.title ?? item.test.id; + const missing = missingCapabilities(session.capabilities, item.test.requires ?? []); + if (missing.length > 0) { + return { + artifacts: [], + durationMs: Date.now() - started, + skipReason: `Unsupported capabilities: ${missing.join(', ')}`, + status: 'skipped', + steps: [], + storyId: item.entry.id, + testId: item.test.id, + title, + }; + } + + const steps: DesktopStoryStepResult[] = []; + const testArtifacts: DesktopArtifact[] = []; + let status: DesktopTestStatus = 'passed'; + let errorMessage: string | undefined; + try { + throwIfAborted(signal); + await withAbort(session.selectStory(item.entry.id, runId), signal); + for (const [index, step] of item.test.steps.entries()) { + throwIfAborted(signal); + const result = await runStep(session, step, index, artifacts, `${item.entry.id}-${item.test.id}`, signal); + steps.push(result); + testArtifacts.push(...result.artifacts); + if (result.status === 'failed') { + status = classifyError(result.errorObject); + errorMessage = result.error; + break; + } + } + } catch (error) { + status = classifyError(error); + errorMessage = error instanceof Error ? error.message : String(error); + } + try { + await session.releaseActions(); + } catch (error) { + const cleanupError = error instanceof Error ? error.message : String(error); + if (status === 'passed') { + status = 'infrastructure-error'; + errorMessage = `Input cleanup failed: ${cleanupError}`; + } else { + errorMessage = `${errorMessage ?? 'Test failed.'} Input cleanup failed: ${cleanupError}`; + } + } + if (signal?.aborted && status === 'passed') { + status = 'cancelled'; + errorMessage = 'Run cancelled during input cleanup.'; + } + + if (status !== 'passed' && artifacts) { + const captured = await captureFailureArtifacts(session, artifacts, `${item.entry.id}-${item.test.id}`); + testArtifacts.push(...captured.artifacts); + if (captured.error) { + errorMessage = `${errorMessage ?? 'Test failed.'} Evidence capture failed: ${captured.error}`; + } + } + + return { + artifacts: testArtifacts, + durationMs: Date.now() - started, + ...(errorMessage ? { error: errorMessage } : {}), + status, + steps, + storyId: item.entry.id, + testId: item.test.id, + title, + }; +} + +type InternalStepResult = DesktopStoryStepResult & { + errorObject?: unknown; +}; + +async function runStep( + session: DesktopSessionClient, + step: DesktopStoryStep, + index: number, + artifacts: ArtifactManager | undefined, + testDirectory: string, + signal?: AbortSignal, +): Promise { + const started = Date.now(); + const stepArtifacts: DesktopArtifact[] = []; + try { + throwIfAborted(signal); + if ('expect' in step) { + await withAbort(assertDesktopExpectation(session, step.expect), signal); + } else { + await withAbort(performAction(session, step, artifacts, testDirectory, stepArtifacts, signal), signal); + } + throwIfAborted(signal); + return { artifacts: stepArtifacts, durationMs: Date.now() - started, index, status: 'passed' }; + } catch (error) { + return { + artifacts: stepArtifacts, + durationMs: Date.now() - started, + error: error instanceof Error ? error.message : String(error), + errorObject: error, + index, + status: 'failed', + }; + } +} + +async function performAction( + session: DesktopSessionClient, + step: Exclude, + artifacts: ArtifactManager | undefined, + testDirectory: string, + stepArtifacts: DesktopArtifact[], + signal?: AbortSignal, +): Promise { + switch (step.action) { + case 'actions': + await session.performActions(step.sequences); + return; + case 'clear': + await (await findDesktopElement(session, step.target)).clear(); + return; + case 'click': + await (await findDesktopElement(session, step.target)).click(); + return; + case 'doubleClick': { + const element = await findDesktopElement(session, step.target); + await element.click(); + await element.click(); + return; + } + case 'keys': + await session.performActions(keySequences(step.value)); + return; + case 'note': + return; + case 'screenshot': { + const image = step.target ? await (await findDesktopElement(session, step.target)).takeScreenshot() : await session.takeScreenshot(); + if (artifacts) { + stepArtifacts.push(artifacts.writeScreenshot(testDirectory, step.name, image)); + } + return; + } + case 'scroll': { + const origin = step.target ? toElementReference(await findDesktopElement(session, step.target)) : 'viewport'; + await session.performActions([ + { + id: 'desktop-story-wheel', + type: 'wheel', + actions: [ + { + type: 'scroll', + deltaX: step.deltaX ?? 0, + deltaY: step.deltaY, + duration: 0, + origin, + x: 0, + y: 0, + }, + ], + }, + ]); + return; + } + case 'setArgs': + await session.updateStoryArgs(requireCurrentStory(await session.getCurrentStory()), step.args); + return; + case 'source': { + const source = await session.getPageSource(); + if (artifacts) { + stepArtifacts.push(artifacts.writeSource(testDirectory, step.name, source)); + } + return; + } + case 'type': + await (await findDesktopElement(session, step.target)).sendKeys(step.text); + return; + case 'wait': + await waitFor(session, step, signal); + return; + } +} + +export async function assertDesktopExpectation(session: DesktopSessionClient, expectation: DesktopStoryExpectation): Promise { + const elements = await findDesktopElements(session, expectation.target); + if (expectation.state === 'count') { + assertEqual(elements.length, expectation.value, 'count'); + return; + } + if (expectation.state === 'exists') { + assertEqual(elements.length > 0, expectation.value ?? true, 'exists'); + return; + } + const element = elements[0]; + if (!element) { + throw new DesktopAssertionError('Expected element does not exist.'); + } + let actual: unknown; + switch (expectation.state) { + case 'accessibleName': + actual = await element.getAttribute('name'); + break; + case 'checked': + actual = await element.getProperty('checked'); + break; + case 'displayed': + actual = await element.isDisplayed(); + break; + case 'enabled': + actual = await element.isEnabled(); + break; + case 'expanded': + actual = await element.getProperty('expanded'); + break; + case 'focused': + actual = await element.getProperty('focused'); + break; + case 'role': + actual = await element.getTagName(); + break; + case 'selected': + actual = await element.isSelected(); + break; + case 'text': + actual = await element.getText(); + break; + case 'value': + actual = await element.getProperty('value'); + break; + default: + throw new DesktopAssertionError(`Unsupported expectation "${expectation.state}".`); + } + assertEqual(actual, expectation.value ?? true, expectation.state); +} + +async function waitFor( + session: DesktopSessionClient, + step: Extract, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + (step.timeoutMs ?? 5000); + let lastError: unknown; + do { + throwIfAborted(signal); + try { + if (step.until) { + await assertDesktopExpectation(session, step.until); + } else if (step.target) { + await findDesktopElement(session, step.target); + } + throwIfAborted(signal); + return; + } catch (error) { + if (error instanceof DesktopRunCancelledError) { + throw error; + } + lastError = error; + } + await withAbort(delay(Math.min(50, Math.max(1, deadline - Date.now()))), signal); + } while (Date.now() < deadline); + throw new WebDriverError('timeout', `Wait condition was not met: ${(lastError as Error)?.message ?? 'unknown condition'}`); +} + +export async function findDesktopElement(session: DesktopSessionClient, selector: DesktopStorySelector): Promise { + const elements = await findDesktopElements(session, selector); + if (!elements[0]) { + throw new WebDriverError('no such element', `No element matched ${JSON.stringify(selector)}.`); + } + return elements[0]; +} + +export async function findDesktopElements(session: DesktopSessionClient, selector: DesktopStorySelector): Promise { + if ('testId' in selector) { + return session.findElements('accessibility id', selector.testId); + } + if ('accessibleName' in selector) { + return session.findElements('link text', selector.accessibleName); + } + if ('text' in selector) { + return session.findElements('-furn:text', selector.text); + } + const candidates = await session.findElements('tag name', selector.role); + if (!selector.name) { + return candidates; + } + const matches: DesktopElementClient[] = []; + for (const candidate of candidates) { + if ((await candidate.getAttribute('name')) === selector.name) { + matches.push(candidate); + } + } + return matches; +} + +function classifyError(error: unknown): DesktopTestStatus { + if (error instanceof DesktopRunCancelledError) { + return 'cancelled'; + } + if (error instanceof WebDriverError) { + if (error.code === 'timeout') { + return 'timed-out'; + } + if (error.code === 'invalid session id' || error.code === 'session not created' || error.code === 'unknown error') { + return 'infrastructure-error'; + } + } + return error instanceof DesktopAssertionError ? 'failed' : 'failed'; +} + +async function captureFailureArtifacts( + session: DesktopSessionClient, + artifacts: ArtifactManager, + testDirectory: string, +): Promise<{ artifacts: DesktopArtifact[]; error?: string }> { + const captured: DesktopArtifact[] = []; + const errors: string[] = []; + try { + captured.push(artifacts.writeScreenshot(testDirectory, 'failure', await session.takeScreenshot())); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + try { + captured.push(artifacts.writeSource(testDirectory, 'failure-source', await session.getPageSource())); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + try { + captured.push(artifacts.writeTree(testDirectory, 'failure-tree', await session.getTree())); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + return { artifacts: captured, ...(errors.length > 0 ? { error: errors.join('; ') } : {}) }; +} + +function missingCapabilities( + capabilities: Readonly>, + required: readonly DesktopStoryCapability[], +): DesktopStoryCapability[] { + const features = capabilities['furn:features']; + if (!features || typeof features !== 'object') { + return [...required]; + } + const values = features as Record; + const mappings: Record = { + 'accessibility-click': 'accessibilityClick', + 'element-screenshot': 'elementScreenshot', + focus: 'focus', + keyboard: 'keyboard', + 'physical-click': 'physicalClick', + screenshot: 'screenshot', + wheel: 'wheel', + }; + return required.filter((capability) => values[mappings[capability]] !== true); +} + +function validateShard(selection: DesktopStoryTestSelection): void { + if (selection.shardCount === undefined && selection.shardIndex === undefined) { + return; + } + const shardCount = selection.shardCount; + const shardIndex = selection.shardIndex; + if ( + !Number.isInteger(shardCount) || + !Number.isInteger(shardIndex) || + shardCount === undefined || + shardIndex === undefined || + shardCount < 1 || + shardIndex < 0 || + shardIndex >= shardCount + ) { + throw new TypeError('Shard selection requires 0 <= shardIndex < shardCount.'); + } +} + +function matchesPattern(value: string, pattern: string): boolean { + const expression = new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`); + return expression.test(value); +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function keySequences(keys: readonly string[]): WebDriverActionSequence[] { + return [ + { + id: 'desktop-story-keyboard', + type: 'key', + actions: keys.flatMap((value) => [ + { type: 'keyDown', value }, + { type: 'keyUp', value }, + ]), + }, + ]; +} + +function toElementReference(element: DesktopElementClient): { [webElementIdentifier]: string } { + return { [webElementIdentifier]: element.id }; +} + +function requireCurrentStory(story: { storyId: string } | null): string { + if (!story) { + throw new Error('Storybook did not report a current story.'); + } + return story.storyId; +} + +function assertEqual(actual: unknown, expected: unknown, label: string): void { + if (!Object.is(actual, expected)) { + throw new DesktopAssertionError(`Expected ${label} to be ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`); + } +} + +function cancelledResult(item: SelectedTest, reason: string): DesktopStoryTestResult { + return { + artifacts: [], + durationMs: 0, + error: reason, + status: 'cancelled', + steps: [], + storyId: item.entry.id, + testId: item.test.id, + title: item.test.title ?? item.test.id, + }; +} + +export class DesktopAssertionError extends Error { + constructor(message: string) { + super(message); + this.name = 'DesktopAssertionError'; + } +} + +class DesktopRunCancelledError extends Error { + constructor(message: string) { + super(message); + this.name = 'DesktopRunCancelledError'; + } +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new DesktopRunCancelledError('Run cancelled during the test.'); + } +} + +function withAbort(operation: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) { + return operation; + } + throwIfAborted(signal); + return new Promise((resolve, reject) => { + let aborted = false; + const onAbort = () => { + aborted = true; + }; + signal.addEventListener('abort', onAbort, { once: true }); + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort); + if (aborted) { + reject(new DesktopRunCancelledError('Run cancelled during the test.')); + } else { + resolve(value); + } + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(aborted ? new DesktopRunCancelledError('Run cancelled during the test.') : error); + }, + ); + }); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/packages/agentic/desktop-driver/src/runner/index.ts b/packages/agentic/desktop-driver/src/runner/index.ts new file mode 100644 index 00000000000..ba98dce7b0b --- /dev/null +++ b/packages/agentic/desktop-driver/src/runner/index.ts @@ -0,0 +1,9 @@ +export { + assertDesktopExpectation, + DesktopAssertionError, + findDesktopElement, + findDesktopElements, + runDesktopStoryTests, + selectDesktopStoryTests, +} from './StoryTestRunner.js'; +export type { DesktopStoryTestRunnerOptions, DesktopStoryTestSelection } from './StoryTestRunner.js'; diff --git a/packages/agentic/desktop-driver/src/server/SessionManager.ts b/packages/agentic/desktop-driver/src/server/SessionManager.ts new file mode 100644 index 00000000000..0bb936e296a --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/SessionManager.ts @@ -0,0 +1,302 @@ +import { randomUUID } from 'node:crypto'; + +import type { + ApplicationLease, + DesktopHostInfo, + DesktopTarget, + DesktopWindow, + NativeElementScope, + NativeElementSnapshot, +} from '../host/types.js'; +import { createInputState } from '../protocol/actions.js'; +import type { WebDriverInputState } from '../protocol/actions.js'; +import { WebDriverError } from '../protocol/errors.js'; +import { withCommandTimeout } from '../protocol/timeouts.js'; +import type { DesktopClickMode, DesktopTimeouts, WebDriverTimeouts } from '../protocol/types.js'; + +export type ElementRecord = { + id: string; + nativeId: string; + previewGeneration: number; + scope: NativeElementScope; +}; + +export type DesktopSession = { + clickMode: DesktopClickMode; + currentWindowId: string; + desktopTimeouts: DesktopTimeouts; + elements: Map; + elementIdsByNativeId: Map; + hostInfo: DesktopHostInfo; + id: string; + inputState: WebDriverInputState; + issuedElementIds: Set; + lease: ApplicationLease; + previewGeneration: number; + story: { previewGeneration: number; runId: string; storyId: string } | null; + target: DesktopTarget; + timeouts: WebDriverTimeouts; + windows: DesktopWindow[]; +}; + +export class SessionManager { + private readonly sessions = new Map(); + private readonly sessionsByTarget = new Map(); + private readonly reservedTargets = new Set(); + private readonly commandQueues = new Map>(); + private readonly inFlightCreates = new Set>(); + private inputQueue: Promise = Promise.resolve(); + private closing = false; + + create( + target: DesktopTarget, + hostInfo: DesktopHostInfo, + clickMode: DesktopClickMode, + launchMode: 'attach' | 'launch', + ): Promise { + if (this.closing) { + return Promise.reject(new WebDriverError('session not created', 'Desktop Driver is shutting down.')); + } + const result = this.createSession(target, hostInfo, clickMode, launchMode); + const settled = result.then( + () => undefined, + () => undefined, + ); + this.inFlightCreates.add(settled); + void settled.finally(() => this.inFlightCreates.delete(settled)); + return result; + } + + beginClose(): void { + this.closing = true; + } + + async waitForCreates(): Promise { + await Promise.all(this.inFlightCreates); + } + + private async createSession( + target: DesktopTarget, + hostInfo: DesktopHostInfo, + clickMode: DesktopClickMode, + launchMode: 'attach' | 'launch', + ): Promise { + if (this.sessionsByTarget.has(target.id) || this.reservedTargets.has(target.id)) { + throw new WebDriverError('session not created', `Target "${target.id}" already has an active session.`); + } + + this.reservedTargets.add(target.id); + let lease: ApplicationLease | undefined; + let releaseReservation = true; + let leasePromise: Promise | undefined; + try { + lease = await withCommandTimeout( + (signal) => { + leasePromise = launchMode === 'attach' ? target.host.attach(target, signal) : target.host.launch(target, signal); + return leasePromise; + }, + 120_000, + `${launchMode === 'attach' ? 'Attaching to' : 'Launching'} target "${target.id}"`, + ); + const activeLease = lease; + const windows = await withCommandTimeout( + (signal) => target.host.windows(activeLease, signal), + 10_000, + `Reading windows for target "${target.id}"`, + ); + if (windows.length === 0) { + throw new WebDriverError('session not created', `Target "${target.id}" did not expose any windows.`); + } + if (this.closing) { + throw new WebDriverError('session not created', 'Desktop Driver shut down while the target was starting.'); + } + const session: DesktopSession = { + clickMode, + currentWindowId: windows[0].id, + desktopTimeouts: { + appLaunch: 120_000, + nativeCommand: 10_000, + stableLayout: 1_000, + storyRender: 30_000, + }, + elements: new Map(), + elementIdsByNativeId: new Map(), + hostInfo, + id: randomUUID(), + inputState: createInputState(), + issuedElementIds: new Set(), + lease, + previewGeneration: 0, + story: null, + target, + timeouts: { + implicit: 0, + pageLoad: 300_000, + script: 30_000, + }, + windows, + }; + this.sessions.set(session.id, session); + this.sessionsByTarget.set(target.id, session.id); + return session; + } catch (error) { + await this.runInputCommand(() => + withCommandTimeout((signal) => target.host.releaseActions(signal), 10_000, `Releasing input for target "${target.id}"`), + ).catch(() => undefined); + if (lease) { + const failedLease = lease; + await withCommandTimeout( + (signal) => target.host.closeApplication(failedLease, signal), + 10_000, + `Closing failed target "${target.id}"`, + ).catch(() => undefined); + } else if (leasePromise) { + releaseReservation = false; + void leasePromise + .then(async (lateLease) => { + await this.runInputCommand(() => + withCommandTimeout((signal) => target.host.releaseActions(signal), 10_000, `Releasing late input for target "${target.id}"`), + ).catch(() => undefined); + await withCommandTimeout( + (signal) => target.host.closeApplication(lateLease, signal), + 10_000, + `Closing late target "${target.id}"`, + ).catch(() => undefined); + }) + .catch(() => undefined) + .finally(() => this.reservedTargets.delete(target.id)); + } + throw error; + } finally { + if (releaseReservation) { + this.reservedTargets.delete(target.id); + } + } + } + + get(id: string): DesktopSession { + const session = this.sessions.get(id); + if (!session) { + throw new WebDriverError('invalid session id', `Session "${id}" does not exist.`); + } + return session; + } + + runCommand(sessionId: string, operation: () => Promise): Promise { + const previous = this.commandQueues.get(sessionId) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(operation); + const tail = result.then( + () => undefined, + () => undefined, + ); + this.commandQueues.set(sessionId, tail); + void tail.finally(() => { + if (this.commandQueues.get(sessionId) === tail) { + this.commandQueues.delete(sessionId); + } + }); + return result; + } + + runInputCommand(operation: () => Promise): Promise { + const result = this.inputQueue.catch(() => undefined).then(operation); + this.inputQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async delete(id: string): Promise { + const session = this.get(id); + this.reservedTargets.add(session.target.id); + const failures: unknown[] = []; + try { + try { + await this.runInputCommand(() => + withCommandTimeout( + (signal) => session.target.host.releaseActions(signal), + session.desktopTimeouts.nativeCommand, + `Releasing input for session "${id}"`, + ), + ); + } catch (error) { + failures.push(error); + } + try { + await withCommandTimeout( + (signal) => session.target.host.closeApplication(session.lease, signal), + session.desktopTimeouts.nativeCommand, + `Closing application for session "${id}"`, + ); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) { + throw failures[0]; + } + if (failures.length > 1) { + throw new AggregateError(failures, `Failed to close session "${id}".`); + } + this.sessions.delete(id); + this.sessionsByTarget.delete(session.target.id); + } finally { + this.reservedTargets.delete(session.target.id); + } + } + + async deleteAll(): Promise { + const failures: unknown[] = []; + for (const id of Array.from(this.sessions.keys())) { + try { + await this.delete(id); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'One or more desktop sessions failed to close.'); + } + } + + registerElement(session: DesktopSession, snapshot: NativeElementSnapshot): ElementRecord { + const existingId = session.elementIdsByNativeId.get(snapshot.id); + if (existingId) { + return session.elements.get(existingId)!; + } + const record: ElementRecord = { + id: randomUUID(), + nativeId: snapshot.id, + previewGeneration: session.previewGeneration, + scope: snapshot.scope, + }; + session.elements.set(record.id, record); + session.elementIdsByNativeId.set(record.nativeId, record.id); + session.issuedElementIds.add(record.id); + return record; + } + + resolveElement(session: DesktopSession, id: string): ElementRecord { + const element = session.elements.get(id); + if (!element) { + throw new WebDriverError( + session.issuedElementIds.has(id) ? 'stale element reference' : 'no such element', + `Element "${id}" is not available in this session.`, + ); + } + if (element.scope === 'preview' && element.previewGeneration !== session.previewGeneration) { + throw new WebDriverError('stale element reference', `Element "${id}" is no longer attached to the current view.`); + } + return element; + } + + invalidatePreview(session: DesktopSession): void { + session.previewGeneration += 1; + for (const [id, element] of session.elements) { + if (element.scope === 'preview') { + session.elements.delete(id); + session.elementIdsByNativeId.delete(element.nativeId); + } + } + } +} diff --git a/packages/agentic/desktop-driver/src/server/TargetRegistry.ts b/packages/agentic/desktop-driver/src/server/TargetRegistry.ts new file mode 100644 index 00000000000..c289e71bbb3 --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/TargetRegistry.ts @@ -0,0 +1,29 @@ +import type { DesktopTarget } from '../host/types.js'; + +export class TargetRegistry { + private readonly targets = new Map(); + + constructor(targets: readonly DesktopTarget[] = []) { + for (const target of targets) { + this.register(target); + } + } + + register(target: DesktopTarget): void { + if (!target.id) { + throw new TypeError('Desktop targets require a non-empty id.'); + } + if (this.targets.has(target.id)) { + throw new Error(`Desktop target "${target.id}" is already registered.`); + } + this.targets.set(target.id, target); + } + + get(id: string): DesktopTarget | undefined { + return this.targets.get(id); + } + + list(): readonly DesktopTarget[] { + return [...this.targets.values()]; + } +} diff --git a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts new file mode 100644 index 00000000000..8dae3394e03 --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts @@ -0,0 +1,775 @@ +import { Buffer } from 'node:buffer'; +import { createServer } from 'node:http'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; + +import type { DesktopTarget, DesktopTreeNode, NativeActionSequence, NativeElementSnapshot, NativeSelector, Rect } from '../host/types.js'; +import { createInputState, parseActionSequences } from '../protocol/actions.js'; +import { createReturnedCapabilities, matchCapabilities } from '../protocol/capabilities.js'; +import { webElementIdentifier } from '../protocol/constants.js'; +import { invalidArgument, toWebDriverError, WebDriverError } from '../protocol/errors.js'; +import { withCommandTimeout } from '../protocol/timeouts.js'; +import type { NewSessionRequest, WebDriverActionSequence, WebDriverElement, WebDriverResponse } from '../protocol/types.js'; +import { SessionManager } from './SessionManager.js'; +import type { DesktopSession, ElementRecord } from './SessionManager.js'; +import { TargetRegistry } from './TargetRegistry.js'; + +export type DesktopDriverServerOptions = { + host?: string; + maxBodyBytes?: number; + port?: number; + targets?: readonly DesktopTarget[]; +}; + +export type DesktopDriverServer = { + host: string; + port: number; + sessions: SessionManager; + targets: TargetRegistry; + url: string; + close(): Promise; +}; + +type RouteContext = { + body: unknown; + method: string; + segments: string[]; +}; + +export async function createDesktopDriverServer(options: DesktopDriverServerOptions = {}): Promise { + const host = options.host ?? '127.0.0.1'; + if (!isLoopbackHost(host)) { + throw new Error(`Desktop Driver binds to loopback by default; "${host}" is not a supported loopback host.`); + } + + const targets = new TargetRegistry(options.targets); + const sessions = new SessionManager(); + let closing = false; + const httpServer = createServer((request, response) => { + if (closing) { + const error = new WebDriverError('unknown error', 'Desktop Driver is shutting down.'); + writeJson(response, error.status, { value: error.toJSON() }); + return; + } + void handleRequest(request, response, sessions, targets, options.maxBodyBytes ?? 1024 * 1024); + }); + const port = await listen(httpServer, host, options.port ?? 0); + + return { + host, + port, + sessions, + targets, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- WebDriver is an intentionally loopback-only local protocol. + url: `http://${formatHost(host)}:${port}`, + async close() { + closing = true; + sessions.beginClose(); + let sessionError: unknown; + await closeServer(httpServer); + await sessions.waitForCreates(); + try { + await sessions.deleteAll(); + } catch (error) { + sessionError = error; + } + const hosts = new Set(targets.list().map(({ host: targetHost }) => targetHost)); + const results = await Promise.allSettled( + [...hosts].map((targetHost) => withCommandTimeout((signal) => targetHost.dispose(signal), 10_000, 'Disposing a desktop host')), + ); + const hostErrors = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(({ reason }) => reason); + if (sessionError || hostErrors.length > 0) { + throw new AggregateError([...(sessionError ? [sessionError] : []), ...hostErrors], 'Desktop Driver cleanup failed.'); + } + }, + }; +} + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + sessions: SessionManager, + targets: TargetRegistry, + maxBodyBytes: number, +): Promise { + try { + if (request.headers.origin) { + throw new WebDriverError('unknown error', 'Browser-origin requests are not accepted by Desktop Driver.'); + } + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- This base URL only parses a loopback listener path. + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + const segments = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); + const method = request.method ?? 'GET'; + const body = method === 'GET' || method === 'DELETE' ? undefined : await readJsonBody(request, maxBodyBytes); + const operation = () => route({ body, method, segments }, sessions, targets); + const value = segments[0] === 'session' && segments[1] ? await sessions.runCommand(segments[1], operation) : await operation(); + writeJson(response, 200, { value }); + } catch (error) { + const webdriverError = toWebDriverError(error); + writeJson(response, webdriverError.status, { value: webdriverError.toJSON() }); + } +} + +async function route(context: RouteContext, sessions: SessionManager, targets: TargetRegistry): Promise { + const { body, method, segments } = context; + + if (method === 'GET' && segments.length === 1 && segments[0] === 'status') { + const targetInfo = await Promise.all( + targets.list().map(async (target) => ({ + endpoint: target.endpoint, + id: target.id, + platformName: target.platformName, + renderer: target.renderer, + host: await withCommandTimeout((signal) => target.host.probe(signal), 10_000, `Probing target "${target.id}"`), + })), + ); + return { message: 'Desktop Driver is ready.', ready: true, targets: targetInfo }; + } + + if (method === 'POST' && segments.length === 1 && segments[0] === 'session') { + const request = requireObject(body, 'New Session request') as NewSessionRequest; + if (!request.capabilities) { + throw invalidArgument('New Session requires "capabilities".'); + } + const matched = await matchCapabilities(request.capabilities, targets.list()); + const hostInfo = await withCommandTimeout( + (signal) => matched.target.host.probe(signal), + 10_000, + `Probing target "${matched.target.id}"`, + ); + const launchMode = matched.requested['furn:launchMode'] ?? 'launch'; + if (launchMode !== 'attach' && launchMode !== 'launch') { + throw invalidArgument('"furn:launchMode" must be "attach" or "launch".'); + } + const session = await sessions.create(matched.target, hostInfo, matched.clickMode, launchMode); + return { + sessionId: session.id, + capabilities: createReturnedCapabilities(matched, hostInfo, session.timeouts), + }; + } + + if (segments[0] !== 'session' || !segments[1]) { + throw new WebDriverError('unknown command', `No WebDriver command matches ${method} /${segments.join('/')}.`); + } + + const session = sessions.get(segments[1]); + const command = segments.slice(2); + + if (method === 'DELETE' && command.length === 0) { + await sessions.delete(session.id); + return null; + } + if (command[0] === 'timeouts') { + return handleTimeouts(method, body, session); + } + if (command[0] === 'window') { + return handleWindowCommand(method, command.slice(1), body, session, sessions); + } + if (method === 'POST' && (command[0] === 'element' || command[0] === 'elements') && command.length === 1) { + return findElements(session, sessions, body, command[0] === 'element'); + } + if (method === 'GET' && command[0] === 'element' && command[1] === 'active') { + const active = await hostCommand(session, 'Reading the active element', (signal) => + session.target.host.activeElement(session.currentWindowId, signal), + ); + return active ? toElementReference(sessions.registerElement(session, active)) : null; + } + if (command[0] === 'element' && command[1]) { + return handleElementCommand(method, command.slice(1), body, session, sessions); + } + if (command[0] === 'actions') { + if (method === 'POST') { + const { actions, nextState } = parseActionSequences(requireObject(body, 'actions request').actions, session.inputState); + const resolvedActions = await resolveActionOrigins(actions, session, sessions); + await sessions.runInputCommand(async () => { + session.inputState = nextState; + try { + await hostCommand(session, 'Performing input actions', (signal) => session.target.host.performActions(resolvedActions, signal)); + } catch (error) { + await hostCommand(session, 'Releasing failed input actions', (signal) => session.target.host.releaseActions(signal)).catch( + () => undefined, + ); + session.inputState = createInputState(); + throw error; + } + }); + return null; + } + if (method === 'DELETE') { + await sessions.runInputCommand(() => + hostCommand(session, 'Releasing input actions', (signal) => session.target.host.releaseActions(signal)), + ); + session.inputState = createInputState(); + return null; + } + } + if (method === 'GET' && command[0] === 'screenshot' && command.length === 1) { + const image = await hostCommand(session, 'Capturing the current window', (signal) => + session.target.host.captureWindow(session.currentWindowId, signal), + ); + return Buffer.from(image.data).toString('base64'); + } + if (method === 'GET' && command[0] === 'source' && command.length === 1) { + return hostCommand(session, 'Reading the accessibility source', (signal) => + session.target.host.source(session.currentWindowId, signal), + ); + } + if (command[0] === 'furn') { + if (method === 'GET' && command[1] === 'tree' && command.length === 2) { + const snapshots = await hostCommand(session, 'Reading the compact accessibility tree', (signal) => + session.target.host.tree(session.currentWindowId, signal), + ); + return buildDesktopTree(snapshots); + } + return handleStorybookCommand(method, command.slice(1), body, session, sessions); + } + + function buildDesktopTree(snapshots: readonly NativeElementSnapshot[]): DesktopTreeNode[] { + const children = new Map(); + for (const snapshot of snapshots) { + const siblings = children.get(snapshot.parentId) ?? []; + siblings.push(snapshot); + children.set(snapshot.parentId, siblings); + } + const build = (snapshot: NativeElementSnapshot): DesktopTreeNode => ({ + children: (children.get(snapshot.id) ?? []).map(build), + ...(snapshot.name ? { name: snapshot.name } : {}), + rect: snapshot.rect, + role: snapshot.role, + states: { + ...(snapshot.checked.supported ? { checked: snapshot.checked.value } : {}), + ...(snapshot.enabled.supported ? { enabled: snapshot.enabled.value } : {}), + ...(snapshot.expanded.supported ? { expanded: snapshot.expanded.value } : {}), + ...(snapshot.focused.supported ? { focused: snapshot.focused.value } : {}), + ...(snapshot.selected.supported ? { selected: snapshot.selected.value } : {}), + ...(snapshot.visible.supported ? { visible: snapshot.visible.value } : {}), + }, + ...(snapshot.automationId ? { testId: snapshot.automationId } : {}), + ...(snapshot.text ? { text: snapshot.text } : {}), + ...(snapshot.value !== undefined ? { value: snapshot.value } : {}), + }); + return (children.get(undefined) ?? []).map(build); + } + + async function resolveActionOrigins( + actions: readonly WebDriverActionSequence[], + session: DesktopSession, + sessions: SessionManager, + ): Promise { + const resolved: NativeActionSequence[] = []; + for (const sequence of actions) { + const resolvedSequence: NativeActionSequence = { ...sequence, actions: [] }; + for (const action of sequence.actions) { + const origin = action.origin; + if (!origin || origin === 'viewport' || origin === 'pointer') { + resolvedSequence.actions.push(action); + continue; + } + const elementId = typeof origin === 'object' ? origin[webElementIdentifier] : undefined; + if (typeof elementId !== 'string') { + throw invalidArgument('Action element origin is missing a valid WebDriver element reference.'); + } + const record = sessions.resolveElement(session, elementId); + await hostCommand(session, `Resolving action origin element "${elementId}"`, (signal) => + session.target.host.snapshot(record.nativeId, signal), + ); + resolvedSequence.actions.push({ + ...action, + origin: { elementId: record.nativeId }, + }); + } + resolved.push(resolvedSequence); + } + return resolved; + } + + if (isUnsupportedBrowserCommand(command)) { + throw new WebDriverError('unsupported operation', `/${command.join('/')} is not supported for native desktop applications.`); + } + + async function handleStorybookCommand( + method: string, + command: string[], + body: unknown, + session: DesktopSession, + sessions: SessionManager, + ): Promise { + const orchestrator = session.target.storyOrchestrator; + if (!orchestrator) { + throw new WebDriverError('unsupported operation', `Target "${session.target.id}" does not provide Storybook orchestration.`); + } + if (method === 'GET' && command[0] === 'manifest' && command.length === 1) { + return withCommandTimeout(() => orchestrator.getManifest(), session.desktopTimeouts.nativeCommand, 'Reading the Storybook manifest'); + } + if (method === 'GET' && command[0] === 'story' && command.length === 1) { + return session.story; + } + if (method === 'POST' && command[0] === 'story' && command.length === 1) { + const request = parseStorySelection(body); + const deadline = Date.now() + session.desktopTimeouts.storyRender; + sessions.invalidatePreview(session); + session.story = null; + const result = await withCommandTimeout( + () => orchestrator.selectStory(request), + remainingTime(deadline), + `Selecting Storybook story "${request.storyId}"`, + ); + await verifyStoryMarker(session, result, deadline); + session.story = result; + return result; + } + if (method === 'POST' && command[0] === 'story' && command[1] === 'reset') { + const request = parseStorySelection(body); + const deadline = Date.now() + session.desktopTimeouts.storyRender; + sessions.invalidatePreview(session); + session.story = null; + const result = await withCommandTimeout( + () => orchestrator.resetStory(request), + remainingTime(deadline), + `Resetting Storybook story "${request.storyId}"`, + ); + await verifyStoryMarker(session, result, deadline); + session.story = result; + return result; + } + + async function verifyStoryMarker( + session: DesktopSession, + expected: { previewGeneration: number; runId: string; storyId: string }, + deadline: number, + ): Promise { + const testId = session.target.storyRootTestId; + if (!testId) { + return; + } + do { + const matches = await hostCommand(session, 'Finding the native Storybook story marker', (signal) => + session.target.host.find({ windowId: session.currentWindowId }, { strategy: 'accessibility id', value: testId }, signal), + ); + for (const marker of matches) { + const value = marker.name ?? marker.value ?? marker.text; + if (value && matchesStoryMarker(value, expected)) { + return; + } + } + await delay(25); + } while (Date.now() <= deadline); + throw new WebDriverError( + 'timeout', + `The native Storybook marker did not confirm story "${expected.storyId}" run "${expected.runId}".`, + ); + } + + function matchesStoryMarker(value: string, expected: { previewGeneration: number; runId: string; storyId: string }): boolean { + try { + const marker = JSON.parse(value) as Record; + return ( + marker.storyId === expected.storyId && marker.runId === expected.runId && marker.previewGeneration === expected.previewGeneration + ); + } catch { + return false; + } + } + if (method === 'POST' && command[0] === 'story' && command[1] === 'args') { + if (!orchestrator.updateArgs) { + throw new WebDriverError('unsupported operation', 'This Storybook target does not support arg updates.'); + } + const request = requireObject(body, 'Storybook args request'); + if (typeof request.storyId !== 'string') { + throw invalidArgument('Storybook args request requires a string "storyId".'); + } + const args = requireObject(request.args, 'Storybook args request "args"'); + sessions.invalidatePreview(session); + session.story = null; + await withCommandTimeout( + () => orchestrator.updateArgs!(request.storyId as string, args), + session.desktopTimeouts.storyRender, + `Updating args for Storybook story "${request.storyId}"`, + ); + return null; + } + throw new WebDriverError('unknown command', `Unknown Desktop Driver extension command "furn/${command.join('/')}".`); + } + + function parseStorySelection(body: unknown): { requestId: string; runId: string; storyId: string } { + const request = requireObject(body, 'Storybook selection request'); + for (const field of ['requestId', 'runId', 'storyId'] as const) { + if (typeof request[field] !== 'string' || !request[field]) { + throw invalidArgument(`Storybook selection request requires a non-empty string "${field}".`); + } + } + return { + requestId: request.requestId as string, + runId: request.runId as string, + storyId: request.storyId as string, + }; + } + throw new WebDriverError('unknown command', `No WebDriver command matches ${method} /session/${session.id}/${command.join('/')}.`); +} + +function handleTimeouts(method: string, body: unknown, session: DesktopSession): unknown { + if (method === 'GET') { + return { ...session.timeouts }; + } + if (method !== 'POST') { + throw new WebDriverError('unknown method', `Method ${method} is not allowed for timeouts.`); + } + const updates = requireObject(body, 'timeouts'); + for (const name of ['implicit', 'pageLoad', 'script'] as const) { + if (updates[name] !== undefined) { + const value = updates[name]; + if (!Number.isInteger(value) || (value as number) < 0) { + throw invalidArgument(`Timeout "${name}" must be a non-negative integer.`); + } + session.timeouts[name] = value as number; + } + } + return null; +} + +async function handleWindowCommand( + method: string, + command: string[], + body: unknown, + session: DesktopSession, + sessions: SessionManager, +): Promise { + const host = session.target.host; + if (command.length === 0 && method === 'GET') { + ensureCurrentWindow(session); + return session.currentWindowId; + } + if (command.length === 0 && method === 'POST') { + const handle = requireObject(body, 'switch window request').handle; + if (typeof handle !== 'string' || !session.windows.some(({ id }) => id === handle)) { + throw new WebDriverError('no such window', `Window "${String(handle)}" does not exist in this session.`); + } + await sessions.runInputCommand(() => hostCommand(session, `Activating window "${handle}"`, (signal) => host.activate(handle, signal))); + session.currentWindowId = handle; + return null; + } + if (command.length === 0 && method === 'DELETE') { + ensureCurrentWindow(session); + await sessions.runInputCommand(() => + hostCommand(session, `Closing window "${session.currentWindowId}"`, (signal) => host.closeWindow(session.currentWindowId, signal)), + ); + session.windows = await hostCommand(session, 'Reading application windows', (signal) => host.windows(session.lease, signal)); + session.currentWindowId = session.windows[0]?.id ?? ''; + return session.windows.map(({ id }) => id); + } + if (command[0] === 'handles' && command.length === 1 && method === 'GET') { + session.windows = await hostCommand(session, 'Reading application windows', (signal) => host.windows(session.lease, signal)); + return session.windows.map(({ id }) => id); + } + if (command[0] === 'rect' && command.length === 1) { + ensureCurrentWindow(session); + if (method === 'GET') { + return hostCommand(session, 'Reading the current window rectangle', (signal) => host.getWindowRect(session.currentWindowId, signal)); + } + if (method === 'POST') { + const rect = validatePartialRect(requireObject(body, 'window rectangle')); + return hostCommand(session, 'Setting the current window rectangle', (signal) => + host.setWindowRect(session.currentWindowId, rect, signal), + ); + } + } + throw new WebDriverError('unknown command', `Unknown window command "${command.join('/')}".`); +} + +async function handleElementCommand( + method: string, + command: string[], + body: unknown, + session: DesktopSession, + sessions: SessionManager, +): Promise { + const record = sessions.resolveElement(session, command[0]); + const snapshot = await hostCommand(session, `Reading element "${record.id}"`, (signal) => + session.target.host.snapshot(record.nativeId, signal), + ); + const operation = command.slice(1); + + if (operation[0] === 'shadow') { + throw new WebDriverError('unsupported operation', 'Shadow roots are not supported for native desktop applications.'); + } + if (method === 'POST' && (operation[0] === 'element' || operation[0] === 'elements') && operation.length === 1) { + return findElements(session, sessions, body, operation[0] === 'element', record); + } + if (method === 'GET' && operation[0] === 'name') { + return snapshot.role; + } + if (method === 'GET' && operation[0] === 'text') { + return snapshot.text ?? snapshot.value ?? snapshot.name ?? ''; + } + if (method === 'GET' && operation[0] === 'rect') { + return snapshot.rect; + } + if (method === 'GET' && operation[0] === 'enabled') { + return requireSupported(snapshot.enabled, 'enabled'); + } + if (method === 'GET' && operation[0] === 'selected') { + return requireSupported(snapshot.selected, 'selected'); + } + if (method === 'GET' && operation[0] === 'displayed') { + return requireSupported(snapshot.visible, 'displayed'); + } + if (method === 'GET' && operation[0] === 'attribute' && operation[1]) { + return getElementValue(snapshot, operation[1]); + } + if (method === 'GET' && operation[0] === 'property' && operation[1]) { + return getElementValue(snapshot, operation[1]); + } + if (method === 'POST' && operation[0] === 'click') { + if (!requireSupported(snapshot.enabled, 'enabled') || !requireSupported(snapshot.visible, 'visible')) { + throw new WebDriverError('element not interactable', `Element "${record.id}" cannot be clicked.`); + } + await sessions.runInputCommand(async () => { + await hostCommand(session, `Activating window "${snapshot.windowId}"`, (signal) => + session.target.host.activate(snapshot.windowId, signal), + ); + if (session.clickMode !== 'accessibility') { + const point = { + x: snapshot.rect.x + snapshot.rect.width / 2, + y: snapshot.rect.y + snapshot.rect.height / 2, + }; + const hit = await hostCommand(session, `Hit testing element "${record.id}"`, (signal) => + session.target.host.hitTest(snapshot.windowId, point.x, point.y, signal), + ); + if (hit && hit.id !== snapshot.id) { + throw new WebDriverError('element click intercepted', `Element "${record.id}" is obscured by another native element.`); + } + } + await hostCommand(session, `Clicking element "${record.id}"`, (signal) => + session.target.host.click(record.nativeId, session.clickMode, signal), + ); + }); + return null; + } + if (method === 'POST' && operation[0] === 'clear') { + await sessions.runInputCommand(() => + hostCommand(session, `Clearing element "${record.id}"`, (signal) => session.target.host.clear(record.nativeId, signal)), + ); + return null; + } + if (method === 'POST' && operation[0] === 'value') { + const request = requireObject(body, 'send keys request'); + const text = + typeof request.text === 'string' + ? request.text + : Array.isArray(request.value) && request.value.every((part) => typeof part === 'string') + ? request.value.join('') + : undefined; + if (text === undefined) { + throw invalidArgument('Send Keys requires a string "text" or string-array "value".'); + } + await sessions.runInputCommand(() => + hostCommand(session, `Sending keys to element "${record.id}"`, (signal) => + session.target.host.sendKeys(record.nativeId, text, signal), + ), + ); + return null; + } + if (method === 'GET' && operation[0] === 'screenshot') { + const image = await hostCommand(session, `Capturing element "${record.id}"`, (signal) => + session.target.host.captureElement(record.nativeId, signal), + ); + return Buffer.from(image.data).toString('base64'); + } + + throw new WebDriverError('unknown command', `Unknown element command "${operation.join('/')}".`); +} + +async function findElements( + session: DesktopSession, + sessions: SessionManager, + body: unknown, + single: boolean, + rootElement?: ElementRecord, +): Promise { + const selector = parseSelector(body); + const deadline = Date.now() + session.timeouts.implicit; + let matches: NativeElementSnapshot[] = []; + do { + matches = await hostCommand(session, `Finding elements by ${selector.strategy}`, (signal) => + session.target.host.find({ elementId: rootElement?.nativeId, windowId: session.currentWindowId }, selector, signal), + ); + if (matches.length > 0 || Date.now() >= deadline) { + break; + } + await delay(25); + } while (Date.now() <= deadline); + + if (single) { + const match = matches[0]; + if (!match) { + throw new WebDriverError('no such element', `No element matched ${selector.strategy} "${selector.value}".`); + } + return toElementReference(sessions.registerElement(session, match)); + } + return matches.map((match) => toElementReference(sessions.registerElement(session, match))); +} + +function parseSelector(body: unknown): NativeSelector { + const request = requireObject(body, 'element lookup'); + const strategy = request.using; + const value = request.value; + if ( + strategy !== '-furn:text' && + strategy !== 'accessibility id' && + strategy !== 'tag name' && + strategy !== 'link text' && + strategy !== 'partial link text' + ) { + throw new WebDriverError('invalid selector', `Locator strategy "${String(strategy)}" is not supported.`); + } + if (typeof value !== 'string') { + throw invalidArgument('Element locator "value" must be a string.'); + } + return { strategy, value }; +} + +function toElementReference(record: ElementRecord): WebDriverElement { + return { [webElementIdentifier]: record.id }; +} + +function requireSupported(value: { supported: true; value: T } | { supported: false; reason: string }, name: string): T { + if (!value.supported) { + const reason = 'reason' in value ? value.reason : 'not reported by the host'; + throw new WebDriverError('unsupported operation', `Element property "${name}" is unavailable: ${reason}`); + } + return value.value; +} + +function getElementValue(snapshot: NativeElementSnapshot, name: string): unknown { + switch (name) { + case 'testID': + case 'automationId': + return snapshot.automationId ?? null; + case 'name': + case 'label': + return snapshot.name ?? null; + case 'role': + return snapshot.role; + case 'value': + return snapshot.value ?? null; + case 'focused': + return requireSupported(snapshot.focused, 'focused'); + case 'enabled': + return requireSupported(snapshot.enabled, 'enabled'); + case 'checked': + return requireSupported(snapshot.checked, 'checked'); + case 'expanded': + return requireSupported(snapshot.expanded, 'expanded'); + case 'selected': + return requireSupported(snapshot.selected, 'selected'); + default: + return null; + } +} + +function validatePartialRect(value: Record): Partial { + const result: Partial = {}; + for (const name of ['x', 'y', 'width', 'height'] as const) { + if (value[name] !== undefined) { + if (typeof value[name] !== 'number' || !Number.isFinite(value[name])) { + throw invalidArgument(`Window rectangle "${name}" must be a finite number.`); + } + if ((name === 'width' || name === 'height') && (value[name] as number) < 0) { + throw invalidArgument(`Window rectangle "${name}" must not be negative.`); + } + result[name] = value[name] as number; + } + } + return result; +} + +function ensureCurrentWindow(session: DesktopSession): void { + if (!session.currentWindowId || !session.windows.some(({ id }) => id === session.currentWindowId)) { + throw new WebDriverError('no such window', 'The session does not have a current window.'); + } +} + +function hostCommand(session: DesktopSession, description: string, operation: (signal: AbortSignal) => Promise): Promise { + return withCommandTimeout(operation, session.desktopTimeouts.nativeCommand, description); +} + +function requireObject(value: unknown, name: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidArgument(`${name} must be an object.`); + } + return value as Record; +} + +function isUnsupportedBrowserCommand(command: readonly string[]): boolean { + return ['url', 'back', 'forward', 'refresh', 'cookie', 'frame', 'alert', 'execute', 'print'].includes(command[0]); +} + +async function readJsonBody(request: IncomingMessage, maxBytes: number): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > maxBytes) { + throw invalidArgument(`Request body exceeds the ${maxBytes}-byte limit.`); + } + chunks.push(buffer); + } + if (chunks.length === 0) { + return {}; + } + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw invalidArgument('Request body must contain valid JSON.'); + } +} + +function writeJson(response: ServerResponse, status: number, body: WebDriverResponse): void { + const json = JSON.stringify(body); + response.writeHead(status, { + 'Cache-Control': 'no-cache', + 'Content-Length': Buffer.byteLength(json), + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end(json); +} + +function isLoopbackHost(host: string): boolean { + return host === '127.0.0.1' || host === '::1' || host === 'localhost'; +} + +function formatHost(host: string): string { + return host.includes(':') ? `[${host}]` : host; +} + +function listen(server: Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Desktop Driver did not receive a TCP address.')); + return; + } + resolve(address.port); + }); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function remainingTime(deadline: number): number { + return Math.max(1, deadline - Date.now()); +} diff --git a/packages/agentic/desktop-driver/src/server/desktopDriver.test.ts b/packages/agentic/desktop-driver/src/server/desktopDriver.test.ts new file mode 100644 index 00000000000..56c3014945a --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/desktopDriver.test.ts @@ -0,0 +1,421 @@ +import { Buffer } from 'node:buffer'; + +import { createDesktopDriverClient } from '../client/DesktopDriverClient.js'; +import type { FakeDesktopHost } from '../hosts/fake/FakeDesktopHost.js'; +import { webElementIdentifier } from '../protocol/constants.js'; +import { runDesktopStoryTests } from '../runner/StoryTestRunner.js'; +import { createDesktopDriverTestHarness } from '../testing/protocolHarness.js'; +import type { DesktopStoryManifest, StoryOrchestrator, StoryReadyResult } from '../storybook.js'; + +describe('Desktop Driver W3C remote end', () => { + test('serves status and a complete fake-host session through raw HTTP', async () => { + const harness = await createDesktopDriverTestHarness(); + try { + const status = await getJson(`${harness.server.url}/status`); + expect(status.value).toMatchObject({ ready: true }); + + const created = await getJson(`${harness.server.url}/session`, { + method: 'POST', + body: JSON.stringify({ + capabilities: { + alwaysMatch: { + browserName: 'furn-native-desktop', + platformName: 'windows', + 'furn:target': harness.target.id, + 'furn:clickMode': 'physical', + }, + }, + }), + }); + const sessionId = created.value.sessionId as string; + expect(created.value.capabilities).toMatchObject({ + browserName: 'furn-native-desktop', + platformName: 'windows', + 'furn:clickMode': 'physical', + 'furn:endpoint': 'windows', + }); + + const found = await getJson(`${harness.server.url}/session/${sessionId}/element`, { + method: 'POST', + body: JSON.stringify({ using: 'accessibility id', value: 'button-primary' }), + }); + const elementId = found.value[webElementIdentifier] as string; + expect(elementId).toEqual(expect.any(String)); + + await getJson(`${harness.server.url}/session/${sessionId}/element/${elementId}/click`, { + method: 'POST', + body: '{}', + }); + expect(harness.host.actions).toContainEqual({ type: 'click', elementId: 'button', mode: 'physical' }); + + const screenshot = await getJson(`${harness.server.url}/session/${sessionId}/screenshot`); + expect( + Buffer.from(screenshot.value as string, 'base64') + .subarray(1, 4) + .toString(), + ).toBe('PNG'); + + const source = await getJson(`${harness.server.url}/session/${sessionId}/source`); + expect(source.value).toContain('id="button-primary"'); + + const malformedActions = await getJson(`${harness.server.url}/session/${sessionId}/actions`, { + method: 'POST', + body: JSON.stringify({ + actions: [{ id: 'invalid', type: 'not-a-source', actions: [{ type: 'not-an-action' }] }], + }), + expectStatus: 400, + }); + expect(malformedActions.value).toMatchObject({ error: 'invalid argument' }); + + const shadow = await getJson(`${harness.server.url}/session/${sessionId}/element/${elementId}/shadow`, { + expectStatus: 500, + }); + expect(shadow.value).toMatchObject({ error: 'unsupported operation' }); + + const unsupported = await getJson(`${harness.server.url}/session/${sessionId}/url`, { expectStatus: 500 }); + expect(unsupported.value).toMatchObject({ error: 'unsupported operation' }); + + await getJson(`${harness.server.url}/session/${sessionId}`, { method: 'DELETE' }); + expect(harness.host.actions).toContainEqual(expect.objectContaining({ type: 'release-actions' })); + } finally { + await harness.close(); + } + }); + + test('uses stable WebDriver element IDs and reports stale references', async () => { + const harness = await createDesktopDriverTestHarness(); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + const first = await session.findElement('accessibility id', 'button-primary'); + const second = await session.findElement('accessibility id', 'button-primary'); + expect(second.id).toBe(first.id); + + harness.host.removeElement('button'); + await expect(first.getText()).rejects.toMatchObject({ code: 'stale element reference' }); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('rejects duplicate capabilities and concurrent sessions per target', async () => { + const harness = await createDesktopDriverTestHarness({ launchDelayMs: 50 }); + try { + const duplicate = await getJson(`${harness.server.url}/session`, { + method: 'POST', + body: JSON.stringify({ + capabilities: { + alwaysMatch: { platformName: 'windows' }, + firstMatch: [{ platformName: 'windows' }], + }, + }), + expectStatus: 400, + }); + expect(duplicate.value).toMatchObject({ error: 'invalid argument' }); + + const client = createDesktopDriverClient({ url: harness.server.url }); + const capabilities = { alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id } }; + const [first, second] = await Promise.allSettled([client.newSession(capabilities), client.newSession(capabilities)]); + expect([first.status, second.status].sort()).toEqual(['fulfilled', 'rejected']); + const session = first.status === 'fulfilled' ? first.value : second.status === 'fulfilled' ? second.value : undefined; + expect(session).toBeDefined(); + if (!session) { + throw new Error('Expected one concurrent session request to succeed.'); + } + expect(harness.host.actions.filter(({ type }) => type === 'launch')).toHaveLength(1); + await expect(client.newSession(capabilities)).rejects.toMatchObject({ code: 'session not created' }); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('waits for in-flight session creation before server cleanup', async () => { + const harness = await createDesktopDriverTestHarness({ launchDelayMs: 80 }); + const client = createDesktopDriverClient({ url: harness.server.url }); + const creating = client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + + await waitUntil(() => harness.host.actions.some(({ type }) => type === 'launch')); + + const closing = harness.server.close(); + await expect(creating).rejects.toMatchObject({ code: 'session not created' }); + await closing; + expect(harness.host.actions).toContainEqual(expect.objectContaining({ type: 'release-actions' })); + expect(harness.host.actions).toContainEqual(expect.objectContaining({ type: 'close-application' })); + }); + + test('serializes release behind a timed-out native action and drains the host operation', async () => { + const harness = await createDesktopDriverTestHarness({ actionDelayMs: 50 }); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + + harness.server.sessions.get(session.id).desktopTimeouts.nativeCommand = 10; + const performing = session.performActions([{ id: 'key', type: 'key', actions: [{ type: 'keyDown', value: 'A' }] }]); + await waitUntil(() => harness.host.actions.some(({ type }) => type === 'actions-start')); + const releasing = session.releaseActions(); + + await expect(performing).rejects.toMatchObject({ code: 'timeout' }); + await expect(releasing).resolves.toBeNull(); + const started = harness.host.actions.findIndex(({ type }) => type === 'actions-start'); + const released = harness.host.actions.findIndex(({ type }) => type === 'release-actions'); + expect(harness.host.actions.some(({ type }) => type === 'actions')).toBe(false); + expect(released).toBeGreaterThan(started); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('keeps a target reserved until application teardown completes', async () => { + const harness = await createDesktopDriverTestHarness({ closeDelayMs: 50 }); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const capabilities = { alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id } }; + const session = await client.newSession(capabilities); + const deleting = session.delete(); + await waitUntil(() => harness.host.actions.some(({ type }) => type === 'close-application-start')); + + await expect(client.newSession(capabilities)).rejects.toMatchObject({ code: 'session not created' }); + await deleting; + const replacement = await client.newSession(capabilities); + await replacement.delete(); + } finally { + await harness.close(); + } + }); + + test('supports the typed client for elements, text entry, actions, and screenshots', async () => { + const harness = await createDesktopDriverTestHarness({ endpoint: 'macos', platformName: 'macos' }); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { + platformName: 'macos', + 'furn:target': harness.target.id, + 'furn:clickMode': 'accessibility', + }, + }); + const input = await session.findElement('accessibility id', 'input-name'); + await input.sendKeys('Ada'); + expect(await input.getText()).toBe('Ada'); + await input.clear(); + expect(await input.getText()).toBe(''); + await session.performActions([{ id: 'keyboard', type: 'key', actions: [{ type: 'keyDown', value: '\uE004' }] }]); + await session.releaseActions(); + expect(await session.takeScreenshot()).toEqual(expect.any(String)); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('routes Storybook commands and invalidates only preview element references', async () => { + const manifest: DesktopStoryManifest = { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/button/button.stories.tsx', + tags: ['story'], + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform', + portablePlanDigest: 'portable', + schemaVersion: 1, + }; + let currentStory: StoryReadyResult | null = null; + const hostRef: { current?: FakeDesktopHost } = {}; + const orchestrator: StoryOrchestrator = { + async getManifest() { + return manifest; + }, + async getCurrentStory() { + return currentStory; + }, + async selectStory(request) { + currentStory = { previewGeneration: 1, runId: request.runId, storyId: request.storyId }; + hostRef.current?.setElementName('story-root', JSON.stringify(currentStory)); + return currentStory; + }, + async resetStory(request) { + currentStory = { previewGeneration: 2, runId: request.runId, storyId: request.storyId }; + hostRef.current?.setElementName('story-root', JSON.stringify(currentStory)); + return currentStory; + }, + }; + const harness = await createDesktopDriverTestHarness({}, orchestrator); + hostRef.current = harness.host; + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + const previewBefore = await session.findElement('accessibility id', 'button-primary'); + const chromeBefore = await session.findElement('accessibility id', 'app-root'); + const elementOriginActions = [ + { + id: 'wheel', + type: 'wheel' as const, + actions: [ + { + type: 'scroll', + deltaX: 0, + deltaY: 100, + origin: { [webElementIdentifier]: previewBefore.id }, + x: 0, + y: 0, + }, + ], + }, + ]; + await session.performActions(elementOriginActions); + expect(harness.host.actions).toContainEqual({ + type: 'actions', + actions: [ + expect.objectContaining({ + actions: [expect.objectContaining({ origin: { elementId: 'button' } })], + }), + ], + }); + + await expect(session.getStoryManifest()).resolves.toEqual(manifest); + await expect(session.selectStory('components-button--default', 'run-1')).resolves.toMatchObject({ + previewGeneration: 1, + runId: 'run-1', + }); + await expect(previewBefore.getText()).rejects.toMatchObject({ code: 'stale element reference' }); + await expect(session.performActions(elementOriginActions)).rejects.toMatchObject({ code: 'stale element reference' }); + await expect(chromeBefore.getTagName()).resolves.toBe('application'); + + const previewAfter = await session.findElement('accessibility id', 'button-primary'); + expect(previewAfter.id).not.toBe(previewBefore.id); + const runnerManifest: DesktopStoryManifest = { + ...manifest, + entries: [ + { + ...manifest.entries[0], + tests: { + version: 1, + tests: [ + { + id: 'clicks-button', + steps: [ + { action: 'wait', target: { testId: 'button-primary' }, timeoutMs: 100 }, + { action: 'click', target: { testId: 'button-primary' } }, + ], + }, + ], + }, + }, + ], + }; + await expect( + runDesktopStoryTests({ + endpoint: 'windows', + manifest: runnerManifest, + platformName: 'windows', + selection: { test: 'clicks-button' }, + session, + targetId: harness.target.id, + }), + ).resolves.toMatchObject({ status: 'passed' }); + expect(harness.host.actions).toContainEqual({ type: 'click', elementId: 'button', mode: 'physical' }); + await expect(session.resetStory('components-button--default', 'run-2')).resolves.toMatchObject({ + previewGeneration: 2, + runId: 'run-2', + }); + await expect(previewAfter.getText()).rejects.toMatchObject({ code: 'stale element reference' }); + await session.delete(); + } finally { + await harness.close(); + } + }); + + test('invalidates preview references before a reset whose marker verification fails', async () => { + const manifest: DesktopStoryManifest = { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'button.stories.tsx', + tags: ['story'], + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform', + portablePlanDigest: 'portable', + schemaVersion: 1, + }; + const hostRef: { current?: FakeDesktopHost } = {}; + const orchestrator: StoryOrchestrator = { + async getCurrentStory() { + return null; + }, + async getManifest() { + return manifest; + }, + async resetStory(request) { + hostRef.current?.resetPreview(); + return { previewGeneration: 1, runId: request.runId, storyId: request.storyId }; + }, + async selectStory(request) { + return { previewGeneration: 1, runId: request.runId, storyId: request.storyId }; + }, + }; + const harness = await createDesktopDriverTestHarness({}, orchestrator); + hostRef.current = harness.host; + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'windows', 'furn:target': harness.target.id }, + }); + harness.server.sessions.get(session.id).desktopTimeouts.storyRender = 20; + const preview = await session.findElement('accessibility id', 'button-primary'); + + await expect(session.resetStory('components-button--default', 'failed-run')).rejects.toMatchObject({ + code: 'timeout', + }); + await expect(preview.getText()).rejects.toMatchObject({ code: 'stale element reference' }); + await session.delete(); + } finally { + await harness.close(); + } + }); +}); + +type GetJsonOptions = RequestInit & { + expectStatus?: number; +}; + +async function getJson(url: string, options: GetJsonOptions = {}): Promise> { + const { expectStatus = 200, ...init } = options; + const response = await fetch(url, { + ...init, + headers: init.body ? { 'Content-Type': 'application/json', ...init.headers } : init.headers, + }); + expect(response.status).toBe(expectStatus); + return response.json() as Promise>; +} + +async function waitUntil(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('Timed out waiting for the test condition.'); +} diff --git a/packages/agentic/desktop-driver/src/server/index.ts b/packages/agentic/desktop-driver/src/server/index.ts new file mode 100644 index 00000000000..d82a25f0124 --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/index.ts @@ -0,0 +1,5 @@ +export { createDesktopDriverServer } from './createDesktopDriverServer.js'; +export type { DesktopDriverServer, DesktopDriverServerOptions } from './createDesktopDriverServer.js'; +export { SessionManager } from './SessionManager.js'; +export type { DesktopSession, ElementRecord } from './SessionManager.js'; +export { TargetRegistry } from './TargetRegistry.js'; diff --git a/packages/agentic/desktop-driver/src/server/webdriverio.contract.cjs b/packages/agentic/desktop-driver/src/server/webdriverio.contract.cjs new file mode 100644 index 00000000000..0ce263c6c6a --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/webdriverio.contract.cjs @@ -0,0 +1,38 @@ +const { remote } = require('webdriverio'); + +async function main() { + const url = new URL(process.argv[2]); + const target = process.argv[3]; + const capabilities = Object.assign( + { + browserName: 'furn-native-desktop', + platformName: 'windows', + }, + { 'furn:target': target }, + ); + const browser = await remote({ + logLevel: 'silent', + hostname: url.hostname, + port: Number(url.port), + path: '/', + capabilities, + }); + + try { + const button = await browser.$('~button-primary'); + const result = { + enabled: await button.isEnabled(), + screenshot: await browser.takeScreenshot(), + tagName: await button.getTagName(), + }; + await button.click(); + process.stdout.write(JSON.stringify(result)); + } finally { + await browser.deleteSession(); + } +} + +main().catch((error) => { + process.stderr.write(error.stack ?? error.message); + process.exitCode = 1; +}); diff --git a/packages/agentic/desktop-driver/src/server/webdriverio.test.ts b/packages/agentic/desktop-driver/src/server/webdriverio.test.ts new file mode 100644 index 00000000000..0fa8521ae6d --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/webdriverio.test.ts @@ -0,0 +1,41 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +import { createDesktopDriverTestHarness } from '../testing/protocolHarness.js'; + +describe('WebdriverIO compatibility', () => { + test('drives the fake host without an Appium service', async () => { + const harness = await createDesktopDriverTestHarness(); + try { + const result = await runContract(harness.server.url, harness.target.id); + expect(result).toMatchObject({ + enabled: true, + screenshot: expect.any(String), + tagName: 'button', + }); + expect(harness.host.actions).toContainEqual({ type: 'click', elementId: 'button', mode: 'physical' }); + } finally { + await harness.close(); + } + }); +}); + +function runContract(url: string, target: string): Promise> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(__dirname, 'webdriverio.contract.cjs'), url, target], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.once('error', reject); + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || `WebdriverIO contract process exited with code ${code}.`)); + return; + } + resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')) as Record); + }); + }); +} diff --git a/packages/agentic/desktop-driver/src/storybook.ts b/packages/agentic/desktop-driver/src/storybook.ts new file mode 100644 index 00000000000..df5296751b6 --- /dev/null +++ b/packages/agentic/desktop-driver/src/storybook.ts @@ -0,0 +1,40 @@ +import type { DesktopStoryTests } from './authoring/storyTests.js'; +import type { DesktopEndpoint } from './protocol/types.js'; + +export type DesktopStoryManifestEntry = { + id: string; + name: string; + packageName: string; + sourcePath: string; + tags: readonly string[]; + title: string; + tests?: DesktopStoryTests; +}; + +export type DesktopStoryManifest = { + endpoint: DesktopEndpoint; + entries: readonly DesktopStoryManifestEntry[]; + platformManifestDigest: string; + portablePlanDigest: string; + schemaVersion: 1; +}; + +export type StorySelectionRequest = { + requestId: string; + runId: string; + storyId: string; +}; + +export type StoryReadyResult = { + previewGeneration: number; + runId: string; + storyId: string; +}; + +export interface StoryOrchestrator { + getManifest(): Promise; + getCurrentStory(): Promise; + selectStory(request: StorySelectionRequest): Promise; + resetStory(request: StorySelectionRequest): Promise; + updateArgs?(storyId: string, args: Readonly>): Promise; +} diff --git a/packages/agentic/desktop-driver/src/testing/FakeStoryOrchestrator.ts b/packages/agentic/desktop-driver/src/testing/FakeStoryOrchestrator.ts new file mode 100644 index 00000000000..dc14b9bc044 --- /dev/null +++ b/packages/agentic/desktop-driver/src/testing/FakeStoryOrchestrator.ts @@ -0,0 +1,55 @@ +import type { FakeDesktopHost } from '../hosts/fake/FakeDesktopHost.js'; +import type { DesktopStoryManifest, StoryOrchestrator, StoryReadyResult, StorySelectionRequest } from '../storybook.js'; + +export class FakeStoryOrchestrator implements StoryOrchestrator { + readonly argUpdates: { args: Readonly>; storyId: string }[] = []; + + private readonly host: FakeDesktopHost; + private readonly manifest: DesktopStoryManifest; + private current: StoryReadyResult | null = null; + private generation = 0; + + constructor(manifest: DesktopStoryManifest, host: FakeDesktopHost) { + this.manifest = manifest; + this.host = host; + } + + async getCurrentStory(): Promise { + return this.current; + } + + async getManifest(): Promise { + return this.manifest; + } + + selectStory(request: StorySelectionRequest): Promise { + return this.select(request); + } + + resetStory(request: StorySelectionRequest): Promise { + return this.select(request); + } + + async updateArgs(storyId: string, args: Readonly>): Promise { + this.requireStory(storyId); + this.argUpdates.push({ args, storyId }); + } + + private async select(request: StorySelectionRequest): Promise { + this.requireStory(request.storyId); + this.host.resetPreview(); + this.current = { + previewGeneration: ++this.generation, + runId: request.runId, + storyId: request.storyId, + }; + this.host.setElementName('story-root', JSON.stringify(this.current)); + return this.current; + } + + private requireStory(storyId: string): void { + if (!this.manifest.entries.some(({ id }) => id === storyId)) { + throw new Error(`Story "${storyId}" is not present in the fake target manifest.`); + } + } +} diff --git a/packages/agentic/desktop-driver/src/testing/fakeStoryElements.test.ts b/packages/agentic/desktop-driver/src/testing/fakeStoryElements.test.ts new file mode 100644 index 00000000000..1df0143388f --- /dev/null +++ b/packages/agentic/desktop-driver/src/testing/fakeStoryElements.test.ts @@ -0,0 +1,40 @@ +import type { DesktopStoryManifest } from '../storybook.js'; +import { createFakeStoryWindows } from './fakeStoryElements.js'; + +describe('createFakeStoryWindows', () => { + test('derives initial semantic state without pre-applying post-action assertions', () => { + const manifest: DesktopStoryManifest = { + endpoint: 'windows', + entries: [ + { + id: 'components-checkbox--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/checkbox/checkbox.stories.tsx', + tags: ['desktop-e2e'], + tests: { + version: 1, + tests: [ + { + id: 'toggle', + steps: [ + { expect: { state: 'role', target: { testId: 'checkbox' }, value: 'checkbox' } }, + { expect: { state: 'checked', target: { testId: 'checkbox' }, value: false } }, + { action: 'click', target: { testId: 'checkbox' } }, + { expect: { state: 'checked', target: { testId: 'checkbox' }, value: true } }, + ], + }, + ], + }, + title: 'Components/Checkbox', + }, + ], + platformManifestDigest: 'platform', + portablePlanDigest: 'portable', + schemaVersion: 1, + }; + + const checkbox = createFakeStoryWindows(manifest)[0].elements.find(({ automationId }) => automationId === 'checkbox'); + expect(checkbox).toMatchObject({ checked: false, role: 'checkbox' }); + }); +}); diff --git a/packages/agentic/desktop-driver/src/testing/fakeStoryElements.ts b/packages/agentic/desktop-driver/src/testing/fakeStoryElements.ts new file mode 100644 index 00000000000..ba0b54fb43c --- /dev/null +++ b/packages/agentic/desktop-driver/src/testing/fakeStoryElements.ts @@ -0,0 +1,167 @@ +import type { DesktopStoryExpectation, DesktopStorySelector, DesktopStoryStep } from '../authoring/storyTests.js'; +import type { FakeDesktopElement, FakeDesktopWindow } from '../hosts/fake/FakeDesktopHost.js'; +import type { DesktopStoryManifest } from '../storybook.js'; + +type MutableFakeElement = Omit & { + rect: FakeDesktopElement['rect']; +}; + +export function createFakeStoryWindows(manifest: DesktopStoryManifest, storyRootTestId = 'story-root'): FakeDesktopWindow[] { + const windowId = 'window-1'; + const windowRect = { height: 600, width: 800, x: 0, y: 0 }; + const elements = new Map(); + let elementIndex = 0; + + for (const entry of manifest.entries) { + for (const test of entry.tests?.tests ?? []) { + const mutated = new Set(); + for (const step of test.steps) { + if ('expect' in step) { + applyExpectation(elements, step.expect, mutated, windowId, () => elementIndex++); + } else { + for (const selector of stepSelectors(step)) { + ensureElement(elements, selector, windowId, () => elementIndex++); + } + markMutated(step, mutated); + } + } + } + } + + return [ + { + elements: [ + { + automationId: 'app-root', + id: 'root', + rect: windowRect, + role: 'application', + scope: 'application', + windowId, + }, + { + automationId: storyRootTestId, + id: 'story-root', + name: JSON.stringify({ previewGeneration: 0, storyId: 'initial--story' }), + parentId: 'root', + rect: windowRect, + role: 'group', + scope: 'preview', + windowId, + }, + ...elements.values(), + ], + id: windowId, + rect: windowRect, + title: 'Desktop Driver Storybook Fake Target', + }, + ]; +} + +function applyExpectation( + elements: Map, + expectation: DesktopStoryExpectation, + mutated: ReadonlySet, + windowId: string, + nextIndex: () => number, +): void { + const element = ensureElement(elements, expectation.target, windowId, nextIndex); + if (mutated.has(selectorIdentity(expectation.target))) { + return; + } + switch (expectation.state) { + case 'accessibleName': + element.name = expectation.value as string; + break; + case 'checked': + element.checked = expectation.value as boolean | 'mixed'; + break; + case 'displayed': + element.visible = expectation.value === undefined ? true : (expectation.value as boolean); + break; + case 'enabled': + element.enabled = expectation.value === undefined ? true : (expectation.value as boolean); + break; + case 'expanded': + element.expanded = expectation.value === undefined ? true : (expectation.value as boolean); + break; + case 'focused': + element.focused = expectation.value === undefined ? true : (expectation.value as boolean); + break; + case 'role': + element.role = expectation.value as string; + break; + case 'selected': + element.selected = expectation.value === undefined ? true : (expectation.value as boolean); + break; + case 'text': + element.text = expectation.value as string; + break; + case 'value': + element.value = expectation.value as string; + break; + case 'count': + case 'exists': + break; + } +} + +function ensureElement( + elements: Map, + selector: DesktopStorySelector, + windowId: string, + nextIndex: () => number, +): MutableFakeElement { + const identity = selectorIdentity(selector); + const existing = elements.get(identity); + if (existing) { + return existing; + } + const index = nextIndex(); + const element: MutableFakeElement = { + id: `story-element-${index}`, + parentId: 'story-root', + rect: { height: 36, width: 180, x: 16, y: 16 + index * 44 }, + role: 'group', + scope: 'preview', + windowId, + ...('testId' in selector ? { automationId: selector.testId } : {}), + ...('accessibleName' in selector ? { name: selector.accessibleName } : {}), + ...('role' in selector ? { name: selector.name, role: selector.role } : {}), + ...('text' in selector ? { name: selector.text, text: selector.text } : {}), + }; + elements.set(identity, element); + return element; +} + +function selectorIdentity(selector: DesktopStorySelector): string { + if ('testId' in selector) { + return `testId:${selector.testId}`; + } + if ('role' in selector) { + return `role:${selector.role}:${selector.name ?? ''}`; + } + if ('accessibleName' in selector) { + return `name:${selector.accessibleName}`; + } + return `text:${selector.text}`; +} + +function stepSelectors(step: Exclude): DesktopStorySelector[] { + if ('target' in step && step.target) { + return [step.target]; + } + if (step.action === 'wait' && step.until) { + return [step.until.target]; + } + return []; +} + +function markMutated(step: Exclude, mutated: Set): void { + if (!('target' in step) || !step.target) { + return; + } + if (step.action === 'click' || step.action === 'doubleClick' || step.action === 'clear' || step.action === 'type') { + mutated.add(selectorIdentity(step.target)); + } +} diff --git a/packages/agentic/desktop-driver/src/testing/index.ts b/packages/agentic/desktop-driver/src/testing/index.ts new file mode 100644 index 00000000000..ce98587df67 --- /dev/null +++ b/packages/agentic/desktop-driver/src/testing/index.ts @@ -0,0 +1,6 @@ +export { FakeDesktopHost } from '../hosts/fake/FakeDesktopHost.js'; +export type { FakeDesktopElement, FakeDesktopHostOptions, FakeDesktopWindow } from '../hosts/fake/FakeDesktopHost.js'; +export { createDesktopDriverStoryHarness, createDesktopDriverTestHarness } from './protocolHarness.js'; +export type { DesktopDriverTestHarness } from './protocolHarness.js'; +export { FakeStoryOrchestrator } from './FakeStoryOrchestrator.js'; +export { createFakeStoryWindows } from './fakeStoryElements.js'; diff --git a/packages/agentic/desktop-driver/src/testing/protocolHarness.ts b/packages/agentic/desktop-driver/src/testing/protocolHarness.ts new file mode 100644 index 00000000000..a4afa9868ad --- /dev/null +++ b/packages/agentic/desktop-driver/src/testing/protocolHarness.ts @@ -0,0 +1,66 @@ +import { FakeDesktopHost } from '../hosts/fake/FakeDesktopHost.js'; +import type { FakeDesktopHostOptions } from '../hosts/fake/FakeDesktopHost.js'; +import type { DesktopTarget } from '../host/types.js'; +import { createDesktopDriverServer } from '../server/createDesktopDriverServer.js'; +import type { DesktopDriverServer } from '../server/createDesktopDriverServer.js'; +import type { StoryOrchestrator } from '../storybook.js'; +import type { DesktopStoryManifest } from '../storybook.js'; +import { FakeStoryOrchestrator } from './FakeStoryOrchestrator.js'; + +export type DesktopDriverTestHarness = { + host: FakeDesktopHost; + storyOrchestrator?: FakeStoryOrchestrator; + server: DesktopDriverServer; + target: DesktopTarget; + close(): Promise; +}; + +export async function createDesktopDriverTestHarness( + options: FakeDesktopHostOptions = {}, + storyOrchestrator?: StoryOrchestrator, +): Promise { + const host = new FakeDesktopHost(options); + const endpoint = options.endpoint ?? 'windows'; + const target: DesktopTarget = { + endpoint, + host, + id: `fake-${endpoint}`, + platformName: options.platformName ?? (endpoint === 'macos' ? 'macos' : 'windows'), + renderer: endpoint === 'win32' ? 'paper' : 'fabric', + ...(storyOrchestrator ? { storyRootTestId: 'story-root' } : {}), + storyOrchestrator, + }; + const server = await createDesktopDriverServer({ targets: [target] }); + return { + host, + server, + target, + close: () => server.close(), + }; +} + +export async function createDesktopDriverStoryHarness( + manifest: DesktopStoryManifest, + options: FakeDesktopHostOptions = {}, +): Promise { + const host = new FakeDesktopHost({ ...options, storyRootTestId: 'story-root' }); + const storyOrchestrator = new FakeStoryOrchestrator(manifest, host); + const endpoint = options.endpoint ?? manifest.endpoint; + const target: DesktopTarget = { + endpoint, + host, + id: `fake-${endpoint}`, + platformName: options.platformName ?? (endpoint === 'macos' ? 'macos' : 'windows'), + renderer: endpoint === 'win32' ? 'paper' : 'fabric', + storyOrchestrator, + storyRootTestId: 'story-root', + }; + const server = await createDesktopDriverServer({ targets: [target] }); + return { + host, + server, + storyOrchestrator, + target, + close: () => server.close(), + }; +} diff --git a/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.test.ts b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.test.ts new file mode 100644 index 00000000000..009709b7621 --- /dev/null +++ b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.test.ts @@ -0,0 +1,76 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { DesktopStoryManifest } from '../storybook.js'; +import { createDesktopDriverStoryHarness } from '../testing/protocolHarness.js'; + +describe('sanctioned WebdriverIO API', () => { + test('lists and runs an authored plan through registered browser commands', async () => { + const manifest: DesktopStoryManifest = { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/button/button.stories.tsx', + tags: ['e2e', 'story'], + tests: { + version: 1, + tests: [ + { + id: 'click', + steps: [ + { action: 'click', target: { testId: 'button-primary' } }, + { expect: { state: 'focused', target: { testId: 'button-primary' }, value: true } }, + ], + }, + ], + }, + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform-digest', + portablePlanDigest: 'portable-digest', + schemaVersion: 1, + }; + const harness = await createDesktopDriverStoryHarness(manifest); + const artifactsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-wdio-')); + try { + const response = await runContract(harness.server.url, harness.target.id, artifactsRoot); + expect(response).toMatchObject({ + manifestEntries: 1, + result: { + status: 'passed', + tests: [{ status: 'passed', storyId: 'components-button--default', testId: 'click' }], + }, + }); + expect(fs.existsSync(path.join(artifactsRoot, 'run.json'))).toBe(true); + } finally { + await harness.close(); + fs.rmSync(artifactsRoot, { force: true, recursive: true }); + } + }); +}); + +function runContract(url: string, targetId: string, artifactsRoot: string): Promise> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(__dirname, 'wdioRunner.contract.cjs'), url, targetId, artifactsRoot], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.once('error', reject); + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || `WebdriverIO runner exited with code ${code}.`)); + return; + } + resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')) as Record); + }); + }); +} diff --git a/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.ts b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.ts new file mode 100644 index 00000000000..76f796ae486 --- /dev/null +++ b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.ts @@ -0,0 +1,130 @@ +import { remote } from 'webdriverio'; + +import type { DesktopStoryRunResult } from '../authoring/results.js'; +import type { DesktopStoryExpectation } from '../authoring/storyTests.js'; +import { ArtifactManager } from '../artifacts/ArtifactManager.js'; +import { createDesktopDriverClient, DesktopSessionClient } from '../client/DesktopDriverClient.js'; +import type { DesktopClickMode, DesktopPlatformName } from '../protocol/types.js'; +import { assertDesktopExpectation, runDesktopStoryTests } from '../runner/StoryTestRunner.js'; +import type { DesktopStoryTestSelection } from '../runner/StoryTestRunner.js'; +import type { DesktopStoryManifest, StoryReadyResult } from '../storybook.js'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace WebdriverIO { + interface Capabilities { + 'furn:clickMode'?: 'accessibility' | 'auto' | 'physical'; + 'furn:endpoint'?: 'macos' | 'win32' | 'windows'; + 'furn:target'?: string; + } + + interface Browser { + desktopListStories(): Promise; + desktopOpenStory(storyId: string, runId?: string): Promise; + desktopResetStory(storyId: string, runId?: string): Promise; + desktopExpect(expectation: DesktopStoryExpectation): Promise; + desktopRunStoryTests(options?: DesktopWebdriverRunOptions): Promise; + } + } +} + +export type DesktopWebdriverOptions = { + clickMode?: DesktopClickMode; + logLevel?: 'debug' | 'error' | 'info' | 'silent' | 'trace' | 'warn'; + platformName: DesktopPlatformName; + targetId: string; + url: string; +}; + +export type DesktopWebdriverRunOptions = { + artifactsRoot?: string; + selection?: DesktopStoryTestSelection; + signal?: AbortSignal; +}; + +export class DesktopWebdriverSession { + readonly browser: WebdriverIO.Browser; + readonly session: DesktopSessionClient; + + constructor(browser: WebdriverIO.Browser, session: DesktopSessionClient) { + this.browser = browser; + this.session = session; + } + + listStories(): Promise { + return this.session.getStoryManifest(); + } + + openStory(storyId: string, runId?: string): Promise { + return this.session.selectStory(storyId, runId); + } + + resetStory(storyId: string, runId?: string): Promise { + return this.session.resetStory(storyId, runId); + } + + expect(expectation: DesktopStoryExpectation): Promise { + return assertDesktopExpectation(this.session, expectation); + } + + async runStoryTests(options: DesktopWebdriverRunOptions = {}): Promise { + const manifest = await this.session.getStoryManifest(); + const endpoint = this.session.capabilities['furn:endpoint']; + const platformName = this.session.capabilities.platformName; + const targetId = this.session.capabilities['furn:target']; + if ( + (endpoint !== 'macos' && endpoint !== 'windows' && endpoint !== 'win32') || + (platformName !== 'macos' && platformName !== 'windows') || + typeof targetId !== 'string' + ) { + throw new Error('Desktop Driver returned incomplete platform capabilities.'); + } + return runDesktopStoryTests({ + ...(options.artifactsRoot ? { artifacts: new ArtifactManager(options.artifactsRoot) } : {}), + endpoint, + manifest, + platformName, + selection: options.selection, + session: this.session, + signal: options.signal, + targetId, + }); + } + + async delete(): Promise { + await this.browser.deleteSession(); + } +} + +export async function connectDesktopWebdriver(options: DesktopWebdriverOptions): Promise { + const url = new URL(options.url); + const capabilities = Object.assign( + { + browserName: 'furn-native-desktop', + platformName: options.platformName, + }, + { + 'furn:clickMode': options.clickMode ?? 'auto', + 'furn:target': options.targetId, + }, + ); + const browser = await remote({ + capabilities, + hostname: url.hostname, + logLevel: options.logLevel ?? 'silent', + path: url.pathname === '/' ? '/' : url.pathname, + port: Number(url.port), + protocol: url.protocol.replace(':', '') as 'http' | 'https', + }); + const client = createDesktopDriverClient({ url: options.url }); + const session = new DesktopSessionClient(client, browser.sessionId, Object.fromEntries(Object.entries(browser.capabilities))); + const desktop = new DesktopWebdriverSession(browser, session); + + browser.addCommand('desktopListStories', () => desktop.listStories()); + browser.addCommand('desktopOpenStory', (storyId: string, runId?: string) => desktop.openStory(storyId, runId)); + browser.addCommand('desktopResetStory', (storyId: string, runId?: string) => desktop.resetStory(storyId, runId)); + browser.addCommand('desktopExpect', (expectation: DesktopStoryExpectation) => desktop.expect(expectation)); + browser.addCommand('desktopRunStoryTests', (runOptions?: DesktopWebdriverRunOptions) => desktop.runStoryTests(runOptions)); + + return desktop; +} diff --git a/packages/agentic/desktop-driver/src/wdio/index.ts b/packages/agentic/desktop-driver/src/wdio/index.ts new file mode 100644 index 00000000000..a520c7eda5c --- /dev/null +++ b/packages/agentic/desktop-driver/src/wdio/index.ts @@ -0,0 +1,2 @@ +export { connectDesktopWebdriver, DesktopWebdriverSession } from './DesktopWebdriver.js'; +export type { DesktopWebdriverOptions, DesktopWebdriverRunOptions } from './DesktopWebdriver.js'; diff --git a/packages/agentic/desktop-driver/src/wdio/wdioRunner.contract.cjs b/packages/agentic/desktop-driver/src/wdio/wdioRunner.contract.cjs new file mode 100644 index 00000000000..22c14b9010c --- /dev/null +++ b/packages/agentic/desktop-driver/src/wdio/wdioRunner.contract.cjs @@ -0,0 +1,30 @@ +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +async function main() { + const moduleUrl = pathToFileURL(path.resolve(__dirname, '..', '..', 'lib', 'wdio', 'index.js')).href; + const { connectDesktopWebdriver } = await import(moduleUrl); + const [url, targetId, artifactsRoot] = process.argv.slice(2); + const desktop = await connectDesktopWebdriver({ + platformName: 'windows', + targetId, + url, + }); + try { + const manifest = await desktop.browser.desktopListStories(); + await desktop.browser.desktopExpect({ + state: 'enabled', + target: { testId: 'button-primary' }, + value: true, + }); + const result = await desktop.browser.desktopRunStoryTests({ artifactsRoot }); + process.stdout.write(JSON.stringify({ manifestEntries: manifest.entries.length, result })); + } finally { + await desktop.delete(); + } +} + +main().catch((error) => { + process.stderr.write(error.stack ?? error.message); + process.exitCode = 1; +}); diff --git a/packages/agentic/desktop-driver/tsconfig.json b/packages/agentic/desktop-driver/tsconfig.json new file mode 100644 index 00000000000..ff8fae9384a --- /dev/null +++ b/packages/agentic/desktop-driver/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@fluentui-react-native/scripts/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "composite": true, + "tsBuildInfoFile": ".cache/tsconfig.tsbuildinfo" + }, + "include": ["src"], + "references": [ + { + "path": "../../../scripts/tsconfig.json" + } + ] +} diff --git a/packages/agentic/storybook-desktop-runtime/README.md b/packages/agentic/storybook-desktop-runtime/README.md new file mode 100644 index 00000000000..75350378555 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/README.md @@ -0,0 +1,21 @@ +# React Native Desktop Storybook Runtime + +Peer-dependent React Native UI and service implementation used by +`@fluentui-react-native/storybook-desktop`. + +Consumers should install and invoke `@fluentui-react-native/storybook-desktop`; +this package is separated so the CLI package remains peer-free and receives a +physical Yarn workspace locator. + +The runtime owns the device side of the Desktop Driver Storybook contract: + +- app and story-root native `testID` markers; +- app-manifest-derived test identity; +- nonce-authenticated runtime hello challenges; +- correlated story-ready and story-error events; +- request/run IDs and preview generations; +- keyed per-test remount and render-error isolation. + +It does not own W3C routing, WebdriverIO, test execution, evidence persistence, +or native accessibility/input/screenshot providers. Those responsibilities +belong to `@fluentui-react-native/desktop-driver`. diff --git a/packages/agentic/storybook-desktop-runtime/config/metro.cjs b/packages/agentic/storybook-desktop-runtime/config/metro.cjs new file mode 100644 index 00000000000..0eaa13ed9e2 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/config/metro.cjs @@ -0,0 +1,125 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { makeMetroConfig } = require('@rnx-kit/metro-config'); +const MetroSymlinksResolver = require('@rnx-kit/metro-resolver-symlinks'); +const { withStorybook } = require('@storybook/react-native/withStorybook'); + +const safeAreaStub = path.resolve(__dirname, 'react-native-safe-area-context.cjs'); + +function resolvePlatformModule(moduleName, platform) { + if (platform !== 'win32') { + return moduleName; + } + + if (moduleName === 'react-native') { + return '@office-iss/react-native-win32'; + } + + if (moduleName.startsWith('react-native/')) { + return `@office-iss/react-native-win32/${moduleName.slice('react-native/'.length)}`; + } + + return moduleName; +} + +function createDesktopStorybookMetroConfig({ configPath }) { + if (!configPath) { + throw new TypeError('createDesktopStorybookMetroConfig requires an app-owned Storybook configPath.'); + } + + const runtimeModulePath = writeRuntimeInstanceModule(path.dirname(configPath)); + const symlinkResolver = MetroSymlinksResolver({ + resolver: 'oxc-resolver', + }); + const config = makeMetroConfig({ + resolver: { + resolveRequest: (context, moduleName, platform) => { + // Storybook's path-based lite-mode mock does not recognize Yarn's virtual pnpm paths. + if (moduleName === '@storybook/react-native-ui' || moduleName.startsWith('@storybook/react-native-ui/')) { + return { type: 'empty' }; + } + if (moduleName === 'react-native-safe-area-context') { + return { type: 'sourceFile', filePath: safeAreaStub }; + } + return symlinkResolver(context, resolvePlatformModule(moduleName, platform), platform); + }, + unstable_enablePackageExports: true, + unstable_conditionNames: ['react-native', 'import', 'require'], + disableHierarchicalLookup: true, + enableSymlinks: true, + }, + transformer: { + unstable_allowRequireContext: true, + }, + }); + + const storybookConfig = withStorybook(config, { + configPath, + liteMode: true, + }); + const getPolyfills = storybookConfig.serializer?.getPolyfills; + + return { + ...storybookConfig, + serializer: { + ...storybookConfig.serializer, + getPolyfills: (...args) => [...(getPolyfills?.(...args) ?? []), runtimeModulePath], + }, + }; +} + +function writeRuntimeInstanceModule(projectRoot) { + const generatedDirectory = path.join(projectRoot, 'storybook-desktop.generated'); + const runtimeModulePath = path.join(generatedDirectory, 'runtime-instance.js'); + const storybookPort = readPort(process.env.STORYBOOK_WS_PORT, 7007); + const instanceId = process.env.FURN_STORYBOOK_INSTANCE_ID || 'default'; + const driverManifest = readDriverManifest(process.env.STORYBOOK_DRIVER_MANIFEST); + const runtimeInstance = { + instanceId, + storybookPort, + ...(driverManifest + ? { + bridgeNonce: driverManifest.bridgeNonce, + endpoint: driverManifest.endpoint, + platformManifestDigest: driverManifest.platformManifestDigest, + portablePlanDigest: driverManifest.portablePlanDigest, + targetId: driverManifest.targetId, + testIDPrefix: driverManifest.testIDPrefix, + } + : {}), + }; + const content = `globalThis.__FURN_DESKTOP_STORYBOOK_INSTANCE__ = Object.freeze(${JSON.stringify(runtimeInstance)});\n`; + + fs.mkdirSync(generatedDirectory, { recursive: true }); + if (!fs.existsSync(runtimeModulePath) || fs.readFileSync(runtimeModulePath, 'utf8') !== content) { + fs.writeFileSync(runtimeModulePath, content); + } + + return runtimeModulePath; +} + +function readDriverManifest(manifestPath) { + if (!manifestPath) { + return undefined; + } + if (!fs.existsSync(manifestPath)) { + throw new Error(`Desktop Driver manifest does not exist at ${manifestPath}.`); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (manifest.schemaVersion !== 1) { + throw new Error(`Unsupported Desktop Driver manifest schema "${manifest.schemaVersion}".`); + } + return manifest; +} + +function readPort(value, fallback) { + const port = Number(value) || fallback; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new RangeError(`Invalid Storybook port "${value}".`); + } + return port; +} + +module.exports = { + createDesktopStorybookMetroConfig, +}; diff --git a/packages/agentic/storybook-desktop-runtime/config/react-native-safe-area-context.cjs b/packages/agentic/storybook-desktop-runtime/config/react-native-safe-area-context.cjs new file mode 100644 index 00000000000..946a102de07 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/config/react-native-safe-area-context.cjs @@ -0,0 +1,22 @@ +const React = require('react'); +const { View } = require('react-native'); + +const insets = { top: 0, right: 0, bottom: 0, left: 0 }; +const frame = { x: 0, y: 0, width: 0, height: 0 }; + +const SafeAreaInsetsContext = React.createContext(insets); +const SafeAreaFrameContext = React.createContext(frame); + +const SafeAreaProvider = ({ children }) => React.createElement(View, { style: { flex: 1 } }, children); +const SafeAreaView = React.forwardRef((props, ref) => React.createElement(View, { ref, ...props })); + +module.exports = { + SafeAreaProvider, + SafeAreaConsumer: SafeAreaInsetsContext.Consumer, + SafeAreaInsetsContext, + SafeAreaFrameContext, + SafeAreaView, + useSafeAreaInsets: () => insets, + useSafeAreaFrame: () => frame, + initialWindowMetrics: { insets, frame }, +}; diff --git a/packages/agentic/storybook-desktop-runtime/package.json b/packages/agentic/storybook-desktop-runtime/package.json new file mode 100644 index 00000000000..aede3eefa63 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/package.json @@ -0,0 +1,106 @@ +{ + "name": "@fluentui-react-native/storybook-desktop-runtime", + "version": "0.1.0", + "description": "React Native runtime and Metro services for desktop Storybook applications", + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui-react-native.git", + "directory": "packages/agentic/storybook-desktop-runtime" + }, + "type": "module", + "main": "lib/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "react-native": "./src/index.ts", + "import": "./lib/index.js", + "default": "./src/index.ts" + }, + "./metro": "./config/metro.cjs", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc -b", + "clean": "fluentui-scripts clean", + "format": "fluentui-scripts format", + "lint": "fluentui-scripts lint" + }, + "dependencies": { + "@fluentui-react-native/callout": "workspace:*", + "@fluentui-react-native/default-theme": "workspace:*", + "@fluentui-react-native/design": "workspace:*", + "@rnx-kit/metro-config": "catalog:", + "@rnx-kit/metro-resolver-symlinks": "catalog:", + "@storybook/react-native": "^10.4.7", + "@storybook/react-native-theming": "^10.4.7", + "@storybook/react-native-ui-common": "^10.4.7", + "@storybook/react-native-ui-lite": "^10.4.7", + "oxc-resolver": "catalog:", + "storybook": "^10.4.0" + }, + "devDependencies": { + "@babel/core": "catalog:", + "@fluentui-react-native/scripts": "workspace:*", + "@office-iss/react-native-win32": "^0.81.0", + "@react-native/babel-preset": "^0.81.0", + "@types/react": "~19.1.4", + "react": "19.1.4", + "react-native": "^0.81.6", + "react-native-macos": "^0.81.0", + "react-native-windows": "^0.81.0" + }, + "peerDependencies": { + "@office-iss/react-native-win32": "^0.81.0", + "@types/react": "~19.1.4", + "react": "19.1.4", + "react-native": "^0.81.6", + "react-native-macos": "^0.81.0", + "react-native-windows": "^0.81.0" + }, + "peerDependenciesMeta": { + "@office-iss/react-native-win32": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react-native-macos": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + }, + "furn": { + "knip": { + "ignoreDependencies": [ + "@fluentui-react-native/callout" + ] + } + }, + "rnx-kit": { + "kitType": "library", + "alignDeps": { + "requirements": { + "development": [ + "react-native@0.81" + ], + "production": [ + "react-native@0.81" + ] + }, + "capabilities": [ + "babel-preset-react-native", + "core", + "core-macos", + "core-win32", + "core-windows" + ] + }, + "extends": "@fluentui-react-native/scripts/kit-config" + } +} diff --git a/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.tsx b/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.tsx new file mode 100644 index 00000000000..ad39652321f --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { addons } from 'storybook/preview-api'; + +import { useDesktopStorybookConfig } from './DesktopStorybookConfig'; + +export const desktopPrepareStoryEvent = 'furn:desktop:prepare-story'; +export const desktopRequestHelloEvent = 'furn:desktop:request-hello'; +export const desktopRuntimeHelloEvent = 'furn:desktop:hello'; +export const desktopStoryErrorEvent = 'furn:desktop:story-error'; +export const desktopStoryReadyEvent = 'furn:desktop:story-ready'; + +type PrepareStoryPayload = { + requestId: string; + runId: string; + storyId: string; +}; + +export function DesktopDriverBridge(): null { + const { prepareStory, runtimeInstance } = useDesktopStorybookConfig(); + + React.useEffect(() => { + if ( + !runtimeInstance?.bridgeNonce || + !runtimeInstance.endpoint || + !runtimeInstance.instanceId || + !runtimeInstance.platformManifestDigest || + !runtimeInstance.targetId + ) { + return undefined; + } + const channel = addons.getChannel(); + const emitHello = () => { + channel.emit(desktopRuntimeHelloEvent, { + endpoint: runtimeInstance.endpoint, + instanceId: runtimeInstance.instanceId, + nonce: runtimeInstance.bridgeNonce, + platformManifestDigest: runtimeInstance.platformManifestDigest, + targetId: runtimeInstance.targetId, + version: 1, + }); + }; + const onPrepare = (payload: PrepareStoryPayload) => { + if (isPrepareStoryPayload(payload)) { + prepareStory(payload); + } + }; + + channel.on(desktopPrepareStoryEvent, onPrepare); + channel.on(desktopRequestHelloEvent, emitHello); + emitHello(); + return () => { + channel.off(desktopPrepareStoryEvent, onPrepare); + channel.off(desktopRequestHelloEvent, emitHello); + }; + }, [prepareStory, runtimeInstance]); + + return null; +} + +function isPrepareStoryPayload(value: unknown): value is PrepareStoryPayload { + if (!value || typeof value !== 'object') { + return false; + } + const payload = value as Record; + return typeof payload.requestId === 'string' && typeof payload.runId === 'string' && typeof payload.storyId === 'string'; +} diff --git a/packages/agentic/storybook-desktop-runtime/src/DesktopStoryRoot.tsx b/packages/agentic/storybook-desktop-runtime/src/DesktopStoryRoot.tsx new file mode 100644 index 00000000000..6bd6ece9796 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/DesktopStoryRoot.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { addons } from 'storybook/preview-api'; + +import { desktopStoryErrorEvent, desktopStoryReadyEvent } from './DesktopDriverBridge'; +import { useDesktopStorybookConfig, useDesktopStorybookTestID } from './DesktopStorybookConfig'; + +type DesktopStoryRootProps = { + children: React.ReactNode; + storyId: string; +}; + +export function DesktopStoryRoot({ children, storyId }: DesktopStoryRootProps) { + const { runtimeInstance, selection } = useDesktopStorybookConfig(); + const testID = useDesktopStorybookTestID('story-root'); + const activeSelection = selection?.storyId === storyId ? selection : undefined; + + React.useEffect(() => { + if (!activeSelection || !runtimeInstance?.portablePlanDigest) { + return; + } + addons.getChannel().emit(desktopStoryReadyEvent, { + portablePlanDigest: runtimeInstance.portablePlanDigest, + previewGeneration: activeSelection.previewGeneration, + requestId: activeSelection.requestId, + runId: activeSelection.runId, + storyId, + }); + }, [activeSelection, runtimeInstance?.portablePlanDigest, storyId]); + + const marker = JSON.stringify({ + previewGeneration: activeSelection?.previewGeneration ?? 0, + runId: activeSelection?.runId, + storyId, + }); + const key = activeSelection ? `${activeSelection.runId}:${activeSelection.previewGeneration}` : storyId; + + return ( + { + if (activeSelection) { + addons.getChannel().emit(desktopStoryErrorEvent, { + message, + requestId: activeSelection.requestId, + runId: activeSelection.runId, + storyId, + }); + } + }} + > + + {children} + + + ); +} + +type StoryRenderErrorBoundaryProps = { + children: React.ReactNode; + onError(message: string): void; +}; + +type StoryRenderErrorBoundaryState = { + error?: Error; +}; + +class StoryRenderErrorBoundary extends React.Component { + override state: StoryRenderErrorBoundaryState = {}; + + static getDerivedStateFromError(error: Error): StoryRenderErrorBoundaryState { + return { error }; + } + + override componentDidCatch(error: Error): void { + this.props.onError(error.message); + } + + override render(): React.ReactNode { + return this.state.error ? null : this.props.children; + } +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, +}); diff --git a/packages/agentic/storybook-desktop-runtime/src/DesktopStorybookConfig.tsx b/packages/agentic/storybook-desktop-runtime/src/DesktopStorybookConfig.tsx new file mode 100644 index 00000000000..75c612775b3 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/DesktopStorybookConfig.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +export type DesktopRuntimeSelection = { + previewGeneration: number; + requestId: string; + runId: string; + storyId: string; +}; + +export type DesktopStorybookRuntimeInstance = { + bridgeNonce?: string; + endpoint?: 'macos' | 'windows' | 'win32'; + instanceId?: string; + platformManifestDigest?: string; + portablePlanDigest?: string; + storybookPort?: number; + targetId?: string; + testIDPrefix?: string; +}; + +type DesktopStorybookConfig = { + prepareStory(selection: Omit): void; + runtimeInstance?: DesktopStorybookRuntimeInstance; + selection?: DesktopRuntimeSelection; + testIDPrefix: string; +}; + +const defaultConfig: DesktopStorybookConfig = { + prepareStory: () => undefined, + testIDPrefix: 'storybook-desktop', +}; + +const DesktopStorybookConfigContext = React.createContext(defaultConfig); + +export function DesktopStorybookConfigProvider({ + children, + runtimeInstance, + testIDPrefix, +}: React.PropsWithChildren>) { + const [selection, setSelection] = React.useState(); + const prepareStory = React.useCallback((next: Omit) => { + setSelection((current) => ({ ...next, previewGeneration: (current?.previewGeneration ?? 0) + 1 })); + }, []); + const value = React.useMemo( + () => ({ prepareStory, runtimeInstance, selection, testIDPrefix }), + [prepareStory, runtimeInstance, selection, testIDPrefix], + ); + return {children}; +} + +export function useDesktopStorybookConfig(): DesktopStorybookConfig { + return React.useContext(DesktopStorybookConfigContext); +} + +export function useDesktopStorybookTestID(suffix: string): string { + const { testIDPrefix } = useDesktopStorybookConfig(); + return `${testIDPrefix}-${suffix}`; +} diff --git a/apps/storybook/src/StorybookTheme.tsx b/packages/agentic/storybook-desktop-runtime/src/StorybookTheme.tsx similarity index 92% rename from apps/storybook/src/StorybookTheme.tsx rename to packages/agentic/storybook-desktop-runtime/src/StorybookTheme.tsx index 26a1ff241ce..26e1c037ca7 100644 --- a/apps/storybook/src/StorybookTheme.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookTheme.tsx @@ -5,6 +5,8 @@ import { createDefaultTheme } from '@fluentui-react-native/default-theme'; import { ThemeProvider } from '@fluentui-react-native/design/theming'; import type { ThemeReference } from '@fluentui-react-native/design/theming'; +import { useDesktopStorybookTestID } from './DesktopStorybookConfig'; + type ThemeChoice = { label: string; theme?: ThemeReference; @@ -25,11 +27,13 @@ const StorybookThemeContext = React.createContext(un export function StorybookThemeHost({ children }: React.PropsWithChildren) { const [selectedName, setSelectedName] = React.useState('none'); const selectedTheme = themeChoices[selectedName].theme; + const toolbarTestID = useDesktopStorybookTestID('theme-toolbar'); + const optionTestID = useDesktopStorybookTestID('theme'); return ( - + Theme {themeChoiceNames.map((name) => { const choice = themeChoices[name]; @@ -41,7 +45,7 @@ export function StorybookThemeHost({ children }: React.PropsWithChildren) { key={name} onPress={() => setSelectedName(name)} style={({ pressed }) => [styles.option, selected && styles.selectedOption, pressed && styles.pressedOption]} - testID={`agentic-storybook-theme-${name}`} + testID={`${optionTestID}-${name}`} > {choice.label} diff --git a/apps/storybook/src/StorybookUI.ts b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.ts similarity index 100% rename from apps/storybook/src/StorybookUI.ts rename to packages/agentic/storybook-desktop-runtime/src/StorybookUI.ts diff --git a/apps/storybook/src/StorybookUI.win32.tsx b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx similarity index 83% rename from apps/storybook/src/StorybookUI.win32.tsx rename to packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx index 1a44f263b59..e6cd22e00fe 100644 --- a/apps/storybook/src/StorybookUI.win32.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookUI.win32.tsx @@ -6,6 +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 { Win32AddonsPanel } from './Win32AddonsPanel'; import { Win32CalloutPortal } from './Win32CalloutPortal'; import { Win32ResizeHandle } from './Win32ResizeHandle'; @@ -22,6 +23,21 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, const [addonsHeight, setAddonsHeight] = React.useState(280); const [storyDrawerOpen, setStoryDrawerOpen] = React.useState(false); const [addonsPanelOpen, setAddonsPanelOpen] = React.useState(false); + const chromeTestID = useDesktopStorybookTestID('win32-chrome'); + const sidebarTestID = useDesktopStorybookTestID('win32-sidebar'); + const sidebarResizeTestID = useDesktopStorybookTestID('win32-sidebar-resize'); + const toolbarTestID = useDesktopStorybookTestID('win32-desktop-toolbar'); + const showSidebarTestID = useDesktopStorybookTestID('win32-show-sidebar'); + const popoutStoriesTestID = useDesktopStorybookTestID('win32-popout-stories'); + const showAddonsTestID = useDesktopStorybookTestID('win32-show-addons'); + const popoutAddonsTestID = useDesktopStorybookTestID('win32-popout-addons'); + const previewTestID = useDesktopStorybookTestID('win32-preview'); + const addonsResizeTestID = useDesktopStorybookTestID('win32-addons-resize'); + const inlineAddonsTestID = useDesktopStorybookTestID('win32-inline-addons'); + const storyDrawerTestID = useDesktopStorybookTestID('win32-story-drawer'); + const closeStoriesTestID = useDesktopStorybookTestID('win32-close-stories'); + const addonsDrawerTestID = useDesktopStorybookTestID('win32-addons-drawer'); + const hideSidebarTestID = useDesktopStorybookTestID('win32-hide-sidebar'); const setSelection = React.useCallback( (selection: Selection) => { @@ -51,7 +67,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, return ( - + {sidebarVisible ? ( <> - + Stories setSidebarVisible(false)} style={styles.headerButton} - testID="agentic-storybook-win32-hide-sidebar" + testID={hideSidebarTestID} > Hide @@ -94,7 +105,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, /> - + ) : null} @@ -103,13 +114,13 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, accessibilityLabel="Storybook desktop toolbar" accessible style={[styles.toolbar, { backgroundColor: theme.background.content, borderBottomColor: theme.appBorderColor }]} - testID="agentic-storybook-win32-desktop-toolbar" + testID={toolbarTestID} > setSidebarVisible(true)} style={[styles.toolbarButton, { backgroundColor: theme.button.background, borderColor: theme.appBorderColor }]} - testID="agentic-storybook-win32-show-sidebar" + testID={showSidebarTestID} > Show stories @@ -125,7 +136,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, opacity: pressed ? 0.75 : 1, }, ]} - testID="agentic-storybook-win32-popout-stories" + testID={popoutStoriesTestID} > Pop out stories @@ -134,7 +145,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, accessibilityRole="button" onPress={() => setAddonsVisible(true)} style={[styles.toolbarButton, { backgroundColor: theme.button.background, borderColor: theme.appBorderColor }]} - testID="agentic-storybook-win32-show-addons" + testID={showAddonsTestID} > Show addons @@ -154,7 +165,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, opacity: pressed ? 0.75 : 1, }, ]} - testID="agentic-storybook-win32-popout-addons" + testID={popoutAddonsTestID} > Pop out addons @@ -163,12 +174,12 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, ) : null} - + {children} {addonsVisible ? ( <> - + setAddonsVisible(false)} parameters={story?.parameters} storyId={story?.id} /> @@ -190,7 +201,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, height={600} onDismiss={closeStoryDrawer} target={storiesAnchorRef} - testID="agentic-storybook-win32-story-drawer" + testID={storyDrawerTestID} visible={storyDrawerOpen} width={360} > @@ -201,7 +212,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, accessibilityRole="button" onPress={closeStoryDrawer} style={styles.closeButton} - testID="agentic-storybook-win32-close-stories" + testID={closeStoriesTestID} > Close @@ -224,7 +235,7 @@ export const StorybookUIComponent: SBUI = ({ children, setStory, storage, story, height={480} onDismiss={closeAddonsDrawer} target={addonsAnchorRef} - testID="agentic-storybook-win32-addons-drawer" + testID={addonsDrawerTestID} visible={addonsPanelOpen} width={520} > diff --git a/apps/storybook/src/Win32AddonsPanel.tsx b/packages/agentic/storybook-desktop-runtime/src/Win32AddonsPanel.tsx similarity index 89% rename from apps/storybook/src/Win32AddonsPanel.tsx rename to packages/agentic/storybook-desktop-runtime/src/Win32AddonsPanel.tsx index e62f1f7f336..b39b2d6a99c 100644 --- a/apps/storybook/src/Win32AddonsPanel.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/Win32AddonsPanel.tsx @@ -7,6 +7,8 @@ import { Addon_TypesEnum } from 'storybook/internal/types'; import type { Addon_BaseType, Addon_Collection } from 'storybook/internal/types'; import { addons } from 'storybook/manager-api'; +import { useDesktopStorybookTestID } from './DesktopStorybookConfig'; + type Win32AddonsPanelProps = { onClose: () => void; parameters?: Parameters; @@ -19,6 +21,9 @@ function preferredPanelId(panels: [string, Addon_BaseType][]) { export function Win32AddonsPanel({ onClose, parameters, storyId }: Win32AddonsPanelProps) { const theme = useTheme(); + const panelTestID = useDesktopStorybookTestID('win32-addons-panel'); + const addonTestID = useDesktopStorybookTestID('win32-addon'); + const closeTestID = useDesktopStorybookTestID('win32-close-addons'); const panels = React.useMemo(() => { const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); return Object.entries(allPanels).filter(([, panel]) => !panel.paramKey || !parameters?.[panel.paramKey]?.disable); @@ -33,12 +38,12 @@ export function Win32AddonsPanel({ onClose, parameters, storyId }: Win32AddonsPa }, [panels, selectedPanelId]); return ( - + {String(title)} @@ -76,7 +81,7 @@ export function Win32AddonsPanel({ onClose, parameters, storyId }: Win32AddonsPa accessibilityRole="button" onPress={onClose} style={styles.closeButton} - testID="agentic-storybook-win32-close-addons" + testID={closeTestID} > Close diff --git a/apps/storybook/src/Win32CalloutPortal.tsx b/packages/agentic/storybook-desktop-runtime/src/Win32CalloutPortal.tsx similarity index 100% rename from apps/storybook/src/Win32CalloutPortal.tsx rename to packages/agentic/storybook-desktop-runtime/src/Win32CalloutPortal.tsx diff --git a/apps/storybook/src/Win32ResizeHandle.tsx b/packages/agentic/storybook-desktop-runtime/src/Win32ResizeHandle.tsx similarity index 100% rename from apps/storybook/src/Win32ResizeHandle.tsx rename to packages/agentic/storybook-desktop-runtime/src/Win32ResizeHandle.tsx diff --git a/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookApp.tsx b/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookApp.tsx new file mode 100644 index 00000000000..35ef0546c56 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookApp.tsx @@ -0,0 +1,72 @@ +import type * as React from 'react'; +import { StyleSheet, View as NativeView } from 'react-native'; +import type { View } from '@storybook/react-native'; + +import { DesktopDriverBridge } from './DesktopDriverBridge'; +import { DesktopStorybookConfigProvider, useDesktopStorybookTestID } from './DesktopStorybookConfig'; +import type { DesktopStorybookRuntimeInstance } from './DesktopStorybookConfig'; +import { StorybookThemeHost } from './StorybookTheme'; +import { StorybookUIComponent } from './StorybookUI'; + +export type DesktopStorybookOptions = { + enableWebsockets?: boolean; + host?: string; + port?: number; + testIDPrefix?: string; +}; + +export function createDesktopStorybookApp( + view: Pick, + { enableWebsockets = true, host = '127.0.0.1', port, testIDPrefix = 'storybook-desktop' }: DesktopStorybookOptions = {}, +) { + const runtimeInstance = ( + globalThis as typeof globalThis & { + __FURN_DESKTOP_STORYBOOK_INSTANCE__?: DesktopStorybookRuntimeInstance; + } + ).__FURN_DESKTOP_STORYBOOK_INSTANCE__; + const memoryStore: Record = {}; + const storage = { + getItem: async (key: string) => (key in memoryStore ? memoryStore[key] : null), + setItem: async (key: string, value: string) => { + memoryStore[key] = value; + }, + }; + const StorybookUI = view.getStorybookUI({ + enableWebsockets, + host, + port: port ?? runtimeInstance?.storybookPort ?? 7007, + CustomUIComponent: StorybookUIComponent, + storage, + }); + + function DesktopStorybookApp() { + const resolvedTestIDPrefix = runtimeInstance?.testIDPrefix ?? testIDPrefix; + return ( + + + + + + + + + ); + } + + return DesktopStorybookApp; +} + +function DesktopStorybookAppRoot({ children }: React.PropsWithChildren) { + const testID = useDesktopStorybookTestID('app-root'); + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, +}); diff --git a/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookPreview.tsx b/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookPreview.tsx new file mode 100644 index 00000000000..78824c3677d --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/createDesktopStorybookPreview.tsx @@ -0,0 +1,31 @@ +import { StyleSheet, View } from 'react-native'; +import type { Preview } from '@storybook/react-native'; + +import { DesktopStoryRoot } from './DesktopStoryRoot'; +import { StorybookThemeProvider } from './StorybookTheme'; + +export function createDesktopStorybookPreview(): Preview { + return { + decorators: [ + (Story, context) => ( + + + + + + + + ), + ], + parameters: {}, + }; +} + +const styles = StyleSheet.create({ + story: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 16, + }, +}); diff --git a/packages/agentic/storybook-desktop-runtime/src/index.ts b/packages/agentic/storybook-desktop-runtime/src/index.ts new file mode 100644 index 00000000000..45af304c309 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/index.ts @@ -0,0 +1,12 @@ +export { createDesktopStorybookApp } from './createDesktopStorybookApp'; +export type { DesktopStorybookOptions } from './createDesktopStorybookApp'; +export { createDesktopStorybookPreview } from './createDesktopStorybookPreview'; +export { + desktopPrepareStoryEvent, + desktopRequestHelloEvent, + desktopRuntimeHelloEvent, + desktopStoryErrorEvent, + desktopStoryReadyEvent, +} from './DesktopDriverBridge'; +export { DesktopStoryRoot } from './DesktopStoryRoot'; +export type { DesktopRuntimeSelection, DesktopStorybookRuntimeInstance } from './DesktopStorybookConfig'; diff --git a/packages/agentic/storybook-desktop-runtime/tsconfig.json b/packages/agentic/storybook-desktop-runtime/tsconfig.json new file mode 100644 index 00000000000..d7156beca43 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@fluentui-react-native/scripts/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "composite": true, + "tsBuildInfoFile": ".cache/tsconfig.tsbuildinfo" + }, + "include": ["src"], + "references": [ + { + "path": "../../native/Callout/tsconfig.json" + }, + { + "path": "../../theming/default-theme/tsconfig.json" + }, + { + "path": "../design/tsconfig.json" + }, + { + "path": "../../../scripts/tsconfig.json" + } + ] +} diff --git a/packages/agentic/storybook-desktop/README.md b/packages/agentic/storybook-desktop/README.md new file mode 100644 index 00000000000..9c61c7f002a --- /dev/null +++ b/packages/agentic/storybook-desktop/README.md @@ -0,0 +1,222 @@ +# React Native Desktop Storybook + +Reusable CLI and configuration for Fluent UI React Native desktop test apps. Together with the companion runtime, it provides: + +- the macOS and Windows Lite UI shell; +- the Win32 Paper desktop chrome and Callout-backed pop-outs; +- a Fluent theme toolbar and preview decorator; +- Metro and Babel configuration for the repo's pnpm-linked desktop hosts; and +- the standalone Storybook channel and MCP server; +- generated platform Story Manifests and authenticated runtime readiness; and +- an embedded W3C Desktop Driver listener in the same server process; and +- a Commander CLI and matching API for serving, native preparation, bundling, builds, launches, and smoke tests. + +The React Native implementation lives in the companion +`@fluentui-react-native/storybook-desktop-runtime` package. Keeping its React +and React Native peers out of this package gives the CLI a physical Yarn +workspace locator, so `storybook-desktop` and `storybook-server` binaries work +with Yarn's pnpm linker instead of resolving through an unmaterialized virtual +workspace path. + +Consuming apps own their native identity in `app.json`, story globs, generated `storybook.requires` file, component +dependencies, and exceptional platform automation. A root `storybook.config.ts` declares which packages supply stories +and overrides only platform behavior that cannot use the shared defaults: + +```ts +import { + createWindowsSmokeOptions, + createWin32RunCommand, + createWin32SmokeCommand, + makeDesktopStorybookConfig, +} from '@fluentui-react-native/storybook-desktop/config'; + +const win32Host = { + component: 'MyStorybook', + windowTitle: 'My Storybook (Win32)', +} as const; + +export default makeDesktopStorybookConfig({ + projectRoot: new URL('.', import.meta.url), + storyPackages: [ + '@scope/components', + [ + '@scope/native-component', + { + platforms: ['macos', 'windows'], + }, + ], + ], + platformOptions: { + windows: { + smoke: createWindowsSmokeOptions({ + windowTitle: 'My Storybook', + }), + }, + win32: { + run: createWin32RunCommand(win32Host), + smoke: { + command: createWin32SmokeCommand({ + ...win32Host, + testIDPrefix: 'my-storybook', + }), + }, + }, + }, +}); +``` + +The corresponding React Native Test App manifest supplies native identity: + +```json +{ + "name": "MyStorybook", + "displayName": "My Storybook", + "macos": { + "bundleIdentifier": "com.example.my-storybook" + }, + "storybook": { + "testIDPrefix": "my-storybook" + } +} +``` + +The app's `src/main.ts` becomes a small adapter: + +```ts +import config from '../storybook.config.ts'; + +export default config.getStorybookConfig(); +``` + +The returned `DesktopStorybookConfig` resolves package roots lazily and exposes app identity, display name, the custom +`storybook.testIDPrefix` field, package metadata, resolved story packages, and generated story globs for CLI and test +orchestration. A config-level `testIDPrefix` remains available as an explicit override for consumers that do not store +Storybook identity in `app.json`. + +## CLI and API + +The `storybook-desktop` binary loads `storybook.config.ts`, `.mts`, `.js`, `.mjs`, or `.cjs` from the current package. +Use `.mts` when the consuming package otherwise defaults JavaScript files to CommonJS. Select a target with a short +platform option, or omit it to use `FURN_STORYBOOK_PLATFORM` and then the host default: + +```sh +storybook-desktop server --win32 +storybook-desktop driver --windows +storybook-desktop manifest --windows +storybook-desktop instance --windows +storybook-desktop prep --macos +storybook-desktop bundle --windows +storybook-desktop build --macos +storybook-desktop run --windows +storybook-desktop smoke --win32 --mode stories +storybook-desktop smoke --windows --mode stories-and-tests +``` + +Use `--config ` for a differently named configuration file. `prep` installs CocoaPods on macOS, generates the +React Native Test App solution on Windows, and is a no-op for the prebuilt Win32 host. `bundle` generates the selected +story catalog and routes to `rnx-cli bundle`. `build` and `run` route to `rnx-cli` by default using native project names +derived from the app manifest. The config supplies default macOS workspace/scheme and Windows solution arguments from +the app key, and reads the macOS bundle identifier directly from `app.json`. Win32 has no native project to build, so +its default build and run operations are unsupported until the consumer provides a prebuilt-host launch command. + +`server` loads the same config, selects the matching platform catalog, and derives the app-owned Storybook config +directory automatically. It accepts `--host` and `--port`; the separate `storybook-server` binary is a convenience +alias for this subcommand. Consumer package scripts should forward arguments rather than define one server alias per +platform. See [`src/cli/README.md`](src/cli/README.md) for the recommended minimal scripts and development, E2E, CI, +and agent workflows. + +`manifest` statically extracts the selected platform's stories and serializable +`parameters.desktopDriver` plans, then writes exact-platform and portable-plan +digests. `instance` prints the enlistment-specific channel, Metro, and driver +identity. `driver` starts the Storybook channel/MCP server and the W3C Desktop +Driver listener on separate loopback ports in one Node process. The initial +target uses the deterministic fake host; native providers are a later stage. + +Component authors tag portable plans with `desktop-e2e`. Button, Checkbox, and +Input provide the initial examples. Plan extraction evaluates only the inline +static `desktopDriver` literal and supports TypeScript `satisfies`; dynamic +values fail with source context instead of being omitted. + +Run the resulting plans through the consuming app's Desktop Driver CLI: + +```sh +yarn desktop-driver stories list \ + --url http://127.0.0.1: \ + --target + +yarn desktop-driver stories run \ + --url http://127.0.0.1: \ + --target \ + --tag desktop-e2e \ + --artifacts artifacts//desktop-driver +``` + +The `driver` startup output and `instance` command report the driver port and +target identity. WebdriverIO is the sanctioned high-level runner; raw W3C and +typed client surfaces remain available for integration and conformance tests. + +`createWindowsSmokeOptions` supplies a package-owned Fabric lifecycle that bundles the Windows catalog, prepares and +builds the generated app, registers and launches its Debug package, starts the channel server and Metro, traverses every story, and stops only +the processes it recorded. `createWin32SmokeCommand` bundles and launches the configured REX host, verifies the shared +desktop chrome, resize handles, and addon surface through the configured test-ID prefix, traverses every story, and +performs the same ownership-safe cleanup. `--mode stories` is the default renderability gate; +`--mode stories-and-tests` performs the same complete traversal and then runs every `desktop-e2e` authored plan through +the Stage 1 manifest-derived fake target. Native plan execution replaces that target when the Stage 2 providers land. +Consumers provide only native identity, title, test-ID prefix, and optional required story IDs. +The Windows helper also records React Native Test App's Debug Metro port (`8081` by default), while Storybook and +Desktop Driver ports remain enlistment-specific. +Artifacts are written beneath the consuming app's `artifacts/windows` or `artifacts/win32` directory. + +`smoke` can also use a complete consumer command or the generic reusable lifecycle. The generic lifecycle starts the shared +channel server and Metro, builds and launches the app, selects every indexed story, runs the configured app stop +command, and terminates only the server processes it started. macOS uses the package's bundle-ID-based stop command by +default. Other generic platform lifecycles require an explicit `smoke.stop`, while consumers can replace the complete +smoke command when native process ownership needs platform-specific handling. Prefer the package-owned Windows and +Win32 command factories over app-local lifecycle scripts. + +Each reusable smoke run derives a stable instance ID from the canonical consuming-project root. That ID suffixes the +configured macOS bundle identifier and seeds separate Storybook and Desktop Driver ports, with occupied-port probing +before launch. Generic and macOS lifecycles also use an enlistment-specific Metro port. The Windows React Native Test +App lifecycle reserves its required `8081` Metro port, so two Metro-backed Windows smoke runs cannot execute +concurrently. The CLI supplies a generated Xcode configuration containing `PRODUCT_BUNDLE_IDENTIFIER` and +`RCT_METRO_PORT`; the Metro helper serializes the matching Storybook port into a generated runtime polyfill. Separate +enlistments therefore never select or stop one another's app or owned services. Generated instance files live under the +consuming app's `storybook-desktop.generated` and `macos/.storybook-desktop` directories and should be ignored. The +runtime module intentionally uses a visible directory because Metro's Windows file map excludes hidden cache +directories. + +The same operations are available without Commander: + +```ts +import { DesktopStorybookCli } from '@fluentui-react-native/storybook-desktop/cli'; +import config from './storybook.config.ts'; + +const storybook = new DesktopStorybookCli(config); +await storybook.bundle('macos'); +await storybook.smoke('macos', { mode: 'stories-and-tests' }); +``` + +Command runners are injectable through the constructor for higher-level automation and tests. `DesktopCommand`, +`DesktopPlatformOptions`, `createDesktopStorybookInstance()`, and the related configuration types are exported from the +`/config` subpath. `server()` runs the foreground server until it is stopped, so supervisors should invoke it as a +dedicated task rather than await it before another operation. + +The app integrates its generated Storybook view with the shared runtime: + +```tsx +import { createDesktopStorybookApp } from '@fluentui-react-native/storybook-desktop-runtime'; + +import { view } from './storybook.requires'; + +export default createDesktopStorybookApp(view); +``` + +Use `createDesktopStorybookPreview()` from the runtime package in the app's `preview.tsx`. Metro configuration is exposed +from `@fluentui-react-native/storybook-desktop-runtime/metro`; Babel, server, config, and CLI helpers are exposed from the +corresponding `@fluentui-react-native/storybook-desktop` subpaths. + +When launched through `driver` or the reusable smoke lifecycle, the generated +runtime instance supplies the configured test-ID prefix, bridge nonce, target +identity, and manifest digests. The runtime exposes stable app/story root +markers and correlates each story selection or reset with a run ID and preview +generation. diff --git a/packages/agentic/storybook-desktop/config/babel.cjs b/packages/agentic/storybook-desktop/config/babel.cjs new file mode 100644 index 00000000000..5a09af866e2 --- /dev/null +++ b/packages/agentic/storybook-desktop/config/babel.cjs @@ -0,0 +1,14 @@ +const transformWin32UnicodeRegex = require.resolve('./transform-win32-unicode-regex.cjs'); + +function createDesktopStorybookBabelConfig(api) { + const platform = api.caller((caller) => caller?.platform); + + return { + presets: ['module:@react-native/babel-preset'], + plugins: platform === 'win32' ? [transformWin32UnicodeRegex] : [], + }; +} + +module.exports = { + createDesktopStorybookBabelConfig, +}; diff --git a/packages/agentic/storybook-desktop/config/cli.cjs b/packages/agentic/storybook-desktop/config/cli.cjs new file mode 100644 index 00000000000..4b2300d901d --- /dev/null +++ b/packages/agentic/storybook-desktop/config/cli.cjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import('../lib/cli/index.js') + .then(({ runDesktopStorybookCli }) => runDesktopStorybookCli()) + .catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); diff --git a/apps/storybook/scripts/run-win32.cjs b/packages/agentic/storybook-desktop/config/run-win32.cjs similarity index 51% rename from apps/storybook/scripts/run-win32.cjs rename to packages/agentic/storybook-desktop/config/run-win32.cjs index 820905ea27c..342644f6bb4 100644 --- a/apps/storybook/scripts/run-win32.cjs +++ b/packages/agentic/storybook-desktop/config/run-win32.cjs @@ -1,22 +1,30 @@ const fs = require('node:fs'); +const { createRequire } = require('node:module'); const path = require('node:path'); -const { runWin32 } = require('@office-iss/rex-win32/run-win32'); - -const packageRoot = path.resolve(__dirname, '..'); -const artifactsDirectory = path.join(packageRoot, 'artifacts', 'win32'); -const consoleOutput = path.join(artifactsDirectory, 'console.log'); +const projectRoot = path.resolve(process.env.STORYBOOK_PROJECT_ROOT || process.cwd()); +const component = process.env.STORYBOOK_WIN32_COMPONENT; +const windowTitle = process.env.STORYBOOK_WIN32_WINDOW_TITLE; const isDevelopment = process.argv.includes('--dev'); const allowRedBox = process.argv.includes('--allow-redbox'); const isCI = process.argv.includes('--ci'); +if (!component || !windowTitle) { + throw new Error('run-win32 requires STORYBOOK_WIN32_COMPONENT and STORYBOOK_WIN32_WINDOW_TITLE.'); +} + +const requireFromProject = createRequire(path.join(projectRoot, 'package.json')); +const { runWin32 } = requireFromProject('@office-iss/rex-win32/run-win32'); +const artifactsDirectory = path.join(projectRoot, 'artifacts', 'win32'); +const consoleOutput = path.join(artifactsDirectory, 'console.log'); + fs.mkdirSync(artifactsDirectory, { recursive: true }); fs.rmSync(consoleOutput, { force: true }); runWin32({ - basePath: path.join(packageRoot, 'dist'), + basePath: path.join(projectRoot, 'dist'), bundle: isDevelopment ? 'index' : 'index.win32', - component: 'AgenticStorybook', + component, consoleOutput, crashOnRedBox: !allowRedBox, debugBundlePath: 'index', @@ -24,7 +32,7 @@ runWin32({ plugin: 'defaultplugin', useDirectDebugger: !isCI, useFastRefresh: isDevelopment, - windowTitle: 'Agentic Components Storybook (Win32)', + windowTitle, }).catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/agentic/storybook-desktop/config/server-cli.cjs b/packages/agentic/storybook-desktop/config/server-cli.cjs new file mode 100644 index 00000000000..b987e4a30c0 --- /dev/null +++ b/packages/agentic/storybook-desktop/config/server-cli.cjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import('../lib/cli/index.js') + .then(({ runDesktopStorybookCli }) => runDesktopStorybookCli([process.argv[0], process.argv[1], 'server', ...process.argv.slice(2)])) + .catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); diff --git a/packages/agentic/storybook-desktop/config/server-runner.cjs b/packages/agentic/storybook-desktop/config/server-runner.cjs new file mode 100644 index 00000000000..20a8466ec8e --- /dev/null +++ b/packages/agentic/storybook-desktop/config/server-runner.cjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +const path = require('node:path'); + +const { startDesktopStorybookServer } = require('./server.cjs'); + +startDesktopStorybookServer({ + configPath: path.resolve(process.env.STORYBOOK_CONFIG_PATH || '.rnstorybook'), + projectRoot: path.resolve(process.env.STORYBOOK_PROJECT_ROOT || '.'), +}).catch((error) => { + process.stderr.write(`${error.stack || error.message}\n`); + process.exitCode = 1; +}); diff --git a/packages/agentic/storybook-desktop/config/server.cjs b/packages/agentic/storybook-desktop/config/server.cjs new file mode 100644 index 00000000000..c8e20128365 --- /dev/null +++ b/packages/agentic/storybook-desktop/config/server.cjs @@ -0,0 +1,121 @@ +const { createRequire } = require('node:module'); +const fs = require('node:fs'); +const path = require('node:path'); + +async function startDesktopStorybookServer({ + configPath, + driverManifestPath = process.env.STORYBOOK_DRIVER_MANIFEST, + host = process.env.STORYBOOK_WS_HOST || '127.0.0.1', + port = Number(process.env.STORYBOOK_WS_PORT) || 7007, + projectRoot = process.cwd(), +} = {}) { + if (!configPath) { + throw new TypeError('startDesktopStorybookServer requires an app-owned Storybook configPath.'); + } + + const requireFromProject = createRequire(path.join(projectRoot, 'package.json')); + const { createChannelServer } = requireFromProject('@storybook/react-native/node'); + const server = createChannelServer({ + host, + port, + configPath, + websockets: true, + experimental_mcp: true, + keepNodeProcessAlive: true, + }); + + // eslint-disable-next-line no-console + console.log(`Storybook channel server listening: + WebSocket : ws://${host}:${port}/ + MCP : http://${host}:${port}/mcp`); + + let driver; + if (driverManifestPath) { + if (!fs.existsSync(driverManifestPath)) { + throw new Error(`Desktop Driver manifest does not exist at ${driverManifestPath}.`); + } + const driverManifest = JSON.parse(fs.readFileSync(driverManifestPath, 'utf8')); + const [ + { createDesktopDriverServer }, + { createFakeStoryWindows, FakeDesktopHost, FakeStoryOrchestrator }, + { StorybookChannelOrchestrator }, + ] = await Promise.all([ + import('@fluentui-react-native/desktop-driver/server'), + import('@fluentui-react-native/desktop-driver/testing'), + import('../lib/driver/index.js'), + ]); + if (!server) { + throw new Error('Desktop Driver Storybook orchestration requires WebSockets.'); + } + const targetHost = new FakeDesktopHost({ + endpoint: driverManifest.endpoint, + platformName: driverManifest.endpoint === 'macos' ? 'macos' : 'windows', + storyRootTestId: `${driverManifest.testIDPrefix}-story-root`, + windows: createFakeStoryWindows(driverManifest.storyManifest, `${driverManifest.testIDPrefix}-story-root`), + }); + const orchestrator = + process.env.STORYBOOK_SMOKE_MODE === 'stories-and-tests' + ? new FakeStoryOrchestrator(driverManifest.storyManifest, targetHost) + : createChannelOrchestrator({ + channelServer: server, + driverManifest, + serverUrl: loopbackUrl(host, port), + targetHost, + StorybookChannelOrchestrator, + }); + driver = await createDesktopDriverServer({ + host, + port: driverManifest.driverPort, + targets: [ + { + endpoint: driverManifest.endpoint, + host: targetHost, + id: driverManifest.targetId, + platformName: driverManifest.endpoint === 'macos' ? 'macos' : 'windows', + renderer: driverManifest.renderer, + storyRootTestId: `${driverManifest.testIDPrefix}-story-root`, + storyOrchestrator: orchestrator, + }, + ], + }); + console.log(` WebDriver: ${driver.url}/`); + const closeDriver = () => { + driver.close().finally(() => { + process.exit(); + }); + }; + process.once('SIGINT', closeDriver); + process.once('SIGTERM', closeDriver); + } + + return { channelServer: server, driver }; +} + +function createChannelOrchestrator({ channelServer, driverManifest, serverUrl, targetHost, StorybookChannelOrchestrator }) { + const channelOrchestrator = new StorybookChannelOrchestrator({ + channelServer, + driverManifest, + serverUrl, + }); + const setStoryMarker = (result) => { + targetHost.resetPreview(); + targetHost.setElementName('story-root', JSON.stringify(result)); + return result; + }; + return { + getManifest: () => channelOrchestrator.getManifest(), + getCurrentStory: () => channelOrchestrator.getCurrentStory(), + selectStory: async (request) => setStoryMarker(await channelOrchestrator.selectStory(request)), + resetStory: async (request) => setStoryMarker(await channelOrchestrator.resetStory(request)), + updateArgs: (storyId, args) => channelOrchestrator.updateArgs(storyId, args), + }; +} + +function loopbackUrl(host, port) { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- native Storybook services are loopback-only + return `http://${host}:${port}`; +} + +module.exports = { + startDesktopStorybookServer, +}; diff --git a/packages/agentic/storybook-desktop/config/smoke-win32.ps1 b/packages/agentic/storybook-desktop/config/smoke-win32.ps1 new file mode 100644 index 00000000000..1b6547f6d9d --- /dev/null +++ b/packages/agentic/storybook-desktop/config/smoke-win32.ps1 @@ -0,0 +1,223 @@ +$ErrorActionPreference = 'Stop' + +$Component = $env:STORYBOOK_WIN32_COMPONENT +$WindowTitle = $env:STORYBOOK_WIN32_WINDOW_TITLE +$TestIDPrefix = $env:STORYBOOK_TEST_ID_PREFIX +$RequiredStoryIds = $env:STORYBOOK_WIN32_REQUIRED_STORIES +$SmokeMode = if ($env:STORYBOOK_SMOKE_MODE) { $env:STORYBOOK_SMOKE_MODE } else { 'stories' } +if (-not $Component -or -not $WindowTitle -or -not $TestIDPrefix) { + throw 'STORYBOOK_WIN32_COMPONENT, STORYBOOK_WIN32_WINDOW_TITLE, and STORYBOOK_TEST_ID_PREFIX are required.' +} +if ($SmokeMode -notin @('stories', 'stories-and-tests')) { + throw "STORYBOOK_SMOKE_MODE must be stories or stories-and-tests. Received '$SmokeMode'." +} + +$projectRoot = (Get-Location).Path +$artifactRoot = Join-Path $projectRoot 'artifacts\win32' +$logRoot = Join-Path $artifactRoot 'smoke-logs' +$storybookPort = if ($env:STORYBOOK_WS_PORT) { [int]$env:STORYBOOK_WS_PORT } else { 7007 } +$driverPort = if ($env:STORYBOOK_DRIVER_PORT) { [int]$env:STORYBOOK_DRIVER_PORT } else { 0 } +$cliPath = Join-Path $PSScriptRoot 'cli.cjs' +$controlPath = Join-Path $PSScriptRoot 'storybook-control.cjs' +$hostPath = Join-Path $PSScriptRoot 'run-win32.cjs' +$requiredStories = @($RequiredStoryIds -split ',' | Where-Object { $_ }) + +if ($SmokeMode -eq 'stories-and-tests' -and (-not $driverPort -or -not $env:STORYBOOK_DRIVER_MANIFEST)) { + throw 'STORYBOOK_DRIVER_PORT and STORYBOOK_DRIVER_MANIFEST are required for stories-and-tests smoke mode.' +} + +New-Item -ItemType Directory -Path $logRoot -Force | Out-Null + +function Invoke-DesktopCli { + param([Parameter(Mandatory)][string[]]$Arguments) + + & node $cliPath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "storybook-desktop $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +function Start-OwnedCommand { + param( + [Parameter(Mandatory)] + [string]$FilePath, + [Parameter(Mandatory)] + [string[]]$ArgumentList, + [Parameter(Mandatory)] + [string]$LogName + ) + + return Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -WorkingDirectory $projectRoot ` + -RedirectStandardOutput (Join-Path $logRoot "$LogName.out.log") ` + -RedirectStandardError (Join-Path $logRoot "$LogName.err.log") -PassThru -WindowStyle Hidden +} + +function Wait-ForTcpPort { + param( + [Parameter(Mandatory)] + [int]$Port, + [int]$TimeoutSeconds = 120 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $client = [System.Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync('127.0.0.1', $Port) + if ($task.Wait(500) -and $client.Connected) { + return + } + } finally { + $client.Dispose() + } + Start-Sleep -Milliseconds 250 + } + + throw "Timed out waiting for port $Port." +} + +function Wait-ForApp { + param([int]$TimeoutSeconds = 120) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $process = Get-Process -Name ReactTest -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq $WindowTitle } | + Select-Object -First 1 + if ($process) { + return $process + } + Start-Sleep -Milliseconds 500 + } + + throw "Timed out waiting for the '$WindowTitle' window." +} + +function Find-AutomationElement { + param( + [Parameter(Mandatory)] + [System.Diagnostics.Process]$Process, + [Parameter(Mandatory)] + [string]$AutomationId + ) + + Add-Type -AssemblyName UIAutomationClient + $condition = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, + $AutomationId + ) + $root = [System.Windows.Automation.AutomationElement]::RootElement + $elements = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition) + return $elements | Where-Object { $_.Current.ProcessId -eq $Process.Id } | Select-Object -First 1 +} + +function Wait-ForAutomationId { + param( + [Parameter(Mandatory)] + [System.Diagnostics.Process]$Process, + [Parameter(Mandatory)] + [string]$AutomationId, + [int]$TimeoutSeconds = 30 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Find-AutomationElement -Process $Process -AutomationId $AutomationId) { + return + } + Start-Sleep -Milliseconds 250 + $Process.Refresh() + } + + throw "Timed out waiting for automation id '$AutomationId'." +} + +function StorybookId { + param([Parameter(Mandatory)][string]$Suffix) + return "$TestIDPrefix-win32-$Suffix" +} + +$ownedProcessIds = [System.Collections.Generic.List[int]]::new() + +function Add-OwnedProcess { + param([int]$Id) + + if ($Id -and -not $ownedProcessIds.Contains($Id)) { + $ownedProcessIds.Add($Id) + } +} + +try { + if (Get-NetTCPConnection -State Listen -LocalPort $storybookPort -ErrorAction SilentlyContinue) { + throw "Storybook port $storybookPort is already in use." + } + if ( + Get-Process -Name ReactTest -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq $WindowTitle } + ) { + throw "A '$WindowTitle' window is already running." + } + + Invoke-DesktopCli -Arguments @('bundle', '--win32') + + $serverLauncher = Start-OwnedCommand -FilePath 'node' ` + -ArgumentList @($cliPath, 'server', '--win32', '--port', [string]$storybookPort) -LogName 'storybook-server' + Add-OwnedProcess -Id $serverLauncher.Id + Wait-ForTcpPort -Port $storybookPort + $serverProcessId = Get-NetTCPConnection -State Listen -LocalPort $storybookPort | + Select-Object -First 1 -ExpandProperty OwningProcess + Add-OwnedProcess -Id $serverProcessId + if ($SmokeMode -eq 'stories-and-tests') { + Wait-ForTcpPort -Port $driverPort + $driverProcessId = Get-NetTCPConnection -State Listen -LocalPort $driverPort | + Select-Object -First 1 -ExpandProperty OwningProcess + Add-OwnedProcess -Id $driverProcessId + } + + $index = Invoke-RestMethod -Uri "http://127.0.0.1:$storybookPort/index.json" + $storyCount = @($index.entries.PSObject.Properties).Count + if ($storyCount -eq 0) { + throw 'The Win32 Storybook server exposed no stories.' + } + foreach ($requiredStoryId in $requiredStories) { + if (-not $index.entries.PSObject.Properties[$requiredStoryId]) { + throw "The Win32 Storybook server did not expose required story '$requiredStoryId'." + } + } + + $hostLauncher = Start-OwnedCommand -FilePath 'node' ` + -ArgumentList @($hostPath, '--ci') -LogName 'win32-host' + Add-OwnedProcess -Id $hostLauncher.Id + $appProcess = Wait-ForApp + $appProcessInfo = Get-CimInstance Win32_Process -Filter "ProcessId=$($appProcess.Id)" + Add-OwnedProcess -Id $appProcessInfo.ParentProcessId + Add-OwnedProcess -Id $appProcess.Id + + Wait-ForAutomationId -Process $appProcess -AutomationId (StorybookId 'sidebar-header') + Wait-ForAutomationId -Process $appProcess -AutomationId (StorybookId 'sidebar-resize') + Wait-ForAutomationId -Process $appProcess -AutomationId (StorybookId 'addons-panel-header') + Wait-ForAutomationId -Process $appProcess -AutomationId (StorybookId 'addons-resize') + Wait-ForAutomationId -Process $appProcess -AutomationId (StorybookId 'addon-storybook-actions-panel') + + $env:STORYBOOK_WS_PORT = [string]$storybookPort + $env:STORYBOOK_SMOKE_FAIL_FAST = '1' + $env:STORYBOOK_SMOKE_SETTLE_MS = '250' + & node $controlPath + if ($LASTEXITCODE -ne 0) { + throw 'Win32 Storybook smoke validation failed.' + } + + if (-not (Get-Process -Id $appProcess.Id -ErrorAction SilentlyContinue)) { + throw 'The Win32 Storybook host exited during the smoke test.' + } +} finally { + for ($index = $ownedProcessIds.Count - 1; $index -ge 0; $index -= 1) { + $ownedProcessId = $ownedProcessIds[$index] + if ($ownedProcessId -and $ownedProcessId -ne $PID) { + $process = Get-Process -Id $ownedProcessId -ErrorAction SilentlyContinue + if ($process) { + Stop-Process -Id $ownedProcessId -ErrorAction Continue + } + } + } +} diff --git a/packages/agentic/storybook-desktop/config/smoke-windows.ps1 b/packages/agentic/storybook-desktop/config/smoke-windows.ps1 new file mode 100644 index 00000000000..7651fb3f2e9 --- /dev/null +++ b/packages/agentic/storybook-desktop/config/smoke-windows.ps1 @@ -0,0 +1,242 @@ +$ErrorActionPreference = 'Stop' + +$Configuration = if ($env:STORYBOOK_WINDOWS_CONFIGURATION) { $env:STORYBOOK_WINDOWS_CONFIGURATION } else { 'Debug' } +$WindowTitle = $env:STORYBOOK_WINDOWS_WINDOW_TITLE +$SmokeMode = if ($env:STORYBOOK_SMOKE_MODE) { $env:STORYBOOK_SMOKE_MODE } else { 'stories' } +if ($Configuration -notin @('Debug', 'Release')) { + throw "STORYBOOK_WINDOWS_CONFIGURATION must be Debug or Release. Received '$Configuration'." +} +if (-not $WindowTitle) { + throw 'STORYBOOK_WINDOWS_WINDOW_TITLE is required.' +} +if ($SmokeMode -notin @('stories', 'stories-and-tests')) { + throw "STORYBOOK_SMOKE_MODE must be stories or stories-and-tests. Received '$SmokeMode'." +} + +$projectRoot = (Get-Location).Path +$artifactRoot = Join-Path $projectRoot 'artifacts\windows' +$logRoot = Join-Path $artifactRoot 'smoke-logs' +$storybookPort = if ($env:STORYBOOK_WS_PORT) { [int]$env:STORYBOOK_WS_PORT } else { 7007 } +$metroPort = if ($env:RCT_METRO_PORT) { [int]$env:RCT_METRO_PORT } else { 8081 } +$driverPort = if ($env:STORYBOOK_DRIVER_PORT) { [int]$env:STORYBOOK_DRIVER_PORT } else { 0 } +$cliPath = Join-Path $PSScriptRoot 'cli.cjs' +$controlPath = Join-Path $PSScriptRoot 'storybook-control.cjs' + +if ($SmokeMode -eq 'stories-and-tests' -and (-not $driverPort -or -not $env:STORYBOOK_DRIVER_MANIFEST)) { + throw 'STORYBOOK_DRIVER_PORT and STORYBOOK_DRIVER_MANIFEST are required for stories-and-tests smoke mode.' +} + +New-Item -ItemType Directory -Path $logRoot -Force | Out-Null + +function Start-OwnedCommand { + param( + [Parameter(Mandatory)] + [string]$FilePath, + [Parameter(Mandatory)] + [string[]]$ArgumentList, + [Parameter(Mandatory)] + [string]$LogName + ) + + return Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -WorkingDirectory $projectRoot ` + -RedirectStandardOutput (Join-Path $logRoot "$LogName.out.log") ` + -RedirectStandardError (Join-Path $logRoot "$LogName.err.log") -PassThru -WindowStyle Hidden +} + +function Wait-ForTcpPort { + param( + [Parameter(Mandatory)] + [int]$Port, + [int]$TimeoutSeconds = 120 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $client = [System.Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync('127.0.0.1', $Port) + if ($task.Wait(500) -and $client.Connected) { + return + } + } finally { + $client.Dispose() + } + Start-Sleep -Milliseconds 250 + } + + throw "Timed out waiting for port $Port." +} + +function Get-PortOwner { + param([Parameter(Mandatory)][int]$Port) + + return Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty OwningProcess +} + +function Wait-ForApp { + param( + [int[]]$ExcludedProcessIds = @(), + [int]$TimeoutSeconds = 120 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $process = Get-Process -Name ReactApp -ErrorAction SilentlyContinue | + Where-Object { + $_.Id -notin $ExcludedProcessIds -and + $_.MainWindowHandle -ne 0 -and + $_.MainWindowTitle -eq $WindowTitle + } | + Select-Object -First 1 + if ($process) { + return $process + } + Start-Sleep -Milliseconds 500 + } + + throw "Timed out waiting for the '$WindowTitle' window." +} + +function Invoke-DesktopCli { + param([Parameter(Mandatory)][string[]]$Arguments) + + & node $cliPath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "storybook-desktop $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +function Install-WindowsFrameworkPackage { + param( + [Parameter(Mandatory)] + [string]$PackageName, + [Parameter(Mandatory)] + [Version]$MinimumVersion, + [Parameter(Mandatory)] + [string]$SdkName, + [Parameter(Mandatory)] + [string]$FileName + ) + + $installedPackage = Get-AppxPackage -Name $packageName | + Where-Object { $_.Architecture -eq 'X64' -and [Version]$_.Version -ge $minimumVersion } | + Select-Object -First 1 + if ($installedPackage) { + return + } + + $sdkRoot = Join-Path ${env:ProgramFiles(x86)} "Microsoft SDKs\Windows Kits\10\ExtensionSDKs\$SdkName" + $frameworkPackage = Get-ChildItem -LiteralPath $sdkRoot -Filter $FileName -Recurse ` + -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $frameworkPackage) { + throw "Could not find the $PackageName framework beneath '$sdkRoot'." + } + + Add-AppxPackage -Path $frameworkPackage.FullName + $installedPackage = Get-AppxPackage -Name $PackageName | + Where-Object { $_.Architecture -eq 'X64' -and [Version]$_.Version -ge $MinimumVersion } | + Select-Object -First 1 + if (-not $installedPackage) { + throw "$PackageName $MinimumVersion or newer was not registered for the current user." + } +} + +function Install-WindowsDebugDependencies { + Install-WindowsFrameworkPackage -PackageName 'Microsoft.VCLibs.140.00.Debug' ` + -MinimumVersion ([Version]'14.0.33519.0') ` + -SdkName 'Microsoft.VCLibs' ` + -FileName 'Microsoft.VCLibs.x64.Debug.14.00.appx' + Install-WindowsFrameworkPackage -PackageName 'Microsoft.VCLibs.140.00.Debug.UWPDesktop' ` + -MinimumVersion ([Version]'14.0.33728.0') ` + -SdkName 'Microsoft.VCLibs.Desktop' ` + -FileName 'Microsoft.VCLibs.x64.Debug.14.00.Desktop.appx' +} + +function Register-WindowsApp { + $manifestPath = Join-Path $projectRoot "windows\ReactApp.Package\bin\x64\$Configuration\AppxManifest.xml" + if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "$Configuration manifest not found at '$manifestPath'." + } + + [xml]$manifest = Get-Content -LiteralPath $manifestPath + $identityName = [string]$manifest.Package.Identity.Name + $appId = [string]$manifest.Package.Applications.Application.Id + $installedPackage = Get-AppxPackage -Name $identityName + if ($installedPackage) { + Remove-AppxPackage -Package $installedPackage.PackageFullName + } + Install-WindowsDebugDependencies + Add-AppxPackage -Register $manifestPath + + $registeredPackage = Get-AppxPackage -Name $identityName + return "shell:AppsFolder\$($registeredPackage.PackageFamilyName)!$appId" +} + +$ownedProcessIds = [System.Collections.Generic.List[int]]::new() + +function Add-OwnedProcess { + param([int]$Id) + + if ($Id -and -not $ownedProcessIds.Contains($Id)) { + $ownedProcessIds.Add($Id) + } +} + +try { + if (Get-NetTCPConnection -State Listen -LocalPort $storybookPort -ErrorAction SilentlyContinue) { + throw "Storybook port $storybookPort is already in use." + } + if (Get-NetTCPConnection -State Listen -LocalPort $metroPort -ErrorAction SilentlyContinue) { + throw "Metro port $metroPort is already in use." + } + + Invoke-DesktopCli -Arguments @('bundle', '--windows') + Invoke-DesktopCli -Arguments @('prep', '--windows') + + $serverLauncher = Start-OwnedCommand -FilePath 'node' ` + -ArgumentList @($cliPath, 'server', '--windows', '--port', [string]$storybookPort) -LogName 'storybook-server' + Add-OwnedProcess -Id $serverLauncher.Id + Wait-ForTcpPort -Port $storybookPort + Add-OwnedProcess -Id (Get-PortOwner -Port $storybookPort) + if ($SmokeMode -eq 'stories-and-tests') { + Wait-ForTcpPort -Port $driverPort + Add-OwnedProcess -Id (Get-PortOwner -Port $driverPort) + } + + Invoke-DesktopCli -Arguments @('build', '--windows') + $launchTarget = Register-WindowsApp + + $metroLauncher = Start-OwnedCommand -FilePath 'rnx-cli' ` + -ArgumentList @('start', '--no-interactive', '--port', [string]$metroPort) -LogName 'metro' + Add-OwnedProcess -Id $metroLauncher.Id + Wait-ForTcpPort -Port $metroPort + Add-OwnedProcess -Id (Get-PortOwner -Port $metroPort) + + $existingAppIds = @(Get-Process -Name ReactApp -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id) + Start-Process explorer.exe $launchTarget + $appProcess = Wait-ForApp -ExcludedProcessIds $existingAppIds + Add-OwnedProcess -Id $appProcess.Id + + $env:STORYBOOK_WS_PORT = [string]$storybookPort + $env:STORYBOOK_SMOKE_FAIL_FAST = '1' + & node $controlPath + if ($LASTEXITCODE -ne 0) { + if (-not (Get-Process -Id $appProcess.Id -ErrorAction SilentlyContinue)) { + throw 'Windows Storybook host terminated during smoke validation.' + } + throw "Windows Storybook smoke validation failed with exit code $LASTEXITCODE." + } +} finally { + for ($index = $ownedProcessIds.Count - 1; $index -ge 0; $index -= 1) { + $ownedProcessId = $ownedProcessIds[$index] + if ($ownedProcessId -and $ownedProcessId -ne $PID) { + $process = Get-Process -Id $ownedProcessId -ErrorAction SilentlyContinue + if ($process) { + Stop-Process -Id $ownedProcessId -ErrorAction Continue + } + } + } +} diff --git a/packages/agentic/storybook-desktop/config/stop-macos-app.applescript b/packages/agentic/storybook-desktop/config/stop-macos-app.applescript new file mode 100644 index 00000000000..81737f98d4d --- /dev/null +++ b/packages/agentic/storybook-desktop/config/stop-macos-app.applescript @@ -0,0 +1,28 @@ +try + set bundleIdentifier to system attribute "FURN_STORYBOOK_BUNDLE_IDENTIFIER" +on error + set bundleIdentifier to "" +end try +if bundleIdentifier is "" then + set bundleIdentifier to "com.microsoft.ReactTestApp" +end if + +tell application "System Events" + set processIds to unix id of every application process whose bundle identifier is bundleIdentifier +end tell + +repeat with processId in processIds + do shell script "/bin/kill -TERM " & quoted form of (processId as text) +end repeat + +repeat 100 times + tell application "System Events" + set appIsRunning to (count of (application processes whose bundle identifier is bundleIdentifier)) > 0 + end tell + if not appIsRunning then + return + end if + delay 0.1 +end repeat + +error "Timed out stopping Storybook application " & bundleIdentifier diff --git a/packages/agentic/storybook-desktop/config/storybook-control.cjs b/packages/agentic/storybook-desktop/config/storybook-control.cjs new file mode 100644 index 00000000000..87f6203d42b --- /dev/null +++ b/packages/agentic/storybook-desktop/config/storybook-control.cjs @@ -0,0 +1,109 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const host = process.env.STORYBOOK_WS_HOST || '127.0.0.1'; +const port = Number(process.env.STORYBOOK_WS_PORT) || 7007; +const smokeMode = process.env.STORYBOOK_SMOKE_MODE || 'stories'; + +function baseUrl() { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- native Storybook services are loopback-only + return `http://${host}:${port}`; +} + +async function request(pathname, options) { + const response = await fetch(`${baseUrl()}${pathname}`, options); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || `Storybook server returned ${response.status}`); + } + return body; +} + +async function selectStory(storyId, attempts = 45) { + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await request(`/select-story-sync/${encodeURIComponent(storyId)}`, { method: 'POST' }); + } catch (error) { + lastError = error; + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } + throw lastError; +} + +async function smoke() { + if (smokeMode !== 'stories' && smokeMode !== 'stories-and-tests') { + throw new Error(`Unsupported Storybook smoke mode "${smokeMode}".`); + } + + const index = await request('/index.json'); + const entries = Object.values(index.entries || {}).filter(({ type }) => type === 'story'); + if (entries.length === 0) { + throw new Error('The Storybook index did not contain any stories.'); + } + + const settleMilliseconds = Number(process.env.STORYBOOK_SMOKE_SETTLE_MS) || 0; + const failFast = process.env.STORYBOOK_SMOKE_FAIL_FAST === '1'; + const failures = []; + + for (const { id } of entries) { + try { + await selectStory(id); + if (settleMilliseconds > 0) { + await new Promise((resolve) => setTimeout(resolve, settleMilliseconds)); + } + process.stdout.write(`rendered ${id}\n`); + } catch (error) { + failures.push({ id, error: error.message }); + process.stderr.write(`failed ${id}: ${error.message}\n`); + if (failFast) { + break; + } + } + } + + if (failures.length > 0) { + throw new Error(`${failures.length} of ${entries.length} stories failed to render`); + } + process.stdout.write(`Rendered ${entries.length} stories.\n`); + + if (smokeMode === 'stories-and-tests') { + await runAuthoredTests(); + } +} + +async function runAuthoredTests() { + const manifestPath = process.env.STORYBOOK_DRIVER_MANIFEST; + if (!manifestPath || !fs.existsSync(manifestPath)) { + throw new Error('STORYBOOK_DRIVER_MANIFEST must identify the generated driver manifest when running authored tests.'); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if ( + manifest.schemaVersion !== 1 || + !['macos', 'win32', 'windows'].includes(manifest.endpoint) || + !Number.isInteger(manifest.driverPort) || + typeof manifest.targetId !== 'string' + ) { + throw new Error(`Invalid Desktop Driver manifest at ${manifestPath}.`); + } + + const smokeTestsUrl = pathToFileURL(path.join(__dirname, '..', 'lib', 'cli', 'smokeTests.js')).href; + const { formatDesktopStorybookSmokeTestSummary, runDesktopStorybookSmokeTests } = await import(smokeTestsUrl); + const result = await runDesktopStorybookSmokeTests({ + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- the Desktop Driver is loopback-only + driverUrl: `http://127.0.0.1:${manifest.driverPort}`, + platform: manifest.endpoint, + projectRoot: process.cwd(), + targetId: manifest.targetId, + }); + process.stdout.write(`${formatDesktopStorybookSmokeTestSummary(result)}\n`); +} + +smoke().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +}); diff --git a/apps/storybook/scripts/transform-win32-unicode-regex.cjs b/packages/agentic/storybook-desktop/config/transform-win32-unicode-regex.cjs similarity index 100% rename from apps/storybook/scripts/transform-win32-unicode-regex.cjs rename to packages/agentic/storybook-desktop/config/transform-win32-unicode-regex.cjs diff --git a/packages/agentic/storybook-desktop/jest.config.cjs b/packages/agentic/storybook-desktop/jest.config.cjs new file mode 100644 index 00000000000..d6535664e2e --- /dev/null +++ b/packages/agentic/storybook-desktop/jest.config.cjs @@ -0,0 +1,9 @@ +const config = require('@fluentui-react-native/scripts/jest-config'); + +module.exports = { + ...config, + moduleNameMapper: { + ...config.moduleNameMapper, + '^(\\.{1,2}/.*)\\.js$': '$1', + }, +}; diff --git a/packages/agentic/storybook-desktop/package.json b/packages/agentic/storybook-desktop/package.json new file mode 100644 index 00000000000..80f56d14a2e --- /dev/null +++ b/packages/agentic/storybook-desktop/package.json @@ -0,0 +1,81 @@ +{ + "name": "@fluentui-react-native/storybook-desktop", + "version": "0.1.0", + "description": "CLI and configuration for React Native desktop Storybook applications", + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui-react-native.git", + "directory": "packages/agentic/storybook-desktop" + }, + "bin": { + "storybook-desktop": "./config/cli.cjs", + "storybook-server": "./config/server-cli.cjs" + }, + "type": "module", + "main": "lib/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "react-native": "./src/index.ts", + "import": "./lib/index.js", + "default": "./src/index.ts" + }, + "./babel": "./config/babel.cjs", + "./config": { + "types": "./lib/config/index.d.ts", + "import": "./lib/config/index.js", + "default": "./src/config/index.ts" + }, + "./driver": { + "types": "./lib/driver/index.d.ts", + "import": "./lib/driver/index.js", + "default": "./src/driver/index.ts" + }, + "./cli": { + "types": "./lib/cli/index.d.ts", + "import": "./lib/cli/index.js", + "default": "./lib/cli/index.js" + }, + "./server": "./config/server.cjs", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc -b", + "clean": "fluentui-scripts clean", + "format": "fluentui-scripts format", + "lint": "fluentui-scripts lint", + "test": "fluentui-scripts jest" + }, + "dependencies": { + "@fluentui-react-native/desktop-driver": "workspace:*", + "@rnx-kit/tools-react-native": "catalog:", + "commander": "^14.0.2", + "regexpu-core": "^6.3.1" + }, + "devDependencies": { + "@babel/core": "catalog:", + "@fluentui-react-native/scripts": "workspace:*", + "@react-native/babel-preset": "^0.81.0" + }, + "furn": { + "jestPlatform": "react", + "knip": { + "ignoreDependencies": [ + "@react-native/babel-preset" + ] + } + }, + "rnx-kit": { + "kitType": "library", + "alignDeps": { + "capabilities": [ + "babel-preset-react-native" + ] + }, + "extends": "@fluentui-react-native/scripts/kit-config" + } +} diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts new file mode 100644 index 00000000000..db2f1c54023 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -0,0 +1,478 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { STORYBOOK_SMOKE_MODE } from '../config/commands'; +import { makeDesktopStorybookConfig } from '../config/makeDesktopStorybookConfig'; +import { FURN_STORYBOOK_BUNDLE_IDENTIFIER, FURN_STORYBOOK_INSTANCE_ID } from '../config/instance'; +import { FURN_STORYBOOK_PLATFORM } from '../config/platforms'; +import type { DesktopCommandRunner, PreparedDesktopCommand, RunningDesktopCommand } from './commandRunner'; +import { createDesktopStorybookCommand } from './createDesktopStorybookCommand'; +import { DesktopStorybookCli } from './DesktopStorybookCli'; + +const storybookRoot = path.resolve(__dirname, '../../../../../apps/storybook'); +const createEmptyStoryManifest = async (_config: unknown, platform: 'macos' | 'windows' | 'win32') => ({ + endpoint: platform, + entries: [], + platformManifestDigest: `${platform}-digest`, + portablePlanDigest: 'portable-digest', + schemaVersion: 1 as const, +}); + +class RecordingRunner implements DesktopCommandRunner { + readonly foreground: PreparedDesktopCommand[] = []; + readonly background: PreparedDesktopCommand[] = []; + stopped = 0; + failCommand?: string; + + async run(command: PreparedDesktopCommand): Promise { + this.foreground.push(command); + if (command.command === this.failCommand) { + throw new Error(`${command.command} failed`); + } + } + + start(command: PreparedDesktopCommand): RunningDesktopCommand { + this.background.push(command); + return { + completed: new Promise(() => {}), + stop: async () => { + this.stopped += 1; + }, + }; + } +} + +function makeConfig(platformOptions = {}) { + return makeDesktopStorybookConfig({ + projectRoot: storybookRoot, + storyPackages: [], + platformOptions, + }); +} + +describe('DesktopStorybookCli', () => { + test('starts the config-owned server with platform and connection options', async () => { + const runner = new RecordingRunner(); + const cli = new DesktopStorybookCli(makeConfig(), { runner }); + + await cli.server('win32', { host: '0.0.0.0', port: 7100 }); + + expect(runner.foreground[0]).toMatchObject({ + command: process.execPath, + args: [path.resolve(storybookRoot, '../../packages/agentic/storybook-desktop/config/server-runner.cjs')], + cwd: storybookRoot, + env: { + [FURN_STORYBOOK_PLATFORM]: 'win32', + STORYBOOK_CONFIG_PATH: path.join(storybookRoot, 'src'), + STORYBOOK_WS_HOST: '0.0.0.0', + STORYBOOK_WS_PORT: '7100', + }, + }); + }); + + test('uses shared preparation and rnx-cli bundle defaults', async () => { + const runner = new RecordingRunner(); + const cli = new DesktopStorybookCli(makeConfig(), { runner }); + + await cli.prep('macos'); + await cli.bundle('win32'); + + expect(runner.foreground).toMatchObject([ + { + command: 'pod', + args: ['install', '--project-directory=macos'], + cwd: storybookRoot, + env: { [FURN_STORYBOOK_PLATFORM]: 'macos' }, + }, + { + command: 'sb-rn-get-stories', + args: ['--config-path', path.join(storybookRoot, 'src')], + env: { [FURN_STORYBOOK_PLATFORM]: 'win32' }, + }, + { + command: 'rnx-cli', + args: ['bundle', '--dev', 'false', '--platform', 'win32'], + env: { [FURN_STORYBOOK_PLATFORM]: 'win32' }, + }, + ]); + }); + + test('uses native project defaults and rejects an unconfigured Win32 build', async () => { + const runner = new RecordingRunner(); + const cli = new DesktopStorybookCli(makeConfig(), { runner }); + + await cli.run('windows'); + + expect(runner.foreground[0]).toMatchObject({ + command: 'rnx-cli', + args: ['run', '--platform', 'windows', '--solution', 'windows/AgenticStorybook.sln'], + }); + await expect(cli.build('win32')).rejects.toThrow('build is not configured for win32'); + }); + + test('always runs app and process cleanup when the reusable smoke lifecycle fails', async () => { + const runner = new RecordingRunner(); + runner.failCommand = 'launch-storybook'; + const cli = new DesktopStorybookCli( + makeConfig({ + macos: { + run: { command: 'launch-storybook' }, + smoke: { + stop: { command: 'stop-storybook' }, + }, + }, + }), + { + createStoryManifest: createEmptyStoryManifest, + runner, + fetch: jest.fn(async () => new Response('{}')), + isPortAvailable: async () => true, + }, + ); + + await expect(cli.smoke('macos')).rejects.toThrow('launch-storybook failed'); + + expect(runner.foreground.map(({ command }) => command)).toEqual(['launch-storybook', 'stop-storybook']); + expect(runner.background.map(({ command }) => path.basename(command))).toEqual([path.basename(process.execPath), 'rnx-cli']); + expect(runner.foreground[0].env).toMatchObject({ + [FURN_STORYBOOK_INSTANCE_ID]: cli.instance.id, + [FURN_STORYBOOK_BUNDLE_IDENTIFIER]: cli.instance.bundleIdentifier, + STORYBOOK_WS_PORT: String(cli.instance.storybookPort), + RCT_METRO_PORT: String(cli.instance.metroPort), + }); + expect(runner.stopped).toBe(2); + }); + + test('renders every indexed story before cleanup', async () => { + const runner = new RecordingRunner(); + const output: string[] = []; + const fetch = jest.fn(async (input: Parameters[0]) => { + const url = input.toString(); + if (url.endsWith('/index.json')) { + return new Response( + JSON.stringify({ + entries: { + first: { id: 'first--story', type: 'story' }, + docs: { id: 'first--docs', type: 'docs' }, + second: { id: 'second--story', type: 'story' }, + }, + }), + ); + } + return new Response('{}'); + }); + const blockedPorts = new Set(); + const cli = new DesktopStorybookCli( + makeConfig({ + macos: { + run: { command: 'launch-storybook' }, + smoke: { + stop: { command: 'stop-storybook' }, + }, + }, + }), + { + createStoryManifest: createEmptyStoryManifest, + runner, + fetch, + isPortAvailable: async (port) => !blockedPorts.has(port), + output: { + write: (value) => { + output.push(value.toString()); + return true; + }, + }, + }, + ); + blockedPorts.add(cli.instance.storybookPort); + blockedPorts.add(cli.instance.metroPort); + + await cli.smoke('macos'); + + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- the test exercises the loopback Storybook server + const serverUrl = `http://127.0.0.1:${cli.instance.storybookPort + 1}`; + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- the test exercises the loopback Storybook server + expect(fetch.mock.calls[0][0]).toBe(`${serverUrl}/index.json`); + expect(fetch.mock.calls.map(([input]) => input.toString()).filter((url) => url.includes('select-story-sync'))).toEqual([ + `${serverUrl}/select-story-sync/first--story`, + `${serverUrl}/select-story-sync/second--story`, + ]); + expect(output.at(-1)).toBe('Rendered 2 stories.\n'); + expect(runner.background[1].args).toEqual(['start', '--no-interactive', '--port', String(cli.instance.metroPort + 1)]); + expect(runner.foreground.at(-1)?.command).toBe('stop-storybook'); + expect(runner.stopped).toBe(2); + }); + + test('keeps retrying the first story while the initial Metro bundle is compiling', async () => { + jest.useFakeTimers(); + try { + const runner = new RecordingRunner(); + let selectionAttempts = 0; + const fetch = jest.fn(async (input: Parameters[0]) => { + const url = input.toString(); + if (url.endsWith('/index.json')) { + return new Response( + JSON.stringify({ + entries: { + first: { id: 'first--story', type: 'story' }, + }, + }), + ); + } + if (url.includes('select-story-sync')) { + selectionAttempts += 1; + if (selectionAttempts <= 12) { + return new Response(JSON.stringify({ error: 'Storybook runtime is not connected yet.' }), { status: 408 }); + } + } + return new Response('{}'); + }); + const cli = new DesktopStorybookCli( + makeConfig({ + macos: { + run: { command: 'launch-storybook' }, + smoke: { + startupTimeoutMs: 10_000, + stop: { command: 'stop-storybook' }, + }, + }, + }), + { + createStoryManifest: createEmptyStoryManifest, + fetch, + isPortAvailable: async () => true, + runner, + }, + ); + + const smoke = cli.smoke('macos'); + await jest.advanceTimersByTimeAsync(7000); + await smoke; + + expect(selectionAttempts).toBe(13); + } finally { + jest.useRealTimers(); + } + }); + + test('runs authored tests after traversing the complete story index', async () => { + const runner = new RecordingRunner(); + const events: string[] = []; + const fetch = jest.fn(async (input: Parameters[0]) => { + const url = input.toString(); + if (url.endsWith('/index.json')) { + return new Response( + JSON.stringify({ + entries: { + first: { id: 'first--story', type: 'story' }, + second: { id: 'second--story', type: 'story' }, + }, + }), + ); + } + if (url.includes('select-story-sync')) { + events.push(`select:${url.split('/').at(-1)}`); + } + return new Response('{}'); + }); + const runSmokeTests = jest.fn(async () => { + events.push('tests'); + return { + endpoint: 'macos' as const, + finishedAt: '2026-08-30T08:00:01.000Z', + manifest: { + platform: 'macos-digest', + portable: 'portable-digest', + }, + platformName: 'macos' as const, + runId: 'smoke-run', + schemaVersion: 1 as const, + startedAt: '2026-08-30T08:00:00.000Z', + status: 'passed' as const, + targetId: 'agenticstorybook-macos', + tests: [], + }; + }); + const cli = new DesktopStorybookCli( + makeConfig({ + macos: { + run: { command: 'launch-storybook' }, + smoke: { + stop: { command: 'stop-storybook' }, + }, + }, + }), + { + createStoryManifest: createEmptyStoryManifest, + fetch, + isPortAvailable: async () => true, + runSmokeTests, + runner, + }, + ); + + await cli.smoke('macos', { mode: 'stories-and-tests' }); + + expect(events).toEqual(['select:first--story', 'select:second--story', 'tests']); + expect(runSmokeTests).toHaveBeenCalledWith({ + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- the test exercises the loopback Desktop Driver + driverUrl: `http://127.0.0.1:${cli.instance.driverPort}`, + platform: 'macos', + projectRoot: storybookRoot, + targetId: 'agenticstorybook-macos', + }); + expect(fetch.mock.calls.map(([input]) => input.toString())).toContain( + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- the test exercises the loopback Desktop Driver + `http://127.0.0.1:${cli.instance.driverPort}/status`, + ); + expect(runner.background[0].env).toMatchObject({ + [STORYBOOK_SMOKE_MODE]: 'stories-and-tests', + }); + }); +}); + +describe('createDesktopStorybookCommand', () => { + test('forwards the selected smoke mode and isolated instance to a package-owned lifecycle', async () => { + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ + config: makeConfig({ + windows: { + smoke: { + command: { command: 'smoke-windows' }, + }, + }, + }), + createStoryManifest: createEmptyStoryManifest, + isPortAvailable: async () => true, + runner, + }); + const manifestPath = path.join(storybookRoot, 'storybook-desktop.generated', 'driver-manifest.windows.json'); + + try { + await program.parseAsync(['node', 'test', 'smoke', '--windows', '--mode', 'stories-and-tests']); + + expect(runner.foreground[0]).toMatchObject({ + command: 'smoke-windows', + env: { + [FURN_STORYBOOK_INSTANCE_ID]: expect.any(String), + [STORYBOOK_SMOKE_MODE]: 'stories-and-tests', + STORYBOOK_DRIVER_MANIFEST: manifestPath, + STORYBOOK_DRIVER_PORT: expect.any(String), + STORYBOOK_WS_PORT: expect.any(String), + RCT_METRO_PORT: expect.any(String), + }, + }); + } finally { + fs.rmSync(manifestPath, { force: true }); + } + }); + + test('starts the channel and embedded driver from one server command', async () => { + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ + config: makeConfig(), + createStoryManifest: createEmptyStoryManifest, + isPortAvailable: async () => true, + runner, + }); + const manifestPath = path.join(storybookRoot, 'storybook-desktop.generated', 'driver-manifest.win32.json'); + + try { + await program.parseAsync(['node', 'test', 'driver', '--win32', '--host', 'localhost', '--port', '7102']); + + expect(runner.foreground[0]).toMatchObject({ + args: [path.resolve(storybookRoot, '../../packages/agentic/storybook-desktop/config/server-runner.cjs')], + env: { + [FURN_STORYBOOK_PLATFORM]: 'win32', + STORYBOOK_DRIVER_MANIFEST: manifestPath, + STORYBOOK_DRIVER_PORT: expect.any(String), + STORYBOOK_WS_HOST: 'localhost', + STORYBOOK_WS_PORT: '7102', + }, + }); + expect(runner.background[0]).toMatchObject({ + command: 'rnx-cli', + args: ['start', '--no-interactive', '--port', expect.any(String)], + env: { STORYBOOK_DRIVER_MANIFEST: manifestPath }, + }); + const firstManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + expect(firstManifest).toMatchObject({ + endpoint: 'win32', + targetId: 'agenticstorybook-win32', + testIDPrefix: 'agentic-storybook', + }); + + const secondRunner = new RecordingRunner(); + const secondProgram = createDesktopStorybookCommand({ + config: makeConfig(), + createStoryManifest: createEmptyStoryManifest, + isPortAvailable: async () => true, + runner: secondRunner, + }); + await secondProgram.parseAsync(['node', 'test', 'driver', '--win32', '--host', 'localhost', '--port', '7102']); + const secondManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + expect(secondManifest.bridgeNonce).toBe(firstManifest.bridgeNonce); + } finally { + fs.rmSync(manifestPath, { force: true }); + } + }); + + test('forwards server platform and connection options', async () => { + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ config: makeConfig(), runner }); + + await program.parseAsync(['node', 'test', 'server', '--win32', '--host', 'localhost', '--port', '7101']); + + expect(runner.foreground[0]).toMatchObject({ + args: [path.resolve(storybookRoot, '../../packages/agentic/storybook-desktop/config/server-runner.cjs')], + env: { + [FURN_STORYBOOK_PLATFORM]: 'win32', + STORYBOOK_WS_HOST: 'localhost', + STORYBOOK_WS_PORT: '7101', + }, + }); + }); + + test('honors an explicit platform flag', async () => { + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ config: makeConfig(), runner }); + + await program.parseAsync(['node', 'test', 'bundle', '--windows']); + + expect(runner.foreground.at(-1)).toMatchObject({ + command: 'rnx-cli', + args: ['bundle', '--dev', 'false', '--platform', 'windows'], + }); + }); + + test('falls back to the configured environment platform', async () => { + const previousPlatform = process.env[FURN_STORYBOOK_PLATFORM]; + process.env[FURN_STORYBOOK_PLATFORM] = 'win32'; + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ config: makeConfig(), runner }); + + try { + await program.parseAsync(['node', 'test', 'bundle']); + } finally { + if (previousPlatform === undefined) { + delete process.env[FURN_STORYBOOK_PLATFORM]; + } else { + process.env[FURN_STORYBOOK_PLATFORM] = previousPlatform; + } + } + + expect(runner.foreground.at(-1)?.args).toEqual(['bundle', '--dev', 'false', '--platform', 'win32']); + }); + + test('rejects multiple platform flags', async () => { + const program = createDesktopStorybookCommand({ config: makeConfig(), runner: new RecordingRunner() }); + program.exitOverride(); + program.configureOutput({ writeErr: () => {} }); + program.commands.forEach((command) => { + command.exitOverride(); + command.configureOutput({ writeErr: () => {} }); + }); + + await expect(program.parseAsync(['node', 'test', 'bundle', '--windows', '--macos'])).rejects.toThrow('cannot be used with option'); + }); +}); diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts new file mode 100644 index 00000000000..52ae62ca62a --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -0,0 +1,610 @@ +import fs from 'node:fs'; +import { createServer } from 'node:net'; +import path from 'node:path'; + +import { + desktopSmokeModes, + STORYBOOK_SMOKE_MODE, + type DesktopCommand, + type DesktopCommandPlan, + type DesktopSmokeMode, + type DesktopSmokeOptions, + type DesktopSmokeRunOptions, + type DesktopStorybookAction, +} from '../config/commands.js'; +import type { DesktopStorybookConfig } from '../config/makeDesktopStorybookConfig.js'; +import { createDesktopStorybookInstance, FURN_STORYBOOK_BUNDLE_IDENTIFIER, FURN_STORYBOOK_INSTANCE_ID } from '../config/instance.js'; +import type { DesktopStorybookInstance } from '../config/instance.js'; +import { FURN_STORYBOOK_PLATFORM } from '../config/platforms.js'; +import type { Platforms } from '../config/platforms.js'; +import { + createDesktopStorybookDriverManifest, + createDesktopStoryManifest, + writeDesktopStorybookDriverManifest, + writeDesktopStoryManifest, +} from '../driver/index.js'; +import { NodeDesktopCommandRunner } from './commandRunner.js'; +import type { DesktopCommandRunner, PreparedDesktopCommand, RunningDesktopCommand } from './commandRunner.js'; +import { formatDesktopStorybookSmokeTestSummary, runDesktopStorybookSmokeTests } from './smokeTests.js'; + +type ResolvedDesktopStorybookInstance = DesktopStorybookInstance & { + driverManifestPath?: string; + macosXcconfigPath?: string; +}; + +export type DesktopStorybookCliOptions = { + createStoryManifest?: typeof createDesktopStoryManifest; + runner?: DesktopCommandRunner; + fetch?: typeof globalThis.fetch; + output?: Pick; + isPortAvailable?: (port: number) => Promise; + runSmokeTests?: typeof runDesktopStorybookSmokeTests; +}; + +export type DesktopStorybookServerOptions = { + host?: string; + port?: number; +}; + +export class DesktopStorybookCli { + readonly config: DesktopStorybookConfig; + readonly instance: DesktopStorybookInstance; + + private readonly runner: DesktopCommandRunner; + private readonly createStoryManifest: typeof createDesktopStoryManifest; + private readonly fetch: typeof globalThis.fetch; + private readonly output: Pick; + private readonly isPortAvailable: (port: number) => Promise; + private readonly runSmokeTests: typeof runDesktopStorybookSmokeTests; + + constructor(config: DesktopStorybookConfig, options: DesktopStorybookCliOptions = {}) { + this.config = config; + this.instance = createDesktopStorybookInstance({ + projectRoot: config.projectRoot, + bundleIdentifierPrefix: config.macosBundleIdentifier, + }); + this.runner = options.runner ?? new NodeDesktopCommandRunner(); + this.createStoryManifest = options.createStoryManifest ?? createDesktopStoryManifest; + this.fetch = options.fetch ?? globalThis.fetch; + this.output = options.output ?? process.stdout; + this.isPortAvailable = options.isPortAvailable ?? isLoopbackPortAvailable; + this.runSmokeTests = options.runSmokeTests ?? runDesktopStorybookSmokeTests; + } + + async server(platform: Platforms, options: DesktopStorybookServerOptions = {}): Promise { + validateServerOptions(options); + const command = this.config.getPlatformOptions(platform).server; + if (command === false || command === undefined) { + throw unsupportedAction('server', platform); + } + await this.executePlan( + { + ...command, + env: { + ...command.env, + ...(options.host ? { STORYBOOK_WS_HOST: options.host } : {}), + ...(options.port ? { STORYBOOK_WS_PORT: String(options.port) } : {}), + }, + }, + platform, + ); + } + + async driver(platform: Platforms, options: DesktopStorybookServerOptions = {}): Promise { + validateServerOptions(options); + validateDriverHost(options.host); + const command = this.config.getPlatformOptions(platform).server; + if (command === false || command === undefined) { + throw unsupportedAction('server', platform); + } + const storybookPort = options.port ?? (await findAvailablePort(this.instance.storybookPort, this.isPortAvailable)); + const metroPort = await findAvailablePort(this.instance.metroPort, this.isPortAvailable, new Set([storybookPort])); + const driverPort = await findAvailablePort(this.instance.driverPort, this.isPortAvailable, new Set([storybookPort, metroPort])); + const resolvedInstance: ResolvedDesktopStorybookInstance = { + ...this.instance, + driverPort, + metroPort, + storybookPort, + }; + resolvedInstance.driverManifestPath = await this.writeDriverManifest(platform, resolvedInstance); + this.output.write(`Storybook instance ${resolvedInstance.id}: channel=${storybookPort}, metro=${metroPort}, driver=${driverPort}\n`); + const metro = this.runner.start(this.prepareCommand(defaultMetroCommand(resolvedInstance), platform, resolvedInstance)); + try { + await this.executePlan( + { + ...command, + env: { + ...command.env, + ...(options.host ? { STORYBOOK_WS_HOST: options.host } : {}), + }, + }, + platform, + resolvedInstance, + ); + } finally { + await metro.stop(); + } + } + + async manifest(platform: Platforms, outputPath?: string): Promise { + const manifest = await this.createStoryManifest(this.config, platform); + const resolvedOutput = resolveFromProject( + this.config.projectRoot, + outputPath ?? path.join('storybook-desktop.generated', `story-manifest.${platform}.json`), + ); + writeDesktopStoryManifest(manifest, resolvedOutput); + this.output.write(`${resolvedOutput}\n`); + return resolvedOutput; + } + + printInstance(platform: Platforms): void { + this.output.write( + `${JSON.stringify( + { + ...this.instance, + endpoint: platform, + targetId: `${this.config.appName}-${platform}`.toLowerCase(), + testIDPrefix: this.config.testIDPrefix, + }, + null, + 2, + )}\n`, + ); + } + + prep(platform: Platforms): Promise { + return this.executeAction('prep', platform); + } + + bundle(platform: Platforms): Promise { + return this.executeAction('bundle', platform); + } + + run(platform: Platforms): Promise { + return this.executeAction('run', platform); + } + + build(platform: Platforms): Promise { + return this.executeAction('build', platform); + } + + async smoke(platform: Platforms, options: DesktopSmokeRunOptions = {}): Promise { + const mode = resolveSmokeMode(options.mode); + const smoke = this.config.getSmokeOptions(platform); + if (smoke === false) { + throw unsupportedAction('smoke', platform); + } + if (smoke?.command) { + const instance = await this.resolveSmokeInstance(platform, smoke); + await this.executePlan(withEnvironment(smoke.command, { [STORYBOOK_SMOKE_MODE]: mode }), platform, instance); + return; + } + if (!smoke?.stop) { + throw new Error( + `The reusable ${platform} smoke lifecycle requires platformOptions.${platform}.smoke.stop so the native app can be shut down safely.`, + ); + } + + const instance = await this.resolveSmokeInstance(platform, smoke); + await this.runSmokeLifecycle(platform, smoke, instance, mode); + } + + private async executeAction( + action: Exclude, + platform: Platforms, + instance?: ResolvedDesktopStorybookInstance, + ): Promise { + const plan = this.config.getCommandPlan(action, platform); + if (plan === false) { + throw unsupportedAction(action, platform); + } + await this.executePlan(plan, platform, instance); + } + + private async runSmokeLifecycle( + platform: Platforms, + smoke: DesktopSmokeOptions, + instance: ResolvedDesktopStorybookInstance, + mode: DesktopSmokeMode, + ): Promise { + const backgroundCommands: RunningDesktopCommand[] = []; + const failures: unknown[] = []; + let primaryFailure: unknown; + const serverUrl = smoke.serverUrl ?? loopbackUrl(instance.storybookPort); + const metroUrl = smoke.metroUrl ?? loopbackUrl(instance.metroPort, '/status'); + + try { + const configuredServer = this.config.getPlatformOptions(platform).server; + const server = smoke.server === false ? undefined : (smoke.server ?? (configuredServer === false ? undefined : configuredServer)); + const metro = smoke.metro === false ? undefined : (smoke.metro ?? defaultMetroCommand(instance)); + const readiness: Promise[] = []; + + if (server) { + const runningServer = this.runner.start( + this.prepareCommand( + { + ...server, + env: { + ...server.env, + [STORYBOOK_SMOKE_MODE]: mode, + }, + }, + platform, + instance, + ), + ); + backgroundCommands.push(runningServer); + readiness.push(this.waitForUrl(new URL('/index.json', serverUrl).href, runningServer, smoke.startupTimeoutMs)); + if (mode === 'stories-and-tests') { + readiness.push(this.waitForUrl(loopbackUrl(instance.driverPort, '/status'), runningServer, smoke.startupTimeoutMs)); + } + } else if (mode === 'stories-and-tests') { + throw new Error('The stories-and-tests smoke mode requires the Storybook server and embedded Desktop Driver.'); + } + if (metro) { + const runningMetro = this.runner.start(this.prepareCommand(metro, platform, instance)); + backgroundCommands.push(runningMetro); + readiness.push(this.waitForUrl(metroUrl, runningMetro, smoke.startupTimeoutMs)); + } + + await Promise.all(readiness); + await this.executeAction('run', platform, instance); + await this.renderEveryStory(serverUrl, smoke.settleMs ?? 0, smoke.startupTimeoutMs); + if (mode === 'stories-and-tests') { + const result = await this.runSmokeTests({ + driverUrl: loopbackUrl(instance.driverPort), + platform, + projectRoot: this.config.projectRoot, + targetId: `${this.config.appName}-${platform}`.toLowerCase(), + }); + this.output.write(`${formatDesktopStorybookSmokeTestSummary(result)}\n`); + } + } catch (error) { + primaryFailure = error; + } finally { + try { + await this.executePlan(smoke.stop!, platform, instance); + } catch (error) { + failures.push(error); + } + for (const backgroundCommand of backgroundCommands.reverse()) { + try { + await backgroundCommand.stop(); + } catch (error) { + failures.push(error); + } + } + } + + if (primaryFailure !== undefined) { + failures.unshift(primaryFailure); + } + if (failures.length === 1) { + throw failures[0]; + } + if (failures.length > 1) { + throw new AggregateError(failures, `${platform} smoke test failed: ${failures.map((error) => errorMessage(error)).join('; ')}`); + } + } + + private async resolveSmokeInstance(platform: Platforms, smoke: DesktopSmokeOptions): Promise { + const storybookPort = smoke.serverUrl + ? portFromUrl(smoke.serverUrl) + : await findAvailablePort(this.instance.storybookPort, this.isPortAvailable); + const metroPort = smoke.metroUrl + ? portFromUrl(smoke.metroUrl) + : await findAvailablePort(this.instance.metroPort, this.isPortAvailable, new Set([storybookPort])); + const driverPort = await findAvailablePort(this.instance.driverPort, this.isPortAvailable, new Set([storybookPort, metroPort])); + const resolvedInstance: ResolvedDesktopStorybookInstance = { + ...this.instance, + driverPort, + storybookPort, + metroPort, + }; + + resolvedInstance.driverManifestPath = await this.writeDriverManifest(platform, resolvedInstance); + if (platform === 'macos') { + resolvedInstance.macosXcconfigPath = writeMacOSInstanceConfig(this.config.projectRoot, resolvedInstance); + } + this.output.write( + `Storybook instance ${resolvedInstance.id}: channel=${storybookPort}, metro=${metroPort}, driver=${driverPort}` + + (platform === 'macos' ? `, bundle=${resolvedInstance.bundleIdentifier}` : '') + + '\n', + ); + return Object.freeze(resolvedInstance); + } + + private async writeDriverManifest(platform: Platforms, instance: DesktopStorybookInstance): Promise { + const storyManifest = await this.createStoryManifest(this.config, platform); + const outputPath = path.join(this.config.projectRoot, 'storybook-desktop.generated', `driver-manifest.${platform}.json`); + const driverManifest = createDesktopStorybookDriverManifest({ + bridgeNonce: readReusableBridgeNonce(outputPath, instance, storyManifest.platformManifestDigest), + config: this.config, + instance, + platform, + storyManifest, + }); + writeDesktopStorybookDriverManifest(driverManifest, outputPath); + return outputPath; + } + + private async renderEveryStory(serverUrl: string, settleMs: number, startupTimeoutMs = 120_000): Promise { + const storyIndex = await this.getJson(new URL('/index.json', serverUrl)); + const entries = Object.values((storyIndex.entries ?? {}) as Record).filter( + (entry) => entry.type === 'story' && entry.id, + ); + if (entries.length === 0) { + throw new Error('The Storybook index did not contain any stories.'); + } + + const failures: Error[] = []; + let consecutiveFailures = 0; + for (const [entryIndex, { id }] of entries.entries()) { + try { + await this.selectStory(serverUrl, id!, entryIndex === 0 ? startupTimeoutMs : 15_000); + consecutiveFailures = 0; + if (settleMs > 0) { + await delay(settleMs); + } + this.output.write(`rendered ${id}\n`); + } catch (error) { + failures.push(new Error(`${id}: ${(error as Error).message}`)); + consecutiveFailures += 1; + if (entryIndex === 0 || consecutiveFailures >= 3) { + break; + } + } + } + + if (failures.length > 0) { + throw new AggregateError(failures, `${failures.length} stories failed to render.`); + } + this.output.write(`Rendered ${entries.length} stories.\n`); + } + + private async selectStory(serverUrl: string, storyId: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + do { + try { + await this.getJson( + new URL(`/select-story-sync/${encodeURIComponent(storyId)}`, serverUrl), + { method: 'POST' }, + Math.min(5000, Math.max(1, deadline - Date.now())), + ); + return; + } catch (error) { + lastError = error; + const remainingMs = deadline - Date.now(); + if (remainingMs > 0) { + await delay(Math.min(500, remainingMs)); + } + } + } while (Date.now() < deadline); + throw lastError; + } + + private async waitForUrl(url: string, process: RunningDesktopCommand, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + const state = await Promise.race([ + process.completed.then((exitCode) => ({ exitCode })), + this.fetchWithTimeout(url, undefined, Math.min(5000, Math.max(1, deadline - Date.now()))) + .then((response) => { + if (!response.ok) { + throw new Error(`${url} returned ${response.status}.`); + } + return { ready: true as const }; + }) + .catch((error: unknown) => ({ error })), + ]); + if ('exitCode' in state) { + throw new Error(`Background command exited with code ${state.exitCode} before ${url} became ready.`); + } + if ('ready' in state) { + return; + } + lastError = state.error; + await delay(500); + } + + throw new Error(`Timed out waiting for ${url}: ${(lastError as Error)?.message ?? 'not ready'}`); + } + + private async getJson(url: URL, init?: RequestInit, timeoutMs?: number): Promise> { + const response = timeoutMs ? await this.fetchWithTimeout(url, init, timeoutMs) : await this.fetch(url, init); + const body = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + throw new Error((body.error as string | undefined) ?? `${url.href} returned ${response.status}.`); + } + return body; + } + + private async fetchWithTimeout(input: string | URL, init: RequestInit | undefined, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await this.fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } + + private async executePlan(plan: DesktopCommandPlan, platform: Platforms, instance?: ResolvedDesktopStorybookInstance): Promise { + for (const command of Array.isArray(plan) ? plan : [plan]) { + await this.runner.run(this.prepareCommand(command, platform, instance)); + } + } + + private prepareCommand( + command: DesktopCommand, + platform: Platforms, + instance?: ResolvedDesktopStorybookInstance, + ): PreparedDesktopCommand { + return { + ...command, + args: command.args ?? [], + cwd: command.cwd ? resolveFromProject(this.config.projectRoot, command.cwd) : this.config.projectRoot, + env: { + ...command.env, + [FURN_STORYBOOK_PLATFORM]: platform, + ...(instance + ? { + [FURN_STORYBOOK_INSTANCE_ID]: instance.id, + [FURN_STORYBOOK_BUNDLE_IDENTIFIER]: instance.bundleIdentifier, + STORYBOOK_WS_PORT: String(instance.storybookPort), + STORYBOOK_DRIVER_PORT: String(instance.driverPort), + ...(instance.driverManifestPath ? { STORYBOOK_DRIVER_MANIFEST: instance.driverManifestPath } : {}), + RCT_METRO_PORT: String(instance.metroPort), + ...(instance.macosXcconfigPath ? { XCODE_XCCONFIG_FILE: instance.macosXcconfigPath } : {}), + } + : {}), + }, + }; + } +} + +function readReusableBridgeNonce( + manifestPath: string, + instance: DesktopStorybookInstance, + platformManifestDigest: string, +): string | undefined { + if (!fs.existsSync(manifestPath)) { + return undefined; + } + try { + const current = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + return current.instanceId === instance.id && + current.storybookPort === instance.storybookPort && + current.driverPort === instance.driverPort && + current.platformManifestDigest === platformManifestDigest && + typeof current.bridgeNonce === 'string' + ? current.bridgeNonce + : undefined; + } catch { + return undefined; + } +} + +function defaultMetroCommand(instance: DesktopStorybookInstance): DesktopCommand { + return { + command: 'rnx-cli', + args: ['start', '--no-interactive', '--port', String(instance.metroPort)], + }; +} + +function writeMacOSInstanceConfig(projectRoot: string, instance: DesktopStorybookInstance): string { + const instanceDirectory = path.join(projectRoot, 'macos', '.storybook-desktop'); + const xcconfigPath = path.join(instanceDirectory, `${instance.id}.xcconfig`); + const content = [ + `PRODUCT_BUNDLE_IDENTIFIER = ${instance.bundleIdentifier}`, + `GCC_PREPROCESSOR_DEFINITIONS = $(inherited) RCT_METRO_PORT=${instance.metroPort}`, + '', + ].join('\n'); + + fs.mkdirSync(instanceDirectory, { recursive: true }); + if (!fs.existsSync(xcconfigPath) || fs.readFileSync(xcconfigPath, 'utf8') !== content) { + fs.writeFileSync(xcconfigPath, content); + } + return xcconfigPath; +} + +async function findAvailablePort( + preferredPort: number, + isPortAvailable: (port: number) => Promise, + excludedPorts: ReadonlySet = new Set(), +): Promise { + for (let offset = 0; offset < 1000; offset += 1) { + const port = preferredPort + offset; + if (!excludedPorts.has(port) && (await isPortAvailable(port))) { + return port; + } + } + throw new Error(`Could not find an available port near ${preferredPort}.`); +} + +function isLoopbackPortAvailable(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE' || error.code === 'EACCES') { + resolve(false); + } else { + reject(error); + } + }); + server.listen(port, '127.0.0.1', () => { + server.close((error) => (error ? reject(error) : resolve(true))); + }); + }); +} + +function portFromUrl(url: string): number { + const parsedUrl = new URL(url); + const port = Number(parsedUrl.port); + if (!port) { + throw new TypeError(`Smoke service URL "${url}" must include an explicit port.`); + } + return port; +} + +function loopbackUrl(port: number, pathname = ''): string { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- native Storybook services are loopback-only + return `http://127.0.0.1:${port}${pathname}`; +} + +function unsupportedAction(action: DesktopStorybookAction, platform: Platforms): Error { + return new Error( + `Desktop Storybook ${action} is not configured for ${platform}. Set platformOptions.${platform}.${action} in storybook.config.ts.`, + ); +} + +function validateServerOptions(options: DesktopStorybookServerOptions): void { + if (options.host !== undefined && !options.host.trim()) { + throw new TypeError('Storybook server host cannot be empty.'); + } + if (options.port !== undefined && (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535)) { + throw new RangeError(`Storybook server port must be an integer between 1 and 65535. Received "${options.port}".`); + } +} + +function validateDriverHost(host: string | undefined): void { + if (host !== undefined && host !== '127.0.0.1' && host !== 'localhost') { + throw new TypeError('The embedded Desktop Driver supports only the 127.0.0.1 and localhost loopback hosts.'); + } +} + +function resolveFromProject(projectRoot: string, target: string): string { + return path.isAbsolute(target) ? target : path.resolve(projectRoot, target); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function resolveSmokeMode(mode: DesktopSmokeMode | undefined): DesktopSmokeMode { + const resolvedMode = mode ?? 'stories'; + if (!desktopSmokeModes.includes(resolvedMode)) { + throw new TypeError(`Smoke mode must be one of ${desktopSmokeModes.join(', ')}. Received "${resolvedMode}".`); + } + return resolvedMode; +} + +function withEnvironment(plan: DesktopCommandPlan, environment: Readonly>): DesktopCommandPlan { + const commands = Array.isArray(plan) ? plan : [plan]; + const prepared = commands.map((command) => ({ + ...command, + env: { + ...command.env, + ...environment, + }, + })); + return Array.isArray(plan) ? prepared : prepared[0]; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/packages/agentic/storybook-desktop/src/cli/README.md b/packages/agentic/storybook-desktop/src/cli/README.md new file mode 100644 index 00000000000..21968f026cc --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/README.md @@ -0,0 +1,180 @@ +# Desktop Storybook CLI integration + +Consuming packages should expose the shared CLI rather than creating one script +per platform or wrapping individual implementation files. Keep package scripts +small so command arguments continue to reach the selected subcommand: + +```json +{ + "scripts": { + "storybook": "storybook-desktop", + "storybook-server": "storybook-desktop server", + "start": "rnx-cli start" + } +} +``` + +With Yarn, arguments after a script name are appended to its command. The +server wrapper therefore supports the complete shared command directly: + +```sh +yarn storybook-server --win32 +yarn storybook-server --windows --host 127.0.0.1 --port 7100 +``` + +Do not add `storybook-server:macos`, `storybook-server:windows`, or +`storybook-server:win32` aliases. The package also publishes a direct +`storybook-server` binary with the same platform and connection options. + +## Platform selection + +Every command that depends on the story catalog accepts exactly one of +`--macos`, `--windows`, or `--win32`. When no option is present, selection uses: + +1. `FURN_STORYBOOK_PLATFORM`; +2. the host default (`macos` on macOS or `windows` on Windows). + +Win32 is never selected implicitly because a Windows machine may contain both +the Windows Fabric app and a Win32 Paper host. Use `--win32` for one command or +set `FURN_STORYBOOK_PLATFORM=win32` for a multi-process workflow. + +Prefer an explicit option for isolated commands: + +```sh +yarn storybook bundle --windows +``` + +Prefer environment injection for workflows where the server, Metro, native +app, and test runner must all resolve the same platform: + +```sh +FURN_STORYBOOK_PLATFORM=win32 yarn storybook-server +FURN_STORYBOOK_PLATFORM=win32 yarn start +FURN_STORYBOOK_PLATFORM=win32 yarn storybook run +``` + +Set the environment through the shell, CI matrix, or process supervisor using +the syntax appropriate for that environment. A `--config ` option may be +placed before the subcommand when the configuration does not use a standard +root filename. + +## Command responsibilities + +| Command | Responsibility | +| -------- | ------------------------------------------------------------------------------------------------- | +| `server` | Start the Storybook channel, REST control, and MCP server for the selected catalog. | +| `prep` | Install or generate native prerequisites. Run after checkout and when native dependencies change. | +| `bundle` | Generate the selected story catalog and produce its release JavaScript bundle through `rnx-cli`. | +| `build` | Build the selected native project without launching it. | +| `run` | Build and launch the selected native app or configured prebuilt host. | +| `smoke` | Own the server, Metro, app launch, all-story traversal, optional authored tests, and cleanup. | + +The TypeScript API exposes the same operations through +`DesktopStorybookCli`. Use it when a test coordinator needs injected process +runners, structured lifecycle ownership, or composition with other tasks. + +## Development workflows + +### Metro-backed development + +Use separate terminals or an ownership-aware process supervisor: + +```sh +yarn storybook-server +yarn start +yarn storybook run +``` + +Run `yarn storybook prep` before the first native launch and after changes that +invalidate generated native projects or dependencies. The `run` command +includes the native build; use `build` when launch or deployment is not +desired. + +When the host default is not the intended endpoint, select the same platform +for all three processes. A flag passed to `storybook-server` does not mutate +the parent shell, so use `FURN_STORYBOOK_PLATFORM` for the complete session +when Metro also performs platform-specific story generation. + +### Bundle-backed development + +For a host configured to load an embedded bundle: + +```sh +yarn storybook bundle --windows +yarn storybook run --windows +``` + +The consuming config must make `run` launch the matching release or prebuilt +host, and the native manifest must package the bundle output. Bundling alone +does not change a Debug app into an offline app. Start `storybook-server` as a +separate process when the embedded app still needs external story control; no +Metro process is required. + +## End-to-end tests + +Use `smoke` for the standard renderability gate: + +```sh +yarn storybook smoke --macos --mode stories +yarn storybook smoke --windows --mode stories +yarn storybook smoke --win32 --mode stories + +yarn storybook smoke --windows --mode stories-and-tests +yarn storybook smoke --win32 --mode stories-and-tests +``` + +`stories` is the default and traverses the complete indexed catalog. +`stories-and-tests` performs the same traversal and then runs the +component-authored `desktop-e2e` plans against the Stage 1 manifest-derived +fake target; native plan execution begins with the Stage 2 providers. Both modes are preferable to a shell +chain because they own the exact server, Metro, app identity, traversal, test +session, and cleanup. Consumer configuration should provide any platform-specific +app stop command. For Windows Fabric and the prebuilt REX Win32 host, configure the package-owned commands returned by +`createWindowsSmokeOptions`, `createWin32RunCommand`, and +`createWin32SmokeCommand`; do not copy lifecycle scripts into the consumer. + +For a broader E2E suite, let the test coordinator own the service processes: + +1. Start `storybook server` and Metro with one platform environment. +2. Run or attach to the native app. +3. Wait for the server and app readiness contracts. +4. Execute tests through stable story IDs and native selectors. +5. Stop only the app and process IDs created by that test session. + +Do not use unowned background shell processes in E2E scripts. Prefer the API or +a test-runner service that guarantees cleanup after setup, test, or teardown +failure. + +## CI workflows + +Set `FURN_STORYBOOK_PLATFORM` as a matrix value and keep stages as separate +commands: + +```sh +yarn storybook prep +yarn storybook bundle +yarn storybook build +``` + +Separate stages provide clear failure attribution and allow native generation, +bundles, and build outputs to be cached independently. Run `smoke` only on a +runner that can launch and interact with the desktop session. + +Avoid a script such as +`storybook-desktop bundle && storybook-desktop build` when callers need to +append a platform option: package-manager arguments reach only the final +command. Use the matrix environment for multi-command jobs or invoke each +command with its own explicit platform option. + +## Agent workflows + +Agents should use `smoke` for unattended validation because its bounded +lifecycle reports story progress and cleans up owned resources. For exploratory +interaction, an agent supervisor may start `server`, Metro, and `run` +separately, then use the REST or MCP surface to select and inspect stories. + +The supervisor must record owned processes and preserve unrelated development +servers or app instances. Reusable smoke runs already derive isolated ports and +native identity from the consuming project root; custom agent workflows should +provide equivalent ownership rather than stopping processes by name or fixed +port. diff --git a/packages/agentic/storybook-desktop/src/cli/commandRunner.ts b/packages/agentic/storybook-desktop/src/cli/commandRunner.ts new file mode 100644 index 00000000000..4a4a4d0cdcf --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/commandRunner.ts @@ -0,0 +1,127 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +import type { DesktopCommand } from '../config/commands.js'; + +export type PreparedDesktopCommand = DesktopCommand & { + args: readonly string[]; + cwd: string; + env: Readonly>; +}; + +export type RunningDesktopCommand = { + completed: Promise; + stop(): Promise; +}; + +export interface DesktopCommandRunner { + run(command: PreparedDesktopCommand): Promise; + start(command: PreparedDesktopCommand): RunningDesktopCommand; +} + +export class NodeDesktopCommandRunner implements DesktopCommandRunner { + async run(command: PreparedDesktopCommand): Promise { + const child = spawnCommand(command, false); + const exitCode = await completed(child); + if (exitCode !== 0) { + throw new Error(formatCommandFailure(command, exitCode)); + } + } + + start(command: PreparedDesktopCommand): RunningDesktopCommand { + const child = spawnCommand(command, true); + const completion = completed(child); + let stopped = false; + + return { + completed: completion, + async stop() { + if (stopped || child.exitCode !== null || child.signalCode !== null) { + return; + } + stopped = true; + await stopChildProcess(child.pid, completion); + }, + }; + } +} + +function spawnCommand(command: PreparedDesktopCommand, background: boolean) { + return spawn(command.command, [...command.args], { + cwd: command.cwd, + detached: background && process.platform !== 'win32', + env: { ...process.env, ...command.env }, + shell: requiresWindowsShell(command.command), + stdio: 'inherit', + }); +} + +function requiresWindowsShell(command: string): boolean { + if (process.platform !== 'win32') { + return false; + } + + const executable = path.basename(command).toLowerCase(); + return path.extname(executable) !== '.exe' && !['node', 'powershell', 'pwsh'].includes(executable); +} + +function completed(child: ReturnType): Promise { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); +} + +async function stopChildProcess(pid: number | undefined, completion: Promise): Promise { + if (!pid) { + return; + } + + if (process.platform === 'win32') { + await stopWindowsProcessTree(pid); + await completion; + return; + } + + try { + process.kill(-pid, 'SIGTERM'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + } + + const stopped = await Promise.race([completion.then(() => true), delay(5000).then(() => false)]); + if (!stopped) { + try { + process.kill(-pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + } + await completion; + } +} + +async function stopWindowsProcessTree(pid: number): Promise { + const command: PreparedDesktopCommand = { + command: process.env.ComSpec ?? 'cmd.exe', + args: ['/d', '/s', '/c', `taskkill /PID ${pid} /T /F`], + cwd: path.parse(process.cwd()).root, + env: {}, + }; + const child = spawnCommand(command, false); + const exitCode = await completed(child); + if (exitCode !== 0) { + throw new Error(`Could not stop owned process tree ${pid}.`); + } +} + +function formatCommandFailure(command: PreparedDesktopCommand, exitCode: number): string { + return `Command "${[command.command, ...command.args].join(' ')}" exited with code ${exitCode}.`; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts new file mode 100644 index 00000000000..52fe80ae633 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts @@ -0,0 +1,176 @@ +import { Command, Option } from 'commander'; + +import { desktopSmokeModes, type DesktopSmokeMode } from '../config/commands.js'; +import type { DesktopStorybookConfig } from '../config/makeDesktopStorybookConfig.js'; +import type { Platforms } from '../config/platforms.js'; +import type { DesktopStorybookCliOptions, DesktopStorybookServerOptions } from './DesktopStorybookCli.js'; +import { DesktopStorybookCli } from './DesktopStorybookCli.js'; +import { loadDesktopStorybookConfig } from './loadConfig.js'; + +type PlatformFlags = { + macos?: boolean; + win32?: boolean; + windows?: boolean; +}; + +type ServerFlags = PlatformFlags & DesktopStorybookServerOptions; +type ManifestFlags = PlatformFlags & { out?: string }; +type SmokeFlags = PlatformFlags & { mode: DesktopSmokeMode }; + +export type CreateDesktopStorybookCommandOptions = DesktopStorybookCliOptions & { + config?: DesktopStorybookConfig; + cwd?: string; +}; + +export function createDesktopStorybookCommand(options: CreateDesktopStorybookCommandOptions = {}): Command { + const program = new Command() + .name('storybook-desktop') + .description('Serve, prepare, bundle, build, run, and smoke test a React Native desktop Storybook app.') + .option('-c, --config ', 'path to storybook.config.ts'); + let apiPromise: Promise | undefined; + + const getApi = () => + (apiPromise ??= Promise.resolve( + options.config ?? loadDesktopStorybookConfig(program.opts<{ config?: string }>().config, options.cwd), + ).then( + (config) => + new DesktopStorybookCli(config, { + runner: options.runner, + createStoryManifest: options.createStoryManifest, + fetch: options.fetch, + output: options.output, + isPortAvailable: options.isPortAvailable, + runSmokeTests: options.runSmokeTests, + }), + )); + + addServerCommand(program, getApi); + addDriverCommand(program, getApi); + addManifestCommand(program, getApi); + addInstanceCommand(program, getApi); + addActionCommand(program, 'prep', 'Prepare native dependencies and generated projects.', getApi); + addActionCommand(program, 'bundle', 'Generate stories and create the platform JavaScript bundle.', getApi); + addActionCommand(program, 'run', 'Build and launch the native Storybook app.', getApi); + addActionCommand(program, 'build', 'Build the native Storybook app without launching it.', getApi); + addSmokeCommand(program, getApi); + + return program; +} + +function addDriverCommand(program: Command, getApi: () => Promise): void { + const command = program + .command('driver') + .description('Start the Storybook channel, MCP, and embedded Desktop Driver servers.') + .option('--host ', 'server host; defaults to STORYBOOK_WS_HOST or 127.0.0.1') + .option('--port ', 'Storybook channel port; defaults to the enlistment-specific port', parsePort); + addPlatformOptions(command); + command.action(async (flags: ServerFlags) => { + const api = await getApi(); + await api.driver(resolvePlatform(flags, api), { host: flags.host, port: flags.port }); + }); +} + +function addManifestCommand(program: Command, getApi: () => Promise): void { + const command = program.command('manifest').description('Generate the platform Story Manifest.').option('--out ', 'output path'); + addPlatformOptions(command); + command.action(async (flags: ManifestFlags) => { + const api = await getApi(); + await api.manifest(resolvePlatform(flags, api), flags.out); + }); +} + +function addInstanceCommand(program: Command, getApi: () => Promise): void { + const command = program.command('instance').description('Print the platform instance identity as JSON.'); + addPlatformOptions(command); + command.action(async (flags: PlatformFlags) => { + const api = await getApi(); + api.printInstance(resolvePlatform(flags, api)); + }); +} + +export async function runDesktopStorybookCli(argv: readonly string[] = process.argv): Promise { + await createDesktopStorybookCommand().parseAsync([...argv]); +} + +function addActionCommand( + program: Command, + action: 'prep' | 'bundle' | 'run' | 'build', + description: string, + getApi: () => Promise, +): void { + const command = program.command(action).description(description); + addPlatformOptions(command); + command.action(async (flags: PlatformFlags) => { + const api = await getApi(); + const platform = resolvePlatform(flags, api); + await api[action](platform); + }); +} + +function addSmokeCommand(program: Command, getApi: () => Promise): void { + const command = program + .command('smoke') + .description('Launch the app, traverse every story, optionally run authored tests, and shut the app down.') + .addOption( + new Option('--mode ', 'coverage mode: traverse stories only, or traverse stories and run authored tests') + .choices([...desktopSmokeModes]) + .default('stories'), + ); + addPlatformOptions(command); + command.action(async (flags: SmokeFlags) => { + const api = await getApi(); + await api.smoke(resolvePlatform(flags, api), { mode: flags.mode }); + }); +} + +function addServerCommand(program: Command, getApi: () => Promise): void { + const command = program + .command('server') + .description('Start the Storybook channel and MCP server.') + .option('--host ', 'server host; defaults to STORYBOOK_WS_HOST or 127.0.0.1') + .option('--port ', 'server port; defaults to STORYBOOK_WS_PORT or 7007', parsePort); + addPlatformOptions(command); + command.action(async (flags: ServerFlags) => { + const api = await getApi(); + await api.server(resolvePlatform(flags, api), { + host: flags.host, + port: flags.port, + }); + }); +} + +function addPlatformOptions(command: Command): void { + command + .addOption(new Option('--windows', 'target React Native Windows').conflicts(['macos', 'win32'])) + .addOption(new Option('--macos', 'target React Native macOS').conflicts(['windows', 'win32'])) + .addOption(new Option('--win32', 'target React Native Win32').conflicts(['windows', 'macos'])); +} + +function selectedPlatform(flags: PlatformFlags): Platforms | undefined { + if (flags.windows) { + return 'windows'; + } + if (flags.macos) { + return 'macos'; + } + if (flags.win32) { + return 'win32'; + } + return undefined; +} + +function resolvePlatform(flags: PlatformFlags, api: DesktopStorybookCli): Platforms { + const platform = selectedPlatform(flags) ?? api.config.platform; + if (!platform) { + throw new Error('Select --windows, --macos, or --win32, or set FURN_STORYBOOK_PLATFORM.'); + } + return platform; +} + +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new TypeError(`Server port must be an integer between 1 and 65535. Received "${value}".`); + } + return port; +} diff --git a/packages/agentic/storybook-desktop/src/cli/index.ts b/packages/agentic/storybook-desktop/src/cli/index.ts new file mode 100644 index 00000000000..5f2c71962f8 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/index.ts @@ -0,0 +1,13 @@ +export { + createDesktopStorybookCommand, + runDesktopStorybookCli, + type CreateDesktopStorybookCommandOptions, +} from './createDesktopStorybookCommand.js'; +export { DesktopStorybookCli, type DesktopStorybookCliOptions, type DesktopStorybookServerOptions } from './DesktopStorybookCli.js'; +export { + NodeDesktopCommandRunner, + type DesktopCommandRunner, + type PreparedDesktopCommand, + type RunningDesktopCommand, +} from './commandRunner.js'; +export { loadDesktopStorybookConfig } from './loadConfig.js'; diff --git a/packages/agentic/storybook-desktop/src/cli/loadConfig.ts b/packages/agentic/storybook-desktop/src/cli/loadConfig.ts new file mode 100644 index 00000000000..0e3953fbf20 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/loadConfig.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { DesktopStorybookConfig } from '../config/makeDesktopStorybookConfig.js'; + +const defaultConfigNames = [ + 'storybook.config.ts', + 'storybook.config.mts', + 'storybook.config.js', + 'storybook.config.mjs', + 'storybook.config.cjs', +] as const; + +export async function loadDesktopStorybookConfig(configPath?: string, cwd = process.cwd()): Promise { + const resolvedPath = configPath ? resolveConfigPath(configPath, cwd) : findConfigPath(cwd); + const loadedModule = (await import(pathToFileURL(resolvedPath).href)) as Record; + const config = loadedModule.default ?? loadedModule.config; + if (!(config instanceof DesktopStorybookConfig)) { + throw new TypeError(`Desktop Storybook config at ${resolvedPath} must default-export the result of makeDesktopStorybookConfig().`); + } + return config; +} + +function resolveConfigPath(configPath: string, cwd: string): string { + const resolvedPath = path.isAbsolute(configPath) ? configPath : path.resolve(cwd, configPath); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Desktop Storybook config does not exist at ${resolvedPath}.`); + } + return resolvedPath; +} + +function findConfigPath(cwd: string): string { + for (const configName of defaultConfigNames) { + const configPath = path.resolve(cwd, configName); + if (fs.existsSync(configPath)) { + return configPath; + } + } + throw new Error(`Could not find ${defaultConfigNames.join(', or ')} beneath ${cwd}.`); +} diff --git a/packages/agentic/storybook-desktop/src/cli/smokeTests.test.ts b/packages/agentic/storybook-desktop/src/cli/smokeTests.test.ts new file mode 100644 index 00000000000..7bbcb3b4658 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/smokeTests.test.ts @@ -0,0 +1,111 @@ +import path from 'node:path'; + +import type { DesktopStoryRunResult } from '@fluentui-react-native/desktop-driver/authoring'; + +import { formatDesktopStorybookSmokeTestSummary, runDesktopStorybookSmokeTests, type DesktopStorybookSmokeConnector } from './smokeTests'; + +const passedResult: DesktopStoryRunResult = { + endpoint: 'windows', + finishedAt: '2026-08-30T08:00:01.000Z', + manifest: { + platform: 'windows-digest', + portable: 'portable-digest', + }, + platformName: 'windows', + runId: 'smoke-run', + schemaVersion: 1, + startedAt: '2026-08-30T08:00:00.000Z', + status: 'passed', + targetId: 'storybook-windows', + tests: [ + { + artifacts: [], + durationMs: 10, + status: 'passed', + steps: [], + storyId: 'components-button--default', + testId: 'pointer-focus', + title: 'Pointer focus', + }, + { + artifacts: [], + durationMs: 0, + skipReason: 'Unsupported capabilities: focus', + status: 'skipped', + steps: [], + storyId: 'components-button--default', + testId: 'keyboard-focus', + title: 'Keyboard focus', + }, + ], +}; + +describe('runDesktopStorybookSmokeTests', () => { + test('runs desktop-e2e plans under the platform artifact root and closes the session', async () => { + const runStoryTests = jest.fn(async () => passedResult); + const deleteSession = jest.fn(async () => undefined); + const connect: DesktopStorybookSmokeConnector = jest.fn(async () => ({ + delete: deleteSession, + runStoryTests, + })); + + const result = await runDesktopStorybookSmokeTests( + { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback service + driverUrl: 'http://127.0.0.1:4444', + platform: 'windows', + projectRoot: 'C:\\repo\\storybook', + targetId: 'storybook-windows', + }, + connect, + ); + + expect(connect).toHaveBeenCalledWith({ + platformName: 'windows', + targetId: 'storybook-windows', + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback service + url: 'http://127.0.0.1:4444', + }); + expect(runStoryTests).toHaveBeenCalledWith({ + artifactsRoot: path.join('C:\\repo\\storybook', 'artifacts', 'windows', 'desktop-driver'), + selection: { + tag: 'desktop-e2e', + }, + }); + expect(deleteSession).toHaveBeenCalledTimes(1); + expect(result).toBe(passedResult); + expect(formatDesktopStorybookSmokeTestSummary(result)).toBe('Ran 2 desktop story tests (1 passed, 1 skipped).'); + }); + + test('fails the smoke run after preserving a failed result and closing the session', async () => { + const failedResult: DesktopStoryRunResult = { + ...passedResult, + status: 'failed', + tests: [ + { + ...passedResult.tests[0], + status: 'timed-out', + }, + ], + }; + const deleteSession = jest.fn(async () => undefined); + const connect: DesktopStorybookSmokeConnector = async () => ({ + delete: deleteSession, + runStoryTests: async () => failedResult, + }); + + await expect( + runDesktopStorybookSmokeTests( + { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback service + driverUrl: 'http://127.0.0.1:4444', + platform: 'windows', + projectRoot: 'C:\\repo\\storybook', + targetId: 'storybook-windows', + }, + connect, + ), + ).rejects.toThrow('components-button--default/pointer-focus (timed-out)'); + expect(deleteSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/agentic/storybook-desktop/src/cli/smokeTests.ts b/packages/agentic/storybook-desktop/src/cli/smokeTests.ts new file mode 100644 index 00000000000..0e6625c9b74 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/smokeTests.ts @@ -0,0 +1,74 @@ +import path from 'node:path'; + +import type { DesktopStoryRunResult } from '@fluentui-react-native/desktop-driver/authoring'; +import { connectDesktopWebdriver } from '@fluentui-react-native/desktop-driver/wdio'; +import type { DesktopWebdriverOptions, DesktopWebdriverSession } from '@fluentui-react-native/desktop-driver/wdio'; + +import type { Platforms } from '../config/platforms.js'; + +type DesktopStorybookSmokeSession = Pick; + +export type DesktopStorybookSmokeConnector = (options: DesktopWebdriverOptions) => Promise; + +export type DesktopStorybookSmokeTestOptions = { + artifactsRoot?: string; + driverUrl: string; + platform: Platforms; + projectRoot: string; + targetId: string; +}; + +export async function runDesktopStorybookSmokeTests( + options: DesktopStorybookSmokeTestOptions, + connect: DesktopStorybookSmokeConnector = connectDesktopWebdriver, +): Promise { + const desktop = await connect({ + platformName: options.platform === 'macos' ? 'macos' : 'windows', + targetId: options.targetId, + url: options.driverUrl, + }); + let result: DesktopStoryRunResult | undefined; + let runFailure: unknown; + + try { + result = await desktop.runStoryTests({ + artifactsRoot: options.artifactsRoot ?? path.join(options.projectRoot, 'artifacts', options.platform, 'desktop-driver'), + selection: { + tag: 'desktop-e2e', + }, + }); + } catch (error) { + runFailure = error; + } + + try { + await desktop.delete(); + } catch (error) { + if (runFailure !== undefined) { + throw new AggregateError([runFailure, error], 'Desktop story tests and session cleanup both failed.'); + } + throw error; + } + + if (runFailure !== undefined) { + throw runFailure; + } + if (!result) { + throw new Error('Desktop story tests completed without a result.'); + } + if (result.status !== 'passed') { + const failedTests = result.tests.filter(({ status }) => status !== 'passed' && status !== 'skipped'); + throw new Error( + `${failedTests.length} of ${result.tests.length} desktop story tests failed: ${failedTests + .map(({ status, storyId, testId }) => `${storyId}/${testId} (${status})`) + .join(', ')}`, + ); + } + return result; +} + +export function formatDesktopStorybookSmokeTestSummary(result: DesktopStoryRunResult): string { + const passed = result.tests.filter(({ status }) => status === 'passed').length; + const skipped = result.tests.filter(({ status }) => status === 'skipped').length; + return `Ran ${result.tests.length} desktop story tests (${passed} passed, ${skipped} skipped).`; +} diff --git a/packages/agentic/storybook-desktop/src/config/commands.ts b/packages/agentic/storybook-desktop/src/config/commands.ts new file mode 100644 index 00000000000..09550fda9ce --- /dev/null +++ b/packages/agentic/storybook-desktop/src/config/commands.ts @@ -0,0 +1,215 @@ +import path from 'node:path'; +import { createRequire } from 'node:module'; + +import type { Platforms } from './platforms.ts'; + +export const desktopStorybookActions = ['server', 'prep', 'bundle', 'run', 'build', 'smoke'] as const; + +export type DesktopStorybookAction = (typeof desktopStorybookActions)[number]; + +export const desktopSmokeModes = ['stories', 'stories-and-tests'] as const; + +export type DesktopSmokeMode = (typeof desktopSmokeModes)[number]; + +export type DesktopSmokeRunOptions = { + /** + * `stories` traverses the complete indexed catalog. `stories-and-tests` also + * runs component-authored desktop-e2e plans after traversal. + * @default "stories" + */ + mode?: DesktopSmokeMode; +}; + +export const STORYBOOK_SMOKE_MODE = 'STORYBOOK_SMOKE_MODE'; + +/** + * A command launched from the consuming Storybook app. + */ +export type DesktopCommand = { + command: string; + args?: readonly string[]; + cwd?: string; + env?: Readonly>; +}; + +export type DesktopCommandPlan = DesktopCommand | readonly DesktopCommand[]; + +export type DesktopNativeProjectOptions = { + /** + * Xcode workspace used by rnx-cli on macOS. + * @default "macos/.xcworkspace" + */ + workspace?: string; + + /** + * Xcode scheme used by rnx-cli on macOS. + * @default appName + */ + scheme?: string; + + /** + * Visual Studio solution used by rnx-cli on Windows. + * @default "windows/.sln" + */ + solution?: string; + + configuration?: 'Debug' | 'Release'; + destination?: 'device' | 'emulator' | 'simulator'; + device?: string; +}; + +export type DesktopSmokeOptions = { + /** + * A complete app-owned smoke command. When set, the reusable server, Metro, traversal, and cleanup lifecycle is skipped. + */ + command?: DesktopCommandPlan; + + /** + * Channel server to run while smoke testing. + * @default storybook-server + */ + server?: DesktopCommand | false; + + /** + * Metro server to run while smoke testing. + * @default rnx-cli start --no-interactive + */ + metro?: DesktopCommand | false; + + /** + * App-owned command that stops the launched native application. Required for the reusable smoke lifecycle. + */ + stop?: DesktopCommandPlan; + + /** + * Storybook channel server URL. + * @default http://127.0.0.1:7007 + */ + serverUrl?: string; + + /** + * Metro status URL. + * @default http://127.0.0.1:8081/status + */ + metroUrl?: string; + + /** + * Maximum time to wait for the channel server and Metro. + * @default 120000 + */ + startupTimeoutMs?: number; + + /** + * Delay after selecting each story. + * @default 0 + */ + settleMs?: number; +}; + +export type DesktopPlatformOptions = { + nativeProject?: DesktopNativeProjectOptions; + + /** + * Override an action's default command plan. Set an action to false when it is intentionally unsupported. + */ + server?: DesktopCommand | false; + prep?: DesktopCommandPlan | false; + bundle?: DesktopCommandPlan | false; + run?: DesktopCommandPlan | false; + build?: DesktopCommandPlan | false; + smoke?: DesktopSmokeOptions | false; +}; + +export type DesktopPlatformOptionsMap = Partial>; + +export type WindowsSmokeCommandOptions = { + configuration?: 'Debug' | 'Release'; + windowTitle: string; +}; + +export type WindowsSmokeOptions = WindowsSmokeCommandOptions & { + /** + * React Native Test App's Windows Debug host reads Metro from this fixed port. + * @default 8081 + */ + metroPort?: number; +}; + +export type Win32HostCommandOptions = { + component: string; + windowTitle: string; +}; + +export type Win32SmokeCommandOptions = Win32HostCommandOptions & { + requiredStoryIds?: readonly string[]; + testIDPrefix: string; +}; + +/** + * Creates the shared Windows Fabric smoke lifecycle command. + */ +export function createWindowsSmokeCommand({ configuration = 'Debug', windowTitle }: WindowsSmokeCommandOptions): DesktopCommand { + return { + command: 'pwsh', + args: ['-NoProfile', '-File', resolveConfigScript('smoke-windows.ps1')], + env: { + STORYBOOK_WINDOWS_CONFIGURATION: configuration, + STORYBOOK_WINDOWS_WINDOW_TITLE: windowTitle, + }, + }; +} + +/** + * Creates the shared Windows Fabric smoke lifecycle and its native Metro-port constraint. + */ +export function createWindowsSmokeOptions({ configuration, metroPort = 8081, windowTitle }: WindowsSmokeOptions): DesktopSmokeOptions { + if (!Number.isInteger(metroPort) || metroPort < 1 || metroPort > 65_535) { + throw new RangeError(`Windows smoke Metro port must be an integer between 1 and 65535. Received "${metroPort}".`); + } + return { + command: createWindowsSmokeCommand({ configuration, windowTitle }), + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- React Native Test App connects to loopback Metro + metroUrl: `http://127.0.0.1:${metroPort}/status`, + }; +} + +/** + * Creates the shared prebuilt REX Win32 host command. + */ +export function createWin32RunCommand({ component, windowTitle }: Win32HostCommandOptions): DesktopCommand { + return { + command: process.execPath, + args: [resolveConfigScript('run-win32.cjs')], + env: { + STORYBOOK_WIN32_COMPONENT: component, + STORYBOOK_WIN32_WINDOW_TITLE: windowTitle, + }, + }; +} + +/** + * Creates the shared Win32 bundle, native UX, and story traversal smoke lifecycle command. + */ +export function createWin32SmokeCommand({ + component, + requiredStoryIds = [], + testIDPrefix, + windowTitle, +}: Win32SmokeCommandOptions): DesktopCommand { + return { + command: 'pwsh', + args: ['-NoProfile', '-File', resolveConfigScript('smoke-win32.ps1')], + env: { + STORYBOOK_TEST_ID_PREFIX: testIDPrefix, + STORYBOOK_WIN32_COMPONENT: component, + STORYBOOK_WIN32_REQUIRED_STORIES: requiredStoryIds.join(','), + STORYBOOK_WIN32_WINDOW_TITLE: windowTitle, + }, + }; +} + +function resolveConfigScript(fileName: string): string { + const workspaceRequire = createRequire(path.resolve(process.cwd(), 'package.json')); + const packageJsonPath = workspaceRequire.resolve('@fluentui-react-native/storybook-desktop/package.json'); + return path.resolve(path.dirname(packageJsonPath), 'config', fileName); +} diff --git a/packages/agentic/storybook-desktop/src/config/index.ts b/packages/agentic/storybook-desktop/src/config/index.ts new file mode 100644 index 00000000000..89360a4c967 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/config/index.ts @@ -0,0 +1,41 @@ +export { + createWindowsSmokeCommand, + createWindowsSmokeOptions, + createWin32RunCommand, + createWin32SmokeCommand, + desktopSmokeModes, + desktopStorybookActions, + STORYBOOK_SMOKE_MODE, + type DesktopCommand, + type DesktopCommandPlan, + type DesktopNativeProjectOptions, + type DesktopPlatformOptions, + type DesktopPlatformOptionsMap, + type DesktopSmokeMode, + type DesktopSmokeOptions, + type DesktopSmokeRunOptions, + type DesktopStorybookAction, + type WindowsSmokeCommandOptions, + type WindowsSmokeOptions, + type Win32HostCommandOptions, + type Win32SmokeCommandOptions, +} from './commands.ts'; +export { + createDesktopStorybookInstance, + FURN_STORYBOOK_BUNDLE_IDENTIFIER, + FURN_STORYBOOK_INSTANCE_ID, + type DesktopStorybookInstance, + type DesktopStorybookInstanceOptions, +} from './instance.ts'; +export { DesktopStorybookConfig, makeDesktopStorybookConfig } from './makeDesktopStorybookConfig.ts'; +export type { + DesktopReactNativeStorybookConfig, + DesktopStorybookConfigOptions, + PlatformStorySettings, + ResolvedPackage, + ResolvedStoryPackage, + StoryPackageSpec, + StorySettings, +} from './makeDesktopStorybookConfig.ts'; +export { FURN_STORYBOOK_PLATFORM, getAllPlatforms, getPlatform, isPlatform, setPlatform } from './platforms.ts'; +export type { Platforms } from './platforms.ts'; diff --git a/packages/agentic/storybook-desktop/src/config/instance.test.ts b/packages/agentic/storybook-desktop/src/config/instance.test.ts new file mode 100644 index 00000000000..85843c40371 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/config/instance.test.ts @@ -0,0 +1,51 @@ +import path from 'node:path'; + +import { createDesktopStorybookInstance } from './instance'; + +const storybookRoot = path.resolve(__dirname, '../../../../../apps/storybook'); +const packageRoot = path.resolve(__dirname, '../..'); + +describe('createDesktopStorybookInstance', () => { + test('creates a stable identity for the canonical project root', () => { + const first = createDesktopStorybookInstance({ + projectRoot: storybookRoot, + bundleIdentifierPrefix: 'com.microsoft.fluentui.agenticstorybook', + }); + const second = createDesktopStorybookInstance({ + projectRoot: path.join(storybookRoot, '.'), + bundleIdentifierPrefix: 'com.microsoft.fluentui.agenticstorybook', + }); + + expect(first).toEqual(second); + expect(first.bundleIdentifier).toBe(`com.microsoft.fluentui.agenticstorybook.i${first.id}`); + expect(first.id).toMatch(/^[a-f0-9]{10}$/); + expect(first.storybookPort).toBeGreaterThanOrEqual(17_000); + expect(first.storybookPort).toBeLessThan(27_000); + expect(first.metroPort).toBeGreaterThanOrEqual(27_000); + expect(first.metroPort).toBeLessThan(37_000); + expect(first.driverPort).toBeGreaterThanOrEqual(37_000); + expect(first.driverPort).toBeLessThan(47_000); + }); + + test('disambiguates separate project roots', () => { + const storybookInstance = createDesktopStorybookInstance({ projectRoot: storybookRoot }); + const packageInstance = createDesktopStorybookInstance({ projectRoot: packageRoot }); + + expect(storybookInstance.id).not.toBe(packageInstance.id); + expect(storybookInstance.bundleIdentifier).not.toBe(packageInstance.bundleIdentifier); + expect([storybookInstance.storybookPort, storybookInstance.metroPort, storybookInstance.driverPort]).not.toEqual([ + packageInstance.storybookPort, + packageInstance.metroPort, + packageInstance.driverPort, + ]); + }); + + test('rejects invalid bundle identifier prefixes', () => { + expect(() => + createDesktopStorybookInstance({ + projectRoot: storybookRoot, + bundleIdentifierPrefix: 'not a bundle identifier', + }), + ).toThrow('Invalid macOS bundle identifier prefix'); + }); +}); diff --git a/packages/agentic/storybook-desktop/src/config/instance.ts b/packages/agentic/storybook-desktop/src/config/instance.ts new file mode 100644 index 00000000000..267f973a26e --- /dev/null +++ b/packages/agentic/storybook-desktop/src/config/instance.ts @@ -0,0 +1,57 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; + +const defaultBundleIdentifierPrefix = 'com.microsoft.ReactTestApp'; +const storybookPortBase = 17_000; +const metroPortBase = 27_000; +const driverPortBase = 37_000; +const portRange = 10_000; + +export const FURN_STORYBOOK_INSTANCE_ID = 'FURN_STORYBOOK_INSTANCE_ID'; +export const FURN_STORYBOOK_BUNDLE_IDENTIFIER = 'FURN_STORYBOOK_BUNDLE_IDENTIFIER'; + +export type DesktopStorybookInstanceOptions = { + projectRoot: string; + bundleIdentifierPrefix?: string; +}; + +export type DesktopStorybookInstance = Readonly<{ + id: string; + projectRoot: string; + bundleIdentifier: string; + driverPort: number; + storybookPort: number; + metroPort: number; +}>; + +/** + * Creates a stable identity for one physical Storybook enlistment. + */ +export function createDesktopStorybookInstance({ + projectRoot, + bundleIdentifierPrefix = defaultBundleIdentifierPrefix, +}: DesktopStorybookInstanceOptions): DesktopStorybookInstance { + validateBundleIdentifier(bundleIdentifierPrefix); + + const canonicalRoot = fs.realpathSync.native(projectRoot); + const digest = createHash('sha256').update(canonicalRoot).digest('hex'); + const id = digest.slice(0, 10); + const storybookOffset = Number.parseInt(digest.slice(10, 18), 16) % portRange; + const metroOffset = Number.parseInt(digest.slice(18, 26), 16) % portRange; + const driverOffset = Number.parseInt(digest.slice(26, 34), 16) % portRange; + + return Object.freeze({ + id, + projectRoot: canonicalRoot, + bundleIdentifier: `${bundleIdentifierPrefix}.i${id}`, + driverPort: driverPortBase + driverOffset, + storybookPort: storybookPortBase + storybookOffset, + metroPort: metroPortBase + metroOffset, + }); +} + +function validateBundleIdentifier(bundleIdentifier: string): void { + if (!bundleIdentifier || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(bundleIdentifier)) { + throw new TypeError(`Invalid macOS bundle identifier prefix "${bundleIdentifier}".`); + } +} diff --git a/packages/agentic/storybook-desktop/src/config/makeDesktopStorybookConfig.test.ts b/packages/agentic/storybook-desktop/src/config/makeDesktopStorybookConfig.test.ts new file mode 100644 index 00000000000..7c734ad6746 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/config/makeDesktopStorybookConfig.test.ts @@ -0,0 +1,203 @@ +import path from 'node:path'; + +import { createWindowsSmokeCommand, createWindowsSmokeOptions, createWin32RunCommand, createWin32SmokeCommand } from './commands'; +import { makeDesktopStorybookConfig } from './makeDesktopStorybookConfig'; +import type { Platforms } from './platforms'; + +const storybookRoot = path.resolve(__dirname, '../../../../../apps/storybook'); + +function makeAgenticConfig() { + return makeDesktopStorybookConfig({ + projectRoot: storybookRoot, + storyPackages: [ + [ + '@fluentui-react-native/components', + { + platformSettings: { + win32: { + storyPatterns: ['src/primitives/**/*.stories.?(ts|tsx)', 'src/components/!(accordion|list-item)/**/*.stories.?(ts|tsx)'], + }, + }, + }, + ], + '@fluentui-react-native/callout', + ], + }); +} + +describe('DesktopStorybookConfig', () => { + test('reads consuming app metadata on demand', () => { + const config = makeAgenticConfig(); + + expect(config.packageName).toBe('@fluentui-react-native/agentic-components-storybook'); + expect(config.appName).toBe('AgenticStorybook'); + expect(config.displayName).toBe('Agentic Components Storybook'); + expect(config.macosBundleIdentifier).toBe('com.microsoft.fluentui.agenticstorybook'); + expect(config.testIDPrefix).toBe('agentic-storybook'); + }); + + test('resolves package roots and default story patterns', () => { + const config = makeAgenticConfig(); + const packages = config.getStoryPackages('macos'); + + expect(packages.map(({ name }) => name)).toEqual(['@fluentui-react-native/components', '@fluentui-react-native/callout']); + expect(packages[0].root).toBe(path.resolve(storybookRoot, '../../packages/agentic/components')); + expect(packages[0].storyPatterns).toEqual(['src/**/*.stories.?(ts|tsx)']); + }); + + test('builds platform-specific Storybook story globs', () => { + const config = makeAgenticConfig(); + + expect(config.getStoryGlobs('macos')).toEqual([ + '../../../packages/agentic/components/src/**/*.stories.?(ts|tsx)', + '../../../packages/native/Callout/src/**/*.stories.?(ts|tsx)', + ]); + expect(config.getStoryGlobs('win32')).toEqual([ + '../../../packages/agentic/components/src/primitives/**/*.stories.?(ts|tsx)', + '../../../packages/agentic/components/src/components/!(accordion|list-item)/**/*.stories.?(ts|tsx)', + '../../../packages/native/Callout/src/**/*.stories.?(ts|tsx)', + ]); + }); + + test('filters packages by platform and applies addon defaults', () => { + const config = makeDesktopStorybookConfig({ + projectRoot: storybookRoot, + storyPackages: [['@fluentui-react-native/callout', { platforms: ['macos'] }]], + }); + + expect(config.getStorybookConfig('windows')).toMatchObject({ + stories: [], + deviceAddons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], + }); + }); + + test('builds platform command defaults from app metadata with rnx-cli', () => { + const config = makeAgenticConfig(); + + expect(config.getCommandPlan('server', 'win32')).toEqual({ + command: process.execPath, + args: [path.resolve(storybookRoot, '../../packages/agentic/storybook-desktop/config/server-runner.cjs')], + env: { + STORYBOOK_CONFIG_PATH: path.join(storybookRoot, 'src'), + STORYBOOK_PROJECT_ROOT: storybookRoot, + }, + }); + expect(config.getPlatformOptions('macos')).toMatchObject({ + nativeProject: { + workspace: 'macos/AgenticStorybook.xcworkspace', + scheme: 'AgenticStorybook', + }, + build: { + command: 'rnx-cli', + args: ['build', '--platform', 'macos', '--workspace', 'macos/AgenticStorybook.xcworkspace', '--scheme', 'AgenticStorybook'], + }, + }); + expect(config.getCommandPlan('build', 'macos')).toEqual({ + command: 'rnx-cli', + args: ['build', '--platform', 'macos', '--workspace', 'macos/AgenticStorybook.xcworkspace', '--scheme', 'AgenticStorybook'], + }); + expect(config.getCommandPlan('run', 'windows')).toEqual({ + command: 'rnx-cli', + args: ['run', '--platform', 'windows', '--solution', 'windows/AgenticStorybook.sln'], + }); + expect(config.getCommandPlan('build', 'win32')).toBe(false); + expect(config.getSmokeOptions('macos')).toEqual({ + stop: { + command: 'osascript', + args: [path.resolve(storybookRoot, '../../packages/agentic/storybook-desktop/config/stop-macos-app.applescript')], + }, + }); + }); + + test('preserves explicit platform command overrides', () => { + const config = makeDesktopStorybookConfig({ + projectRoot: storybookRoot, + platformOptions: { + windows: { + nativeProject: { + configuration: 'Release', + }, + build: { + command: 'custom-build', + }, + }, + }, + }); + + expect(config.getCommandPlan('build', 'windows')).toEqual({ command: 'custom-build' }); + expect(config.getPlatformOptions('windows').nativeProject).toEqual({ + solution: 'windows/AgenticStorybook.sln', + configuration: 'Release', + }); + expect(config.getCommandPlan('run', 'windows')).toEqual({ + command: 'rnx-cli', + args: ['run', '--platform', 'windows', '--solution', 'windows/AgenticStorybook.sln', '--configuration', 'Release'], + }); + }); + + test('creates package-owned Windows and Win32 smoke commands', () => { + const configDirectory = path.resolve(__dirname, '../../config'); + + expect(createWindowsSmokeCommand({ windowTitle: 'Consumer Storybook' })).toEqual({ + command: 'pwsh', + args: ['-NoProfile', '-File', path.join(configDirectory, 'smoke-windows.ps1')], + env: { + STORYBOOK_WINDOWS_CONFIGURATION: 'Debug', + STORYBOOK_WINDOWS_WINDOW_TITLE: 'Consumer Storybook', + }, + }); + expect(createWindowsSmokeOptions({ windowTitle: 'Consumer Storybook' })).toEqual({ + command: { + command: 'pwsh', + args: ['-NoProfile', '-File', path.join(configDirectory, 'smoke-windows.ps1')], + env: { + STORYBOOK_WINDOWS_CONFIGURATION: 'Debug', + STORYBOOK_WINDOWS_WINDOW_TITLE: 'Consumer Storybook', + }, + }, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- React Native Test App uses loopback Metro + metroUrl: 'http://127.0.0.1:8081/status', + }); + expect(createWin32RunCommand({ component: 'ConsumerStorybook', windowTitle: 'Consumer Storybook (Win32)' })).toEqual({ + command: process.execPath, + args: [path.join(configDirectory, 'run-win32.cjs')], + env: { + STORYBOOK_WIN32_COMPONENT: 'ConsumerStorybook', + STORYBOOK_WIN32_WINDOW_TITLE: 'Consumer Storybook (Win32)', + }, + }); + expect( + createWin32SmokeCommand({ + component: 'ConsumerStorybook', + requiredStoryIds: ['first--story', 'second--story'], + testIDPrefix: 'consumer-storybook', + windowTitle: 'Consumer Storybook (Win32)', + }), + ).toEqual({ + command: 'pwsh', + args: ['-NoProfile', '-File', path.join(configDirectory, 'smoke-win32.ps1')], + env: { + STORYBOOK_TEST_ID_PREFIX: 'consumer-storybook', + STORYBOOK_WIN32_COMPONENT: 'ConsumerStorybook', + STORYBOOK_WIN32_REQUIRED_STORIES: 'first--story,second--story', + STORYBOOK_WIN32_WINDOW_TITLE: 'Consumer Storybook (Win32)', + }, + }); + }); + + test('rejects invalid platforms, duplicate packages, and escaping patterns', () => { + expect(() => makeAgenticConfig().getStorybookConfig('ios' as Platforms)).toThrow('must be one of'); + expect(() => + makeDesktopStorybookConfig({ + projectRoot: storybookRoot, + storyPackages: ['@fluentui-react-native/callout', '@fluentui-react-native/callout'], + }), + ).toThrow('configured more than once'); + expect(() => + makeDesktopStorybookConfig({ + projectRoot: storybookRoot, + storyPatterns: ['../outside/**/*.stories.tsx'], + }), + ).toThrow('must stay within its package'); + }); +}); diff --git a/packages/agentic/storybook-desktop/src/config/makeDesktopStorybookConfig.ts b/packages/agentic/storybook-desktop/src/config/makeDesktopStorybookConfig.ts new file mode 100644 index 00000000000..5bca34f8b01 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/config/makeDesktopStorybookConfig.ts @@ -0,0 +1,667 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + DesktopCommand, + DesktopCommandPlan, + DesktopNativeProjectOptions, + DesktopPlatformOptions, + DesktopPlatformOptionsMap, + DesktopSmokeOptions, + DesktopStorybookAction, +} from './commands.ts'; +import { getAllPlatforms, getPlatform, isPlatform } from './platforms.ts'; +import type { Platforms } from './platforms.ts'; + +const defaultStoryPatterns = ['src/**/*.stories.?(ts|tsx)'] as const; +const defaultDeviceAddons = ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'] as const; +const defaultMacOSBundleIdentifier = 'com.microsoft.ReactTestApp'; +const defaultTestIDPrefix = 'storybook-desktop'; + +type JsonObject = Record; + +type PackageManifest = JsonObject & { + name?: string; +}; + +type AppManifest = JsonObject & { + components?: { + appKey?: string; + displayName?: string; + }[]; + displayName?: string; + macos?: { + bundleIdentifier?: string; + }; + name?: string; + storybook?: { + testIDPrefix?: string; + }; +}; + +export type PlatformStorySettings = { + /** + * Story glob patterns that replace the package or global patterns on this platform. + */ + storyPatterns?: readonly string[]; +}; + +export type StorySettings = { + /** + * Platforms on which the package's stories are included. + * @default all desktop platforms available to the consuming app + */ + platforms?: readonly Platforms[]; + + /** + * Story glob patterns to search within each package. + * @default all .stories.ts and .stories.tsx files beneath src + */ + storyPatterns?: readonly string[]; + + /** + * Platform-specific pattern overrides. + */ + platformSettings?: Partial>; +}; + +export type StoryPackageSpec = string | readonly [packageName: string, settings: Partial]; + +export type DesktopStorybookConfigOptions = StorySettings & { + /** + * Path to the consuming app. A file URL is convenient from storybook.config.ts. + * @default process.cwd() + */ + projectRoot?: string | URL; + + /** + * Directory containing Storybook's main.ts and preview.tsx. + * Relative paths are resolved from projectRoot. + * @default "src" + */ + storybookConfigDir?: string; + + /** + * Path to the react-native-test-app manifest, relative to projectRoot. + * @default "app.json" + */ + appConfigPath?: string; + + /** + * Packages to include stories from. Use '.' to include the current package. Use the [string, StorySettings] + * tuple to override the default story settings for a specific package. + * @default ["."] + */ + storyPackages?: readonly StoryPackageSpec[]; + + /** + * On-device addons. + * @default controls and actions + */ + deviceAddons?: readonly string[]; + + /** + * Prefix for native Storybook chrome and story-root automation identifiers. + * @default "storybook-desktop" + */ + testIDPrefix?: string; + + /** + * Native project, command, and smoke-test settings for each desktop platform. + */ + platformOptions?: DesktopPlatformOptionsMap; +}; + +export type ResolvedPackage = { + manifest: Readonly; + name: string; + root: string; +}; + +export type ResolvedStoryPackage = ResolvedPackage & { + storyPatterns: readonly string[]; +}; + +export type DesktopReactNativeStorybookConfig = { + deviceAddons: string[]; + stories: string[]; +}; + +/** + * Class representing the configuration for a desktop Storybook instance. This takes relevant user settings + * and can build various configurations on demand for different platforms and environments. + */ +export class DesktopStorybookConfig { + readonly projectRoot: string; + readonly storybookConfigDir: string; + readonly appConfigPath: string; + + private readonly config: DesktopStorybookConfigOptions; + private readonly requireFromProject: NodeJS.Require; + private readonly packageCache = new Map(); + private readonly storyPackageCache = new Map(); + private packageManifestCache?: Readonly; + private appManifestCache?: Readonly; + + constructor(userConfig: DesktopStorybookConfigOptions = {}) { + this.projectRoot = resolveProjectRoot(userConfig.projectRoot); + this.storybookConfigDir = resolveFromProject(this.projectRoot, userConfig.storybookConfigDir ?? 'src'); + this.appConfigPath = resolveFromProject(this.projectRoot, userConfig.appConfigPath ?? 'app.json'); + this.config = normalizeConfig(userConfig, this.projectRoot); + this.requireFromProject = createRequire(path.join(this.projectRoot, 'package.json')); + } + + get platform(): Platforms | undefined { + return getPlatform(); + } + + get platforms(): readonly Platforms[] { + return this.config.platforms ?? getAllPlatforms(this.projectRoot); + } + + get packageManifest(): Readonly { + return (this.packageManifestCache ??= readJsonFile(path.join(this.projectRoot, 'package.json'))); + } + + get packageName(): string { + return requireSetting(this.packageManifest.name, `Package manifest at ${this.projectRoot} does not define "name".`); + } + + get appManifest(): Readonly { + return (this.appManifestCache ??= readJsonFile(this.appConfigPath)); + } + + get appName(): string { + return requireSetting( + this.appManifest.components?.[0]?.appKey ?? this.appManifest.name, + `App manifest at ${this.appConfigPath} does not define a component appKey or "name".`, + ); + } + + get displayName(): string { + return this.appManifest.components?.[0]?.displayName ?? this.appManifest.displayName ?? this.appName; + } + + get macosBundleIdentifier(): string { + return this.appManifest.macos?.bundleIdentifier ?? defaultMacOSBundleIdentifier; + } + + get testIDPrefix(): string { + const prefix = this.config.testIDPrefix ?? this.appManifest.storybook?.testIDPrefix ?? defaultTestIDPrefix; + validateTestIDPrefix(prefix); + return prefix; + } + + resolvePackage(packageName: string): ResolvedPackage { + const cachedPackage = this.packageCache.get(packageName); + if (cachedPackage) { + return cachedPackage; + } + + const root = packageName === '.' ? this.projectRoot : this.resolveDependencyRoot(packageName); + const manifest = readJsonFile(path.join(root, 'package.json')); + const resolvedName = requireSetting(manifest.name, `Package manifest at ${root} does not define "name".`); + if (packageName !== '.' && resolvedName !== packageName) { + throw new Error(`Resolved "${packageName}" to package "${resolvedName}" at ${root}.`); + } + + const resolvedPackage = Object.freeze({ + manifest, + name: resolvedName, + root, + }); + this.packageCache.set(packageName, resolvedPackage); + return resolvedPackage; + } + + getStoryPackages(platformSetting: Platforms | string | undefined = this.platform): readonly ResolvedStoryPackage[] { + const platform = platformSetting ? getPlatform(platformSetting) : undefined; + const cacheKey = platform ?? 'unscoped'; + const cachedPackages = this.storyPackageCache.get(cacheKey); + if (cachedPackages) { + return cachedPackages; + } + + const storyPackages = (this.config.storyPackages ?? ['.']) + .map((spec) => this.resolveStoryPackage(spec, platform)) + .filter((storyPackage): storyPackage is ResolvedStoryPackage => storyPackage !== undefined); + const frozenPackages = Object.freeze(storyPackages); + this.storyPackageCache.set(cacheKey, frozenPackages); + return frozenPackages; + } + + getStoryGlobs(platformSetting: Platforms | string | undefined = this.platform): readonly string[] { + return Object.freeze( + this.getStoryPackages(platformSetting).flatMap(({ root, storyPatterns }) => { + const relativePackageRoot = toPosixPath(path.relative(this.storybookConfigDir, root)); + return storyPatterns.map((pattern) => path.posix.join(relativePackageRoot, pattern)); + }), + ); + } + + getStorybookConfig(platformSetting: Platforms | string | undefined = this.platform): DesktopReactNativeStorybookConfig { + return { + stories: [...this.getStoryGlobs(platformSetting)], + deviceAddons: [...(this.config.deviceAddons ?? defaultDeviceAddons)], + }; + } + + getPlatformOptions(platformSetting: Platforms | string | undefined = this.platform): Readonly { + const platform = requirePlatform(platformSetting); + const configured = this.config.platformOptions?.[platform] ?? {}; + const nativeProject = { + ...defaultNativeProjectOptions(this, platform), + ...configured.nativeProject, + }; + + return Object.freeze({ + nativeProject: Object.freeze(nativeProject), + server: configured.server !== undefined ? configured.server : defaultServerCommand(this), + prep: configured.prep !== undefined ? configured.prep : defaultPrepPlan(platform), + bundle: configured.bundle !== undefined ? configured.bundle : defaultBundlePlan(this, platform), + run: configured.run !== undefined ? configured.run : defaultNativePlan(platform, 'run', nativeProject), + build: configured.build !== undefined ? configured.build : defaultNativePlan(platform, 'build', nativeProject), + smoke: configured.smoke !== undefined ? configured.smoke : defaultSmokeOptions(this, platform), + }); + } + + getCommandPlan( + action: Exclude, + platformSetting: Platforms | string | undefined = this.platform, + ): DesktopCommandPlan | false { + const platform = requirePlatform(platformSetting); + const plan = this.getPlatformOptions(platform)[action]; + if (plan === undefined) { + throw new Error(`No default ${action} command is available for ${platform}.`); + } + return plan; + } + + getSmokeOptions(platformSetting: Platforms | string | undefined = this.platform): Readonly | false | undefined { + const platform = requirePlatform(platformSetting); + return this.getPlatformOptions(platform).smoke; + } + + private resolveStoryPackage(spec: StoryPackageSpec, platform?: Platforms): ResolvedStoryPackage | undefined { + const [packageName, packageSettings] = typeof spec === 'string' ? [spec, {}] : spec; + const enabledPlatforms = packageSettings.platforms ?? this.config.platforms ?? getAllPlatforms(this.projectRoot); + if (platform && enabledPlatforms.length > 0 && !enabledPlatforms.includes(platform)) { + return undefined; + } + + const globalPlatformSettings = platform ? this.config.platformSettings?.[platform] : undefined; + const packagePlatformSettings = platform ? packageSettings.platformSettings?.[platform] : undefined; + const storyPatterns = + packagePlatformSettings?.storyPatterns ?? + packageSettings.storyPatterns ?? + globalPlatformSettings?.storyPatterns ?? + this.config.storyPatterns ?? + defaultStoryPatterns; + + return Object.freeze({ + ...this.resolvePackage(packageName), + storyPatterns: Object.freeze(storyPatterns.map((pattern) => normalizeStoryPattern(pattern, packageName))), + }); + } + + private resolveDependencyRoot(packageName: string): string { + try { + return path.dirname(this.requireFromProject.resolve(`${packageName}/package.json`)); + } catch (error) { + if (!isPackageResolutionError(error)) { + throw error; + } + } + + const entryPath = this.requireFromProject.resolve(packageName); + let current = path.dirname(entryPath); + while (true) { + const manifestPath = path.join(current, 'package.json'); + if (fs.existsSync(manifestPath)) { + const manifest = readJsonFile(manifestPath); + if (manifest.name === packageName) { + return current; + } + } + + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Could not find package root for "${packageName}" from ${this.projectRoot}.`); + } + current = parent; + } + } +} + +export function makeDesktopStorybookConfig(userConfig: DesktopStorybookConfigOptions = {}): DesktopStorybookConfig { + return new DesktopStorybookConfig(userConfig); +} + +function resolveProjectRoot(projectRoot?: string | URL): string { + if (projectRoot instanceof URL) { + return path.resolve(fileURLToPath(projectRoot.href)); + } + return path.resolve(projectRoot ?? process.cwd()); +} + +function resolveFromProject(projectRoot: string, setting: string): string { + return path.isAbsolute(setting) ? path.normalize(setting) : path.resolve(projectRoot, setting); +} + +function requirePlatform(platformSetting: Platforms | string | undefined): Platforms { + const platform = getPlatform(platformSetting); + if (!platform) { + throw new Error('A desktop platform must be selected.'); + } + return platform; +} + +function defaultPrepPlan(platform: Platforms): DesktopCommandPlan { + switch (platform) { + case 'macos': + return { command: 'pod', args: ['install', '--project-directory=macos'] }; + case 'windows': + return { command: 'install-windows-test-app', args: ['--use-fabric'] }; + case 'win32': + return []; + } +} + +function defaultServerCommand(config: DesktopStorybookConfig): DesktopCommand { + return { + command: process.execPath, + args: [path.join(config.resolvePackage('@fluentui-react-native/storybook-desktop').root, 'config', 'server-runner.cjs')], + env: { + STORYBOOK_CONFIG_PATH: config.storybookConfigDir, + STORYBOOK_PROJECT_ROOT: config.projectRoot, + }, + }; +} + +function defaultBundlePlan(config: DesktopStorybookConfig, platform: Platforms): DesktopCommandPlan { + return [ + { + command: 'sb-rn-get-stories', + args: ['--config-path', config.storybookConfigDir], + }, + { + command: 'rnx-cli', + args: ['bundle', '--dev', 'false', '--platform', platform], + }, + ]; +} + +function defaultNativePlan( + platform: Platforms, + action: 'build' | 'run', + nativeProject: DesktopNativeProjectOptions, +): DesktopCommandPlan | false { + if (platform === 'win32') { + return false; + } + + return { + command: 'rnx-cli', + args: [action, '--platform', platform, ...nativeProjectArgs(platform, nativeProject, action)], + }; +} + +function nativeProjectArgs( + platform: Exclude, + nativeProject: DesktopNativeProjectOptions, + action: 'build' | 'run', +): string[] { + const args: string[] = []; + if (platform === 'macos') { + args.push('--workspace', requireSetting(nativeProject.workspace, 'The macOS workspace was not resolved.')); + args.push('--scheme', requireSetting(nativeProject.scheme, 'The macOS scheme was not resolved.')); + } else { + args.push('--solution', requireSetting(nativeProject.solution, 'The Windows solution was not resolved.')); + } + if (nativeProject.configuration) { + args.push('--configuration', nativeProject.configuration); + } + if (nativeProject.destination) { + args.push('--destination', nativeProject.destination); + } + if (action === 'run' && nativeProject.device) { + args.push('--device', nativeProject.device); + } + return args; +} + +function defaultNativeProjectOptions(config: DesktopStorybookConfig, platform: Platforms): DesktopNativeProjectOptions { + switch (platform) { + case 'macos': + return { + workspace: `macos/${config.appName}.xcworkspace`, + scheme: config.appName, + }; + case 'windows': + return { + solution: `windows/${config.appName}.sln`, + }; + case 'win32': + return {}; + } +} + +function defaultSmokeOptions(config: DesktopStorybookConfig, platform: Platforms): DesktopSmokeOptions | undefined { + if (platform !== 'macos') { + return undefined; + } + return { + stop: { + command: 'osascript', + args: [path.join(config.resolvePackage('@fluentui-react-native/storybook-desktop').root, 'config', 'stop-macos-app.applescript')], + }, + }; +} + +function normalizeConfig(config: DesktopStorybookConfigOptions, projectRoot: string): DesktopStorybookConfigOptions { + validatePlatforms(config.platforms, 'config'); + validatePlatformSettings(config.platformSettings, 'config'); + validateDesktopPlatformOptions(config.platformOptions); + if (config.testIDPrefix !== undefined) { + validateTestIDPrefix(config.testIDPrefix); + } + + const seenPackages = new Set(); + const normalizedStoryPackages: StoryPackageSpec[] = []; + for (const spec of config.storyPackages ?? ['.']) { + const [packageName, settings] = typeof spec === 'string' ? [spec, {}] : spec; + if (!packageName.trim()) { + throw new TypeError('Story package names cannot be empty.'); + } + if (seenPackages.has(packageName)) { + throw new Error(`Story package "${packageName}" is configured more than once.`); + } + seenPackages.add(packageName); + validatePlatforms(settings.platforms, `story package "${packageName}"`); + validatePlatformSettings(settings.platformSettings, `story package "${packageName}"`); + for (const pattern of settings.storyPatterns ?? []) { + normalizeStoryPattern(pattern, packageName); + } + normalizedStoryPackages.push(typeof spec === 'string' ? packageName : [packageName, normalizeStorySettings(settings)]); + } + for (const pattern of config.storyPatterns ?? []) { + normalizeStoryPattern(pattern, projectRoot); + } + + return { + ...config, + ...normalizeStorySettings(config), + storyPackages: normalizedStoryPackages, + deviceAddons: config.deviceAddons ? [...config.deviceAddons] : undefined, + platformOptions: normalizeDesktopPlatformOptions(config.platformOptions), + }; +} + +function validateTestIDPrefix(prefix: string): void { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(prefix)) { + throw new TypeError('testIDPrefix must contain lowercase alphanumeric segments separated by hyphens.'); + } +} + +function normalizeDesktopPlatformOptions(platformOptions: DesktopPlatformOptionsMap | undefined): DesktopPlatformOptionsMap | undefined { + if (!platformOptions) { + return undefined; + } + + return Object.fromEntries( + Object.entries(platformOptions).map(([platform, options]) => [ + platform, + { + ...options, + nativeProject: options.nativeProject ? { ...options.nativeProject } : undefined, + server: normalizeCommand(options.server), + prep: normalizeCommandPlan(options.prep), + bundle: normalizeCommandPlan(options.bundle), + run: normalizeCommandPlan(options.run), + build: normalizeCommandPlan(options.build), + smoke: + options.smoke === false + ? false + : options.smoke + ? { + ...options.smoke, + command: normalizeCommandPlan(options.smoke.command), + server: normalizeCommand(options.smoke.server), + metro: normalizeCommand(options.smoke.metro), + stop: normalizeCommandPlan(options.smoke.stop), + } + : undefined, + }, + ]), + ) as DesktopPlatformOptionsMap; +} + +function normalizeCommandPlan(plan: T): T { + if (plan === false || plan === undefined) { + return plan; + } + return (isCommandArray(plan) ? plan.map((command) => normalizeCommand(command)) : normalizeCommand(plan)) as T; +} + +function normalizeCommand(command: T): T { + if (command === false || command === undefined) { + return command; + } + return { + ...command, + args: command.args ? [...command.args] : undefined, + env: command.env ? { ...command.env } : undefined, + } as T; +} + +function normalizeStorySettings(settings: Partial): StorySettings { + const platformSettings = settings.platformSettings + ? (Object.fromEntries( + Object.entries(settings.platformSettings).map(([platform, platformSetting]) => [ + platform, + { + storyPatterns: platformSetting.storyPatterns ? [...platformSetting.storyPatterns] : undefined, + }, + ]), + ) as Partial>) + : undefined; + + return { + platforms: settings.platforms ? [...settings.platforms] : undefined, + storyPatterns: settings.storyPatterns ? [...settings.storyPatterns] : undefined, + platformSettings, + }; +} + +function validatePlatforms(platforms: readonly Platforms[] | undefined, source: string): void { + for (const platform of platforms ?? []) { + if (!isPlatform(platform)) { + throw new RangeError(`${source} contains unsupported platform "${platform}".`); + } + } +} + +function validatePlatformSettings(settings: Partial> | undefined, source: string): void { + for (const [platform, platformSettings] of Object.entries(settings ?? {})) { + if (!isPlatform(platform)) { + throw new RangeError(`${source} contains unsupported platform override "${platform}".`); + } + for (const pattern of platformSettings.storyPatterns ?? []) { + normalizeStoryPattern(pattern, `${source}:${platform}`); + } + } +} + +function validateDesktopPlatformOptions(platformOptions: DesktopPlatformOptionsMap | undefined): void { + for (const [platform, options] of Object.entries(platformOptions ?? {})) { + if (!isPlatform(platform)) { + throw new RangeError(`config contains unsupported desktop platform options "${platform}".`); + } + validateCommand(options.server, `${platform}.server`); + validateCommandPlan(options.prep, `${platform}.prep`); + validateCommandPlan(options.bundle, `${platform}.bundle`); + validateCommandPlan(options.run, `${platform}.run`); + validateCommandPlan(options.build, `${platform}.build`); + if (options.smoke !== false) { + validateCommandPlan(options.smoke?.command, `${platform}.smoke.command`); + validateCommand(options.smoke?.server, `${platform}.smoke.server`); + validateCommand(options.smoke?.metro, `${platform}.smoke.metro`); + validateCommandPlan(options.smoke?.stop, `${platform}.smoke.stop`); + } + } +} + +function validateCommandPlan(plan: DesktopCommandPlan | false | undefined, source: string): void { + if (plan === false || plan === undefined) { + return; + } + for (const command of isCommandArray(plan) ? plan : [plan]) { + validateCommand(command, source); + } +} + +function validateCommand(command: DesktopCommand | false | undefined, source: string): void { + if (command !== false && command !== undefined && !command.command.trim()) { + throw new TypeError(`Desktop command ${source} must define a command.`); + } +} + +function isCommandArray(plan: DesktopCommandPlan): plan is readonly DesktopCommand[] { + return Array.isArray(plan); +} + +function normalizeStoryPattern(pattern: string, source: string): string { + const normalizedPattern = toPosixPath(pattern).replace(/^\.\//, ''); + if ( + !normalizedPattern || + path.posix.isAbsolute(normalizedPattern) || + path.win32.isAbsolute(pattern) || + normalizedPattern === '..' || + normalizedPattern.startsWith('../') + ) { + throw new TypeError(`Story pattern "${pattern}" for ${source} must stay within its package.`); + } + return normalizedPattern; +} + +function readJsonFile(filePath: string): Readonly { + const content = fs.readFileSync(filePath, 'utf8'); + return Object.freeze(JSON.parse(content) as T); +} + +function requireSetting(value: string | undefined, message: string): string { + if (!value) { + throw new Error(message); + } + return value; +} + +function toPosixPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function isPackageResolutionError(error: unknown): error is NodeJS.ErrnoException { + const code = (error as NodeJS.ErrnoException)?.code; + return code === 'MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED'; +} diff --git a/packages/agentic/storybook-desktop/src/config/platforms.ts b/packages/agentic/storybook-desktop/src/config/platforms.ts new file mode 100644 index 00000000000..2b9d5202d97 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/config/platforms.ts @@ -0,0 +1,47 @@ +import os from 'node:os'; +import path from 'node:path'; +import type { AllPlatforms } from '@rnx-kit/tools-react-native/platform'; +import { getAvailablePlatforms } from '@rnx-kit/tools-react-native/platform'; + +export type Platforms = Extract; + +export const FURN_STORYBOOK_PLATFORM = 'FURN_STORYBOOK_PLATFORM'; + +const supportedPlatforms = ['macos', 'windows', 'win32'] as const satisfies readonly Platforms[]; +const platformCache = new Map(); + +export function getAllPlatforms(projectRoot = process.cwd()): readonly Platforms[] { + const resolvedRoot = path.resolve(projectRoot); + let platforms = platformCache.get(resolvedRoot); + if (!platforms) { + platforms = Object.freeze(Object.keys(getAvailablePlatforms(resolvedRoot)).filter(isPlatform)); + platformCache.set(resolvedRoot, platforms); + } + return platforms; +} + +const defaultPlatform = os.platform() === 'win32' ? 'windows' : os.platform() === 'darwin' ? 'macos' : undefined; + +export function isPlatform(setting: string): setting is Platforms { + return supportedPlatforms.includes(setting as Platforms); +} + +function parsePlatform(setting: string | AllPlatforms, source: string): Platforms { + if (!isPlatform(setting)) { + throw new RangeError(`${source} must be one of: ${supportedPlatforms.join(', ')}. Received "${setting}".`); + } + return setting; +} + +export function getPlatform(setting?: string | AllPlatforms): Platforms | undefined { + if (setting) { + return parsePlatform(setting, 'Desktop Storybook platform'); + } + + const environmentPlatform = process.env[FURN_STORYBOOK_PLATFORM]; + return environmentPlatform ? parsePlatform(environmentPlatform, FURN_STORYBOOK_PLATFORM) : defaultPlatform; +} + +export function setPlatform(platform: Platforms): void { + process.env[FURN_STORYBOOK_PLATFORM] = parsePlatform(platform, 'Desktop Storybook platform'); +} diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts new file mode 100644 index 00000000000..7eadef78b3f --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts @@ -0,0 +1,243 @@ +import type { DesktopStoryManifest } from '@fluentui-react-native/desktop-driver'; + +import type { DesktopStorybookDriverManifest } from './driverManifest.js'; +import { StorybookChannelOrchestrator } from './StorybookChannelOrchestrator.js'; +import type { StorybookChannelServer } from './StorybookChannelOrchestrator.js'; + +class FakeChannelClient { + readyState = 1; + readonly sent: string[] = []; + private readonly messageListeners: ((data: unknown) => void)[] = []; + private readonly closeListeners: (() => void)[] = []; + + on(event: 'close', listener: () => void): void; + on(event: 'message', listener: (data: unknown) => void): void; + on(event: 'close' | 'message', listener: (() => void) | ((data: unknown) => void)): void { + if (event === 'close') { + this.closeListeners.push(listener as () => void); + } else { + this.messageListeners.push(listener as (data: unknown) => void); + } + } + + send(message: string): void { + this.sent.push(message); + } + + receive(type: string, payload: unknown): void { + const message = JSON.stringify({ type, args: [payload] }); + for (const listener of this.messageListeners) { + listener(message); + } + } + + close(): void { + for (const listener of this.closeListeners) { + listener(); + } + } +} + +class FakeChannelServer implements StorybookChannelServer { + readonly clients = new Set(); + private readonly connectionListeners: ((client: FakeChannelClient) => void)[] = []; + + on(_event: 'connection', listener: (client: FakeChannelClient) => void): void { + this.connectionListeners.push(listener); + } + + connect(client: FakeChannelClient): void { + this.clients.add(client); + for (const listener of this.connectionListeners) { + listener(client); + } + } +} + +const storyManifest: DesktopStoryManifest = { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/button/button.stories.tsx', + tags: ['story'], + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform-digest', + portablePlanDigest: 'portable-digest', + schemaVersion: 1, +}; + +const driverManifest: DesktopStorybookDriverManifest = { + appName: 'AgenticStorybook', + bridgeNonce: 'nonce', + displayName: 'Agentic Components Storybook', + driverPort: 4444, + endpoint: 'windows', + instanceId: 'instance', + metroPort: 8081, + platformManifestDigest: storyManifest.platformManifestDigest, + portablePlanDigest: storyManifest.portablePlanDigest, + renderer: 'fabric', + schemaVersion: 1, + storyManifest, + storybookPort: 7007, + targetId: 'agenticstorybook-windows', + testIDPrefix: 'agentic-storybook', +}; + +describe('StorybookChannelOrchestrator', () => { + test('authenticates the runtime and correlates selection readiness', async () => { + const channelServer = new FakeChannelServer(); + const client = new FakeChannelClient(); + channelServer.connect(client); + const orchestrator = new StorybookChannelOrchestrator({ + channelServer, + driverManifest, + fetch: jest.fn(async () => new Response('{}', { status: 408 })), + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL + serverUrl: 'http://127.0.0.1:7007', + timeoutMs: 1000, + }); + client.receive('furn:desktop:hello', { + endpoint: 'windows', + instanceId: 'instance', + nonce: 'nonce', + platformManifestDigest: 'platform-digest', + targetId: 'agenticstorybook-windows', + version: 1, + }); + expect(JSON.parse(client.sent[0])).toEqual({ type: 'furn:desktop:request-hello', args: [] }); + client.sent.length = 0; + + const selection = orchestrator.selectStory({ + requestId: 'request-1', + runId: 'run-1', + storyId: 'components-button--default', + }); + await Promise.resolve(); + expect(JSON.parse(client.sent[0])).toEqual({ + type: 'furn:desktop:prepare-story', + args: [{ requestId: 'request-1', runId: 'run-1', storyId: 'components-button--default' }], + }); + client.receive('furn:desktop:story-ready', { + portablePlanDigest: 'portable-digest', + previewGeneration: 1, + requestId: 'request-1', + runId: 'run-1', + storyId: 'components-button--default', + }); + + await expect(selection).resolves.toEqual({ + previewGeneration: 1, + runId: 'run-1', + storyId: 'components-button--default', + }); + await expect(orchestrator.getCurrentStory()).resolves.toMatchObject({ runId: 'run-1' }); + }); + + test('observes render errors while the selection request is still pending', async () => { + const channelServer = new FakeChannelServer(); + const client = new FakeChannelClient(); + const unauthenticatedClient = new FakeChannelClient(); + channelServer.connect(client); + channelServer.connect(unauthenticatedClient); + let finishFetch: ((response: Response) => void) | undefined; + const fetch = jest.fn(() => new Promise((resolve) => (finishFetch = resolve))); + const orchestrator = new StorybookChannelOrchestrator({ + channelServer, + driverManifest, + fetch, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL + serverUrl: 'http://127.0.0.1:7007', + timeoutMs: 1000, + }); + client.receive('furn:desktop:hello', { + endpoint: 'windows', + instanceId: 'instance', + nonce: 'nonce', + platformManifestDigest: 'platform-digest', + targetId: 'agenticstorybook-windows', + version: 1, + }); + + const selection = orchestrator.selectStory({ + requestId: 'request-error', + runId: 'run-error', + storyId: 'components-button--default', + }); + await Promise.resolve(); + let settled = false; + void selection.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + const errorPayload = { + message: 'render failed', + requestId: 'request-error', + runId: 'run-error', + storyId: 'components-button--default', + }; + unauthenticatedClient.receive('furn:desktop:story-error', errorPayload); + await Promise.resolve(); + expect(settled).toBe(false); + client.receive('furn:desktop:story-error', errorPayload); + finishFetch?.(new Response('{}')); + + await expect(selection).rejects.toThrow('render failed'); + }); + + test('rejects stories outside the exact platform manifest', async () => { + const orchestrator = new StorybookChannelOrchestrator({ + channelServer: new FakeChannelServer(), + driverManifest, + fetch: jest.fn(), + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL + serverUrl: 'http://127.0.0.1:7007', + timeoutMs: 10, + }); + + await expect(orchestrator.selectStory({ requestId: 'request', runId: 'run', storyId: 'missing--story' })).rejects.toThrow( + 'not present', + ); + }); + + test('cancels reset readiness when the authenticated bridge closes', async () => { + const channelServer = new FakeChannelServer(); + const client = new FakeChannelClient(); + channelServer.connect(client); + const orchestrator = new StorybookChannelOrchestrator({ + channelServer, + driverManifest, + fetch: jest.fn(), + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL + serverUrl: 'http://127.0.0.1:7007', + timeoutMs: 10, + }); + client.receive('furn:desktop:hello', { + endpoint: 'windows', + instanceId: 'instance', + nonce: 'nonce', + platformManifestDigest: 'platform-digest', + targetId: 'agenticstorybook-windows', + version: 1, + }); + client.readyState = 3; + + await expect( + orchestrator.resetStory({ + requestId: 'request-reset', + runId: 'run-reset', + storyId: 'components-button--default', + }), + ).rejects.toThrow('not connected'); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +}); diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts new file mode 100644 index 00000000000..552b33019da --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts @@ -0,0 +1,287 @@ +import type { + DesktopStoryManifest, + StoryOrchestrator, + StoryReadyResult, + StorySelectionRequest, +} from '@fluentui-react-native/desktop-driver'; + +import type { DesktopStorybookDriverManifest } from './driverManifest.js'; + +type ChannelMessage = { + args?: unknown[]; + type?: string; +}; + +type ChannelClient = { + readyState: number; + on(event: 'close', listener: () => void): void; + on(event: 'message', listener: (data: unknown) => void): void; + send(message: string): void; +}; + +export type StorybookChannelServer = { + clients: Iterable; + on(event: 'connection', listener: (client: ChannelClient) => void): void; +}; + +export type StorybookChannelOrchestratorOptions = { + channelServer: StorybookChannelServer; + driverManifest: DesktopStorybookDriverManifest; + fetch?: typeof globalThis.fetch; + serverUrl: string; + timeoutMs?: number; +}; + +type PendingSelection = { + reject(error: Error): void; + request: StorySelectionRequest; + resolve(result: StoryReadyResult): void; + timer: ReturnType; +}; + +export class StorybookChannelOrchestrator implements StoryOrchestrator { + private readonly driverManifest: DesktopStorybookDriverManifest; + private readonly fetch: typeof globalThis.fetch; + private readonly serverUrl: string; + private readonly timeoutMs: number; + private readonly pending = new Map(); + private readonly bridgeWaiters = new Set<() => void>(); + private bridgeConnected = false; + private bridgeClient?: ChannelClient; + private currentStory: StoryReadyResult | null = null; + + constructor({ + channelServer, + driverManifest, + fetch = globalThis.fetch, + serverUrl, + timeoutMs = 30_000, + }: StorybookChannelOrchestratorOptions) { + this.driverManifest = driverManifest; + this.fetch = fetch; + this.serverUrl = serverUrl.replace(/\/$/, ''); + this.timeoutMs = timeoutMs; + channelServer.on('connection', (client) => this.attachClient(client)); + for (const client of channelServer.clients) { + this.attachClient(client); + } + } + + async getManifest(): Promise { + return this.driverManifest.storyManifest; + } + + async getCurrentStory(): Promise { + return this.currentStory; + } + + async selectStory(request: StorySelectionRequest): Promise { + this.requireStory(request.storyId); + await this.waitForBridge(); + const ready = this.waitForReady(request); + void ready.catch(() => undefined); + this.broadcast('furn:desktop:prepare-story', request); + try { + const response = await this.fetch(`${this.serverUrl}/select-story-sync/${encodeURIComponent(request.storyId)}`, { + method: 'POST', + }); + if (!response.ok && response.status !== 408) { + throw new Error(`Storybook failed to select "${request.storyId}" with status ${response.status}.`); + } + return await ready; + } catch (error) { + this.cancelPending(request.requestId); + throw error; + } + } + + async resetStory(request: StorySelectionRequest): Promise { + this.requireStory(request.storyId); + await this.waitForBridge(); + const ready = this.waitForReady(request); + void ready.catch(() => undefined); + try { + this.broadcast('furn:desktop:prepare-story', request); + return ready; + } catch (error) { + this.cancelPending(request.requestId); + throw error; + } + } + + async updateArgs(storyId: string, args: Readonly>): Promise { + this.requireStory(storyId); + this.broadcast('updateStoryArgs', { storyId, updatedArgs: args }); + } + + private attachClient(client: ChannelClient): void { + client.on('message', (data) => this.onMessage(client, data)); + client.on('close', () => { + if (this.bridgeClient === client) { + this.bridgeClient = undefined; + this.bridgeConnected = false; + } + }); + if (client.readyState === 1) { + client.send(JSON.stringify({ type: 'furn:desktop:request-hello', args: [] })); + } + } + + private onMessage(client: ChannelClient, data: unknown): void { + let message: ChannelMessage; + try { + const text = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data); + message = JSON.parse(text) as ChannelMessage; + } catch { + return; + } + const payload = message.args?.[0]; + if (!payload || typeof payload !== 'object') { + return; + } + if (message.type === 'furn:desktop:hello') { + this.acceptHello(client, payload as Record); + } else if (message.type === 'furn:desktop:story-ready') { + this.acceptReady(client, payload as Record); + } else if (message.type === 'furn:desktop:story-error') { + this.acceptError(client, payload as Record); + } + } + + private acceptHello(client: ChannelClient, payload: Record): void { + const expected = this.driverManifest; + if ( + payload.version !== 1 || + payload.instanceId !== expected.instanceId || + payload.endpoint !== expected.endpoint || + payload.targetId !== expected.targetId || + payload.platformManifestDigest !== expected.platformManifestDigest || + payload.nonce !== expected.bridgeNonce + ) { + return; + } + if (this.bridgeClient && this.bridgeClient !== client) { + return; + } + this.bridgeClient = client; + this.bridgeConnected = true; + for (const resolve of this.bridgeWaiters) { + resolve(); + } + this.bridgeWaiters.clear(); + } + + private acceptReady(client: ChannelClient, payload: Record): void { + if (client !== this.bridgeClient) { + return; + } + const requestId = payload.requestId; + if ( + typeof requestId !== 'string' || + typeof payload.runId !== 'string' || + typeof payload.storyId !== 'string' || + typeof payload.previewGeneration !== 'number' || + payload.portablePlanDigest !== this.driverManifest.portablePlanDigest + ) { + return; + } + const pending = this.pending.get(requestId); + if (!pending) { + return; + } + if (payload.runId !== pending.request.runId || payload.storyId !== pending.request.storyId) { + return; + } + const result = { + previewGeneration: payload.previewGeneration, + runId: payload.runId, + storyId: payload.storyId, + }; + clearTimeout(pending.timer); + this.pending.delete(requestId); + this.currentStory = result; + pending.resolve(result); + } + + private acceptError(client: ChannelClient, payload: Record): void { + if ( + client !== this.bridgeClient || + typeof payload.requestId !== 'string' || + typeof payload.runId !== 'string' || + typeof payload.storyId !== 'string' + ) { + return; + } + const pending = this.pending.get(payload.requestId); + if (!pending || pending.request.runId !== payload.runId || pending.request.storyId !== payload.storyId) { + return; + } + this.rejectPending( + payload.requestId, + new Error(typeof payload.message === 'string' ? payload.message : 'The Storybook runtime failed to render the story.'), + ); + } + + private waitForBridge(): Promise { + if (this.bridgeConnected) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.bridgeWaiters.delete(onReady); + reject(new Error('Timed out waiting for the authenticated Storybook runtime bridge.')); + }, this.timeoutMs); + const onReady = () => { + clearTimeout(timer); + resolve(); + }; + this.bridgeWaiters.add(onReady); + }); + } + + private waitForReady(request: StorySelectionRequest): Promise { + if (this.pending.has(request.requestId)) { + throw new Error(`Story selection request "${request.requestId}" is already pending.`); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(request.requestId); + reject(new Error(`Timed out waiting for Storybook story "${request.storyId}" run "${request.runId}".`)); + }, this.timeoutMs); + this.pending.set(request.requestId, { reject, request, resolve, timer }); + }); + } + + private rejectPending(requestId: string, error: Error): void { + const pending = this.pending.get(requestId); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(requestId); + pending.reject(error); + } + + private cancelPending(requestId: string): void { + const pending = this.pending.get(requestId); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(requestId); + } + + private requireStory(storyId: string): void { + if (!this.driverManifest.storyManifest.entries.some(({ id }) => id === storyId)) { + throw new Error(`Story "${storyId}" is not present in the ${this.driverManifest.endpoint} manifest.`); + } + } + + private broadcast(type: string, payload: unknown): void { + const message = JSON.stringify({ type, args: [payload] }); + if (!this.bridgeClient || this.bridgeClient.readyState !== 1) { + throw new Error('The authenticated Storybook runtime bridge is not connected.'); + } + this.bridgeClient.send(message); + } +} diff --git a/packages/agentic/storybook-desktop/src/driver/driverManifest.ts b/packages/agentic/storybook-desktop/src/driver/driverManifest.ts new file mode 100644 index 00000000000..870b482319b --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/driverManifest.ts @@ -0,0 +1,69 @@ +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { DesktopStoryManifest } from '@fluentui-react-native/desktop-driver'; + +import type { DesktopStorybookConfig } from '../config/makeDesktopStorybookConfig.js'; +import type { DesktopStorybookInstance } from '../config/instance.js'; +import type { Platforms } from '../config/platforms.js'; + +export type DesktopStorybookDriverManifest = { + appName: string; + bridgeNonce: string; + displayName: string; + driverPort: number; + endpoint: Platforms; + instanceId: string; + metroPort: number; + platformManifestDigest: string; + portablePlanDigest: string; + renderer: 'fabric' | 'paper'; + schemaVersion: 1; + storyManifest: DesktopStoryManifest; + storybookPort: number; + targetId: string; + testIDPrefix: string; +}; + +export type CreateDesktopStorybookDriverManifestOptions = { + bridgeNonce?: string; + config: DesktopStorybookConfig; + instance: DesktopStorybookInstance; + platform: Platforms; + storyManifest: DesktopStoryManifest; +}; + +export function createDesktopStorybookDriverManifest({ + bridgeNonce = randomBytes(24).toString('base64url'), + config, + instance, + platform, + storyManifest, +}: CreateDesktopStorybookDriverManifestOptions): DesktopStorybookDriverManifest { + return Object.freeze({ + appName: config.appName, + bridgeNonce, + displayName: config.displayName, + driverPort: instance.driverPort, + endpoint: platform, + instanceId: instance.id, + metroPort: instance.metroPort, + platformManifestDigest: storyManifest.platformManifestDigest, + portablePlanDigest: storyManifest.portablePlanDigest, + renderer: platform === 'win32' ? 'paper' : 'fabric', + schemaVersion: 1, + storyManifest, + storybookPort: instance.storybookPort, + targetId: `${config.appName}-${platform}`.toLowerCase(), + testIDPrefix: config.testIDPrefix, + }); +} + +export function writeDesktopStorybookDriverManifest(manifest: DesktopStorybookDriverManifest, outputPath: string): void { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const content = `${JSON.stringify(manifest, null, 2)}\n`; + if (!fs.existsSync(outputPath) || fs.readFileSync(outputPath, 'utf8') !== content) { + fs.writeFileSync(outputPath, content); + } +} diff --git a/packages/agentic/storybook-desktop/src/driver/fixtures/button.stories.ts b/packages/agentic/storybook-desktop/src/driver/fixtures/button.stories.ts new file mode 100644 index 00000000000..0acbd718fc0 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/fixtures/button.stories.ts @@ -0,0 +1,18 @@ +export default { + title: 'Components/FixtureButton', +}; + +export const Default = { + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'clicks-button', + requires: ['physical-click'], + steps: [{ action: 'click', target: { testId: 'fixture-button' } }], + }, + ], + }, + }, +}; diff --git a/packages/agentic/storybook-desktop/src/driver/index.ts b/packages/agentic/storybook-desktop/src/driver/index.ts new file mode 100644 index 00000000000..93d3a15dcc5 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/index.ts @@ -0,0 +1,5 @@ +export { createDesktopStorybookDriverManifest, writeDesktopStorybookDriverManifest } from './driverManifest.js'; +export type { CreateDesktopStorybookDriverManifestOptions, DesktopStorybookDriverManifest } from './driverManifest.js'; +export { StorybookChannelOrchestrator } from './StorybookChannelOrchestrator.js'; +export type { StorybookChannelOrchestratorOptions, StorybookChannelServer } from './StorybookChannelOrchestrator.js'; +export { createDesktopStoryManifest, writeDesktopStoryManifest } from './storyManifest.js'; diff --git a/packages/agentic/storybook-desktop/src/driver/representativePlans.contract.cjs b/packages/agentic/storybook-desktop/src/driver/representativePlans.contract.cjs new file mode 100644 index 00000000000..27b1cd850bf --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/representativePlans.contract.cjs @@ -0,0 +1,113 @@ +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +async function importFile(filePath) { + return import(pathToFileURL(filePath).href); +} + +async function main() { + const packageRoot = path.resolve(__dirname, '..', '..'); + const projectRoot = path.resolve(packageRoot, '..', '..', '..', 'apps', 'storybook'); + const desktopDriverRoot = path.dirname(require.resolve('@fluentui-react-native/desktop-driver/package.json')); + const [{ makeDesktopStorybookConfig }, { createDesktopStoryManifest }, testing, wdio] = await Promise.all([ + importFile(path.join(packageRoot, 'lib', 'config', 'index.js')), + importFile(path.join(packageRoot, 'lib', 'driver', 'index.js')), + importFile(path.join(desktopDriverRoot, 'lib', 'testing', 'index.js')), + importFile(path.join(desktopDriverRoot, 'lib', 'wdio', 'index.js')), + ]); + const config = makeDesktopStorybookConfig({ + projectRoot, + storyPackages: ['@fluentui-react-native/components'], + }); + const manifest = await createDesktopStoryManifest(config, 'windows'); + const planned = manifest.entries.filter(({ tests }) => tests); + const windowRect = { x: 0, y: 0, width: 800, height: 600 }; + const harness = await testing.createDesktopDriverStoryHarness(manifest, { + windows: [ + { + id: 'window-1', + title: 'Representative Plans', + elements: [ + { + id: 'root', + automationId: 'app-root', + rect: windowRect, + role: 'application', + scope: 'application', + windowId: 'window-1', + }, + { + id: 'story-root', + automationId: 'story-root', + name: JSON.stringify({ previewGeneration: 0, storyId: 'initial--story' }), + parentId: 'root', + rect: windowRect, + role: 'group', + scope: 'preview', + windowId: 'window-1', + }, + { + id: 'button', + automationId: 'agentic-storybook-button', + name: 'Button', + parentId: 'story-root', + rect: { x: 10, y: 10, width: 120, height: 40 }, + role: 'button', + scope: 'preview', + windowId: 'window-1', + }, + { + id: 'checkbox', + automationId: 'agentic-storybook-checkbox', + checked: false, + name: 'Checkbox', + parentId: 'story-root', + rect: { x: 10, y: 60, width: 120, height: 40 }, + role: 'checkbox', + scope: 'preview', + windowId: 'window-1', + }, + { + id: 'input', + automationId: 'agentic-storybook-input', + name: 'Search files', + parentId: 'story-root', + rect: { x: 10, y: 110, width: 200, height: 40 }, + role: 'textbox', + scope: 'preview', + value: '', + windowId: 'window-1', + }, + ], + }, + ], + }); + const desktop = await wdio.connectDesktopWebdriver({ + platformName: 'windows', + targetId: harness.target.id, + url: harness.server.url, + }); + try { + const runOptions = { + artifactsRoot: process.argv[2], + selection: { tag: 'desktop-e2e' }, + }; + const result = await desktop.runStoryTests(runOptions); + const repeated = await desktop.runStoryTests(runOptions); + process.stdout.write( + JSON.stringify({ + planned: planned.map(({ id, tests }) => ({ id, tests: tests.tests.map(({ id: testId }) => testId) })), + repeated, + result, + }), + ); + } finally { + await desktop.delete(); + await harness.close(); + } +} + +main().catch((error) => { + process.stderr.write(error.stack ?? error.message); + process.exitCode = 1; +}); diff --git a/packages/agentic/storybook-desktop/src/driver/representativePlans.test.ts b/packages/agentic/storybook-desktop/src/driver/representativePlans.test.ts new file mode 100644 index 00000000000..a6ea286e20e --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/representativePlans.test.ts @@ -0,0 +1,61 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +jest.setTimeout(30_000); + +describe('representative desktop story plans', () => { + test('extracts and runs Button, Checkbox, and Input plans unchanged through WebdriverIO', async () => { + const artifactsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'representative-story-plans-')); + try { + const response = await runContract(artifactsRoot); + expect(response).toMatchObject({ + planned: [ + { id: 'components-button--default', tests: ['pointer-focus'] }, + { id: 'components-checkbox--default', tests: ['toggles-checked-state'] }, + { id: 'components-input--default', tests: ['types-and-clears'] }, + ], + result: { + status: 'passed', + tests: [ + { status: 'passed', testId: 'pointer-focus' }, + { status: 'passed', testId: 'toggles-checked-state' }, + { status: 'passed', testId: 'types-and-clears' }, + ], + }, + repeated: { + status: 'passed', + tests: [ + { status: 'passed', testId: 'pointer-focus' }, + { status: 'passed', testId: 'toggles-checked-state' }, + { status: 'passed', testId: 'types-and-clears' }, + ], + }, + }); + expect(fs.existsSync(path.join(artifactsRoot, 'run.json'))).toBe(true); + } finally { + fs.rmSync(artifactsRoot, { force: true, recursive: true }); + } + }); +}); + +function runContract(artifactsRoot: string): Promise> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(__dirname, 'representativePlans.contract.cjs'), artifactsRoot], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.once('error', reject); + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || `Representative plan process exited with code ${code}.`)); + return; + } + resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')) as Record); + }); + }); +} diff --git a/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts b/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts new file mode 100644 index 00000000000..2931d706311 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts @@ -0,0 +1,148 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { createServer } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +import { createDesktopDriverClient } from '@fluentui-react-native/desktop-driver/client'; + +import type { DesktopStorybookDriverManifest } from './driverManifest.js'; + +jest.setTimeout(30_000); + +describe('desktop Storybook server integration', () => { + test('hosts the Storybook channel and Stage 1 smoke-test target in one process', async () => { + const [storybookPort, driverPort] = await Promise.all([getAvailablePort(), getAvailablePort()]); + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'furn-storybook-driver-')); + const manifestPath = path.join(temporaryDirectory, 'driver-manifest.json'); + const projectRoot = path.resolve(__dirname, '../../../../../apps/storybook'); + const driverManifest: DesktopStorybookDriverManifest = { + appName: 'AgenticStorybook', + bridgeNonce: 'integration-nonce', + displayName: 'Agentic Components Storybook', + driverPort, + endpoint: 'windows', + instanceId: 'integration', + metroPort: 8081, + platformManifestDigest: 'platform-digest', + portablePlanDigest: 'portable-digest', + renderer: 'fabric', + schemaVersion: 1, + storyManifest: { + endpoint: 'windows', + entries: [ + { + id: 'components-button--default', + name: 'Default', + packageName: '@fluentui-react-native/components', + sourcePath: 'src/components/button/button.stories.tsx', + tags: ['desktop-e2e'], + title: 'Components/Button', + }, + ], + platformManifestDigest: 'platform-digest', + portablePlanDigest: 'portable-digest', + schemaVersion: 1, + }, + storybookPort, + targetId: 'integration-windows', + testIDPrefix: 'integration-storybook', + }; + fs.writeFileSync(manifestPath, JSON.stringify(driverManifest)); + + const child = spawn(process.execPath, [path.resolve(__dirname, '../../config/server-runner.cjs')], { + env: { + ...process.env, + STORYBOOK_CONFIG_PATH: path.join(projectRoot, 'src'), + STORYBOOK_DRIVER_MANIFEST: manifestPath, + STORYBOOK_PROJECT_ROOT: projectRoot, + STORYBOOK_SMOKE_MODE: 'stories-and-tests', + STORYBOOK_WS_PORT: String(storybookPort), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const output: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => output.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => output.push(chunk)); + + try { + const [indexResponse, driverResponse] = await Promise.all([ + waitForResponse(loopbackUrl(storybookPort, '/index.json')), + waitForResponse(loopbackUrl(driverPort, '/status')), + ]); + const index = (await indexResponse.json()) as { entries?: Record }; + const driver = (await driverResponse.json()) as { value?: { ready?: boolean; targets?: { id?: string }[] } }; + + expect(Object.keys(index.entries ?? {}).length).toBeGreaterThan(0); + expect(driver.value).toMatchObject({ + ready: true, + targets: [{ id: 'integration-windows' }], + }); + + const client = createDesktopDriverClient({ url: loopbackUrl(driverPort, '') }); + const session = await client.newSession({ + alwaysMatch: { + platformName: 'windows', + 'furn:target': 'integration-windows', + }, + }); + await expect(session.selectStory('components-button--default', 'integration-run')).resolves.toEqual({ + previewGeneration: 1, + runId: 'integration-run', + storyId: 'components-button--default', + }); + await session.delete(); + } finally { + child.kill(); + await waitForExit(child); + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } + + expect(Buffer.concat(output).toString('utf8')).toContain('WebDriver:'); + }); +}); + +function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Could not allocate a loopback port.')); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +async function waitForResponse(url: string): Promise { + const deadline = Date.now() + 20_000; + let lastError: unknown; + do { + try { + const response = await fetch(url); + if (response.ok) { + return response; + } + lastError = new Error(`${url} returned ${response.status}.`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (Date.now() < deadline); + throw new Error(`Timed out waiting for ${url}: ${(lastError as Error)?.message ?? 'not ready'}`); +} + +function waitForExit(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(); + } + return new Promise((resolve) => child.once('exit', () => resolve())); +} + +function loopbackUrl(port: number, pathname: string): string { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback service + return `http://127.0.0.1:${port}${pathname}`; +} diff --git a/packages/agentic/storybook-desktop/src/driver/storyManifest.test.ts b/packages/agentic/storybook-desktop/src/driver/storyManifest.test.ts new file mode 100644 index 00000000000..b75f4f9a417 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/storyManifest.test.ts @@ -0,0 +1,170 @@ +import path from 'node:path'; + +import type { DesktopStorybookConfig, ResolvedStoryPackage } from '../config/makeDesktopStorybookConfig.js'; +import { createDesktopStoryManifest } from './storyManifest.js'; + +const projectRoot = path.resolve(__dirname, '../../../../../apps/storybook'); +const fixtureRoot = path.resolve(__dirname, 'fixtures'); + +function fixtureConfig() { + const storyPackage: ResolvedStoryPackage = { + manifest: { name: '@fluentui-react-native/storybook-fixture' }, + name: '@fluentui-react-native/storybook-fixture', + root: fixtureRoot, + storyPatterns: ['**/*.stories.ts'], + }; + return { + projectRoot, + getStoryPackages: () => [storyPackage], + } satisfies Pick; +} + +describe('createDesktopStoryManifest', () => { + test('extracts serializable plans and creates stable platform and portable digests', async () => { + const tools = { + loadCsf: () => ({ + parse: () => ({ + meta: { title: 'Components/FixtureButton' }, + stories: [ + { + id: 'components-fixturebutton--default', + name: 'Default', + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'clicks-button', + requires: ['physical-click'], + steps: [{ action: 'click', target: { testId: 'fixture-button' } }], + }, + ], + }, + }, + }, + ], + }), + }), + }; + const windows = await createDesktopStoryManifest(fixtureConfig(), 'windows', tools); + const macos = await createDesktopStoryManifest(fixtureConfig(), 'macos', tools); + + expect(windows).toMatchObject({ + endpoint: 'windows', + schemaVersion: 1, + entries: [ + { + id: 'components-fixturebutton--default', + packageName: '@fluentui-react-native/storybook-fixture', + sourcePath: 'button.stories.ts', + tests: { + version: 1, + tests: [{ id: 'clicks-button' }], + }, + }, + ], + }); + expect(windows.platformManifestDigest).not.toBe(macos.platformManifestDigest); + expect(windows.portablePlanDigest).toBe(macos.portablePlanDigest); + }); + + test('rejects dynamic, spread, and computed parameter containers and plan keys', async () => { + const story = { id: 'components-fixturebutton--default', name: 'Default' }; + const dynamicParameterTools = { + loadCsf: () => ({ + parse: () => ({ + _stories: { Default: story }, + _storyAnnotations: { + Default: { + parameters: { type: 'CallExpression', loc: { start: { line: 8 } } }, + }, + }, + meta: { title: 'Components/FixtureButton' }, + stories: [story], + }), + }), + }; + await expect(createDesktopStoryManifest(fixtureConfig(), 'windows', dynamicParameterTools)).rejects.toThrow('static object literal'); + + const spreadParameterTools = { + loadCsf: () => ({ + parse: () => ({ + _stories: { Default: story }, + _storyAnnotations: { + Default: { + parameters: { + type: 'ObjectExpression', + properties: [{ type: 'SpreadElement', argument: { type: 'Identifier', name: 'shared' } }], + }, + }, + }, + meta: { title: 'Components/FixtureButton' }, + stories: [story], + }), + }), + }; + await expect(createDesktopStoryManifest(fixtureConfig(), 'windows', spreadParameterTools)).rejects.toThrow('use a spread'); + + const computedParameterTools = { + loadCsf: () => ({ + parse: () => ({ + _stories: { Default: story }, + _storyAnnotations: { + Default: { + parameters: { + type: 'ObjectExpression', + properties: [ + { + type: 'ObjectProperty', + computed: true, + key: { type: 'Identifier', name: 'planKey' }, + value: { type: 'ObjectExpression', properties: [] }, + loc: { start: { line: 12 } }, + }, + ], + }, + }, + }, + meta: { title: 'Components/FixtureButton' }, + stories: [story], + }), + }), + }; + await expect(createDesktopStoryManifest(fixtureConfig(), 'windows', computedParameterTools)).rejects.toThrow('computed property'); + + const computedPlanTools = { + loadCsf: () => ({ + parse: () => ({ + _stories: { Default: story }, + _storyAnnotations: { + Default: { + parameters: { + type: 'ObjectExpression', + properties: [ + { + type: 'ObjectProperty', + key: { type: 'Identifier', name: 'desktopDriver' }, + value: { + type: 'ObjectExpression', + properties: [ + { + type: 'ObjectProperty', + computed: true, + key: { type: 'Identifier', name: 'version' }, + value: { type: 'NumericLiteral', value: 1 }, + }, + ], + }, + }, + ], + }, + }, + }, + meta: { title: 'Components/FixtureButton' }, + stories: [story], + }), + }), + }; + await expect(createDesktopStoryManifest(fixtureConfig(), 'windows', computedPlanTools)).rejects.toThrow('Computed object properties'); + }); +}); diff --git a/packages/agentic/storybook-desktop/src/driver/storyManifest.ts b/packages/agentic/storybook-desktop/src/driver/storyManifest.ts new file mode 100644 index 00000000000..f7a62bb78bf --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/storyManifest.ts @@ -0,0 +1,260 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { validateDesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; +import type { DesktopStoryManifest, DesktopStoryManifestEntry, DesktopStoryTests } from '@fluentui-react-native/desktop-driver'; + +import type { DesktopStorybookConfig, ResolvedStoryPackage } from '../config/makeDesktopStorybookConfig.js'; +import type { Platforms } from '../config/platforms.js'; + +type StaticStory = { + id: string; + name?: string; + parameters?: Record; + tags?: string[]; +}; + +type BabelNode = { + argument?: BabelNode; + computed?: boolean; + elements?: (BabelNode | null)[]; + expression?: BabelNode; + expressions?: BabelNode[]; + extra?: { rawValue?: unknown }; + key?: BabelNode; + loc?: { start?: { line?: number } }; + name?: string; + operator?: string; + properties?: BabelNode[]; + quasis?: { value?: { cooked?: string } }[]; + type: string; + value?: unknown; +}; + +type CsfFile = { + _stories?: Record; + _storyAnnotations?: Record>; + meta?: { tags?: string[]; title?: string }; + stories: StaticStory[]; +}; + +type StorybookCsfTools = { + loadCsf(code: string, options: { fileName: string; makeTitle(title: string): string }): { parse(): CsfFile }; +}; + +type StoryManifestConfig = Pick & { + getStoryPackages(platform: Platforms): readonly ResolvedStoryPackage[]; +}; + +export async function createDesktopStoryManifest( + config: StoryManifestConfig, + platform: Platforms, + tools?: StorybookCsfTools, +): Promise { + const requireFromProject = createRequire(path.join(config.projectRoot, 'package.json')); + const { loadCsf } = tools ?? (await loadStorybookCsfTools(requireFromProject)); + const packages = config.getStoryPackages(platform); + const entries: DesktopStoryManifestEntry[] = []; + + for (const storyPackage of packages) { + const sourceFiles = [ + ...new Set( + storyPackage.storyPatterns.flatMap((pattern) => + fs.globSync(pattern, { cwd: storyPackage.root }).map((sourceFile) => path.resolve(storyPackage.root, sourceFile)), + ), + ), + ].sort(); + for (const sourceFile of sourceFiles) { + const code = fs.readFileSync(sourceFile, 'utf8'); + let csf: CsfFile; + try { + csf = loadCsf(code, { fileName: sourceFile, makeTitle: (title) => title }).parse(); + } catch (error) { + throw new Error(`Failed to statically parse Storybook file ${sourceFile}: ${(error as Error).message}`, { cause: error }); + } + const stories = csf._stories ? Object.entries(csf._stories) : csf.stories.map((story) => [story.name ?? story.id, story] as const); + for (const [exportName, staticStory] of stories) { + const tests = readDesktopStoryTests( + extractDesktopStoryTests(csf._storyAnnotations?.[exportName]?.parameters, sourceFile, staticStory.id) ?? + staticStory.parameters?.desktopDriver, + sourceFile, + staticStory.id, + ); + entries.push({ + id: staticStory.id, + name: staticStory.name ?? staticStory.id, + packageName: storyPackage.name, + sourcePath: toPosixPath(path.relative(storyPackage.root, sourceFile)), + tags: [...new Set([...(csf.meta?.tags ?? []), ...(staticStory.tags ?? []), 'story'])].sort(), + title: csf.meta?.title ?? staticStory.id.split('--')[0], + ...(tests ? { tests } : {}), + }); + } + + function extractDesktopStoryTests(parameters: BabelNode | undefined, sourceFile: string, storyId: string): unknown { + if (!parameters) { + return undefined; + } + const object = unwrapExpression(parameters); + if (object.type !== 'ObjectExpression') { + const line = object.loc?.start?.line; + throw new Error(`Story parameters for "${storyId}" in ${sourceFile}${line ? `:${line}` : ''} must be a static object literal.`); + } + const spreadProperty = object.properties?.find((candidate) => candidate.type === 'SpreadElement'); + if (spreadProperty) { + const line = spreadProperty.loc?.start?.line; + throw new Error( + `Story parameters for "${storyId}" in ${sourceFile}${line ? `:${line}` : ''} use a spread and cannot be statically inspected.`, + ); + } + const computedProperty = object.properties?.find((candidate) => candidate.computed); + if (computedProperty) { + const line = computedProperty.loc?.start?.line; + throw new Error( + `Story parameters for "${storyId}" in ${sourceFile}${line ? `:${line}` : ''} use a computed property and cannot be statically inspected.`, + ); + } + const property = object.properties?.find( + (candidate) => candidate.type === 'ObjectProperty' && propertyName(candidate.key) === 'desktopDriver', + ); + if (!property) { + return undefined; + } + if (!property.value || typeof property.value !== 'object') { + throw new Error(`Desktop-driver plan for "${storyId}" in ${sourceFile} does not have a static value.`); + } + try { + return evaluateStaticValue(property.value as BabelNode); + } catch (error) { + const line = property.loc?.start?.line; + throw new Error( + `Desktop-driver plan for "${storyId}" in ${sourceFile}${line ? `:${line}` : ''} must be a static JSON literal: ${ + (error as Error).message + }`, + { cause: error }, + ); + } + } + + function evaluateStaticValue(node: BabelNode): unknown { + const value = unwrapExpression(node); + switch (value.type) { + case 'StringLiteral': + case 'NumericLiteral': + case 'BooleanLiteral': + return value.value; + case 'NullLiteral': + return null; + case 'TemplateLiteral': + if ((value.expressions?.length ?? 0) === 0 && value.quasis?.length === 1) { + return value.quasis[0].value?.cooked ?? ''; + } + break; + case 'UnaryExpression': + if (value.operator === '-' && value.argument?.type === 'NumericLiteral') { + return -(value.argument.value as number); + } + break; + case 'ArrayExpression': + return (value.elements ?? []).map((element) => (element ? evaluateStaticValue(element) : null)); + case 'ObjectExpression': + return Object.fromEntries( + (value.properties ?? []).map((property) => { + if (property.type !== 'ObjectProperty') { + throw new Error(`Unsupported object member "${property.type}".`); + } + if (property.computed) { + throw new Error('Computed object properties are not supported.'); + } + const name = propertyName(property.key); + if (!name || !property.value || typeof property.value !== 'object') { + throw new Error('Object properties require static names and values.'); + } + return [name, evaluateStaticValue(property.value as BabelNode)]; + }), + ); + } + throw new Error(`Unsupported expression "${value.type}".`); + } + + function unwrapExpression(node: BabelNode): BabelNode { + let current = node; + while ( + current.expression && + (current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSNonNullExpression' || + current.type === 'ParenthesizedExpression') + ) { + current = current.expression; + } + return current; + } + + function propertyName(node: BabelNode | undefined): string | undefined { + if (!node) { + return undefined; + } + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'StringLiteral') { + return typeof node.value === 'string' ? node.value : undefined; + } + return undefined; + } + } + } + + entries.sort((left, right) => left.id.localeCompare(right.id)); + const endpoint = platform; + const platformManifestDigest = digest({ endpoint, entries }); + const portablePlanDigest = digest( + entries.filter(({ tests }) => tests && tests.portable !== false).map(({ id, tests }) => ({ id, tests })), + ); + + return Object.freeze({ + endpoint, + entries: Object.freeze(entries), + platformManifestDigest, + portablePlanDigest, + schemaVersion: 1, + }); +} + +async function loadStorybookCsfTools(requireFromProject: NodeJS.Require): Promise { + const modulePath = requireFromProject.resolve('storybook/internal/csf-tools'); + return import(pathToFileURL(modulePath).href) as Promise; +} + +export function writeDesktopStoryManifest(manifest: DesktopStoryManifest, outputPath: string): void { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const content = `${JSON.stringify(manifest, null, 2)}\n`; + if (!fs.existsSync(outputPath) || fs.readFileSync(outputPath, 'utf8') !== content) { + fs.writeFileSync(outputPath, content); + } +} + +function readDesktopStoryTests(value: unknown, sourceFile: string, storyId: string): DesktopStoryTests | undefined { + if (value === undefined) { + return undefined; + } + try { + return validateDesktopStoryTests(value, `${sourceFile}#${storyId}.parameters.desktopDriver`); + } catch (error) { + throw new Error(`Invalid desktop-driver plan in ${sourceFile} for story "${storyId}": ${(error as Error).message}`, { + cause: error, + }); + } +} + +function digest(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function toPosixPath(value: string): string { + return value.split(path.sep).join('/'); +} diff --git a/packages/agentic/storybook-desktop/src/index.ts b/packages/agentic/storybook-desktop/src/index.ts new file mode 100644 index 00000000000..8cbaea15464 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/index.ts @@ -0,0 +1,28 @@ +export { + createDesktopStorybookCommand, + DesktopStorybookCli, + loadDesktopStorybookConfig, + NodeDesktopCommandRunner, + runDesktopStorybookCli, +} from './cli/index.js'; +export type { + CreateDesktopStorybookCommandOptions, + DesktopCommandRunner, + DesktopStorybookCliOptions, + DesktopStorybookServerOptions, + PreparedDesktopCommand, + RunningDesktopCommand, +} from './cli/index.js'; +export { + createDesktopStorybookDriverManifest, + createDesktopStoryManifest, + StorybookChannelOrchestrator, + writeDesktopStorybookDriverManifest, + writeDesktopStoryManifest, +} from './driver/index.js'; +export type { + CreateDesktopStorybookDriverManifestOptions, + DesktopStorybookDriverManifest, + StorybookChannelOrchestratorOptions, + StorybookChannelServer, +} from './driver/index.js'; diff --git a/packages/agentic/storybook-desktop/tsconfig.json b/packages/agentic/storybook-desktop/tsconfig.json new file mode 100644 index 00000000000..cc8223069ee --- /dev/null +++ b/packages/agentic/storybook-desktop/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@fluentui-react-native/scripts/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "composite": true, + "rewriteRelativeImportExtensions": true, + "tsBuildInfoFile": ".cache/tsconfig.tsbuildinfo" + }, + "include": ["src"], + "references": [ + { + "path": "../desktop-driver/tsconfig.json" + }, + { + "path": "../../../scripts/tsconfig.json" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index a9e9136c2f9..9444b814778 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,9 +19,18 @@ { "path": "packages/agentic/components/tsconfig.json" }, + { + "path": "packages/agentic/desktop-driver/tsconfig.json" + }, { "path": "packages/agentic/design/tsconfig.json" }, + { + "path": "packages/agentic/storybook-desktop-runtime/tsconfig.json" + }, + { + "path": "packages/agentic/storybook-desktop/tsconfig.json" + }, { "path": "packages/codemods/tsconfig.json" }, diff --git a/yarn.lock b/yarn.lock index 3b6d3c24290..49327536beb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2137,6 +2137,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/aix-ppc64@npm:0.28.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/aix-ppc64@npm:0.28.2" @@ -2144,6 +2151,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm64@npm:0.28.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/android-arm64@npm:0.28.2" @@ -2151,6 +2165,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm@npm:0.28.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/android-arm@npm:0.28.2" @@ -2158,6 +2179,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-x64@npm:0.28.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/android-x64@npm:0.28.2" @@ -2165,6 +2193,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-arm64@npm:0.28.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/darwin-arm64@npm:0.28.2" @@ -2172,6 +2207,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-x64@npm:0.28.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/darwin-x64@npm:0.28.2" @@ -2179,6 +2221,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-arm64@npm:0.28.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/freebsd-arm64@npm:0.28.2" @@ -2186,6 +2235,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-x64@npm:0.28.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/freebsd-x64@npm:0.28.2" @@ -2193,6 +2249,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm64@npm:0.28.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-arm64@npm:0.28.2" @@ -2200,6 +2263,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm@npm:0.28.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-arm@npm:0.28.2" @@ -2207,6 +2277,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ia32@npm:0.28.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-ia32@npm:0.28.2" @@ -2214,6 +2291,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-loong64@npm:0.28.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-loong64@npm:0.28.2" @@ -2221,6 +2305,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-mips64el@npm:0.28.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-mips64el@npm:0.28.2" @@ -2228,6 +2319,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ppc64@npm:0.28.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-ppc64@npm:0.28.2" @@ -2235,6 +2333,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-riscv64@npm:0.28.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-riscv64@npm:0.28.2" @@ -2242,6 +2347,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-s390x@npm:0.28.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-s390x@npm:0.28.2" @@ -2249,6 +2361,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-x64@npm:0.28.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/linux-x64@npm:0.28.2" @@ -2256,6 +2375,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-arm64@npm:0.28.1" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/netbsd-arm64@npm:0.28.2" @@ -2263,6 +2389,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-x64@npm:0.28.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/netbsd-x64@npm:0.28.2" @@ -2270,6 +2403,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-arm64@npm:0.28.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/openbsd-arm64@npm:0.28.2" @@ -2277,6 +2417,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-x64@npm:0.28.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/openbsd-x64@npm:0.28.2" @@ -2284,6 +2431,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openharmony-arm64@npm:0.28.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/openharmony-arm64@npm:0.28.2" @@ -2291,6 +2445,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/sunos-x64@npm:0.28.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/sunos-x64@npm:0.28.2" @@ -2298,6 +2459,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-arm64@npm:0.28.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/win32-arm64@npm:0.28.2" @@ -2305,6 +2473,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-ia32@npm:0.28.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/win32-ia32@npm:0.28.2" @@ -2312,6 +2487,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-x64@npm:0.28.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/win32-x64@npm:0.28.2" @@ -2330,7 +2512,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.11.0, @eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.11.0, @eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d @@ -2379,49 +2561,34 @@ __metadata: "@babel/core": "catalog:" "@fluentui-react-native/callout": "workspace:*" "@fluentui-react-native/components": "workspace:*" - "@fluentui-react-native/default-theme": "workspace:*" - "@fluentui-react-native/design": "workspace:*" + "@fluentui-react-native/desktop-driver": "workspace:*" "@fluentui-react-native/focus-zone": "workspace:*" "@fluentui-react-native/scripts": "workspace:*" + "@fluentui-react-native/storybook-desktop": "workspace:*" + "@fluentui-react-native/storybook-desktop-runtime": "workspace:*" "@office-iss/react-native-win32": "npm:^0.81.0" "@office-iss/rex-win32": "npm:0.81.1" "@react-native-community/cli": "npm:^20.0.0" "@react-native-community/cli-platform-android": "npm:^20.0.0" "@react-native-community/cli-platform-ios": "npm:^20.0.0" - "@react-native-windows/automation": "npm:0.81.32" "@react-native-windows/cli": "npm:^0.81.0" "@react-native/babel-preset": "npm:^0.81.0" "@react-native/metro-babel-transformer": "npm:^0.81.0" "@react-native/metro-config": "npm:^0.81.0" "@rnx-kit/cli": "catalog:" - "@rnx-kit/metro-config": "catalog:" - "@rnx-kit/metro-resolver-symlinks": "catalog:" "@storybook/addon-ondevice-actions": "npm:^10.4.7" "@storybook/addon-ondevice-controls": "npm:^10.4.7" - "@storybook/mcp": "npm:^0.7.0" - "@storybook/react": "npm:^10.4.6" "@storybook/react-native": "npm:^10.4.7" - "@storybook/react-native-theming": "npm:^10.4.7" - "@storybook/react-native-ui-common": "npm:^10.4.7" - "@storybook/react-native-ui-lite": "npm:^10.4.7" - "@tmcp/adapter-valibot": "npm:^0.1.6" - "@tmcp/transport-http": "npm:^0.8.6" "@types/react": "npm:~19.1.4" cross-env: "catalog:" - jest: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" metro: "npm:^0.83.8" - oxc-resolver: "catalog:" react: "npm:19.1.4" react-native: "npm:^0.81.6" react-native-macos: "npm:^0.81.0" react-native-svg: "npm:^15.12.1" react-native-test-app: "catalog:" react-native-windows: "npm:^0.81.0" - regexpu-core: "npm:^6.3.1" storybook: "npm:^10.4.0" - tmcp: "npm:^1.19.4" - valibot: "npm:^1.4.2" languageName: unknown linkType: soft @@ -2856,6 +3023,7 @@ __metadata: "@babel/core": "catalog:" "@fluentui-react-native/callout": "workspace:*" "@fluentui-react-native/design": "workspace:*" + "@fluentui-react-native/desktop-driver": "workspace:*" "@fluentui-react-native/framework-base": "workspace:*" "@fluentui-react-native/scripts": "workspace:*" "@react-native-community/cli": "npm:^20.0.0" @@ -3319,6 +3487,18 @@ __metadata: languageName: unknown linkType: soft +"@fluentui-react-native/desktop-driver@workspace:*, @fluentui-react-native/desktop-driver@workspace:packages/agentic/desktop-driver": + version: 0.0.0-use.local + resolution: "@fluentui-react-native/desktop-driver@workspace:packages/agentic/desktop-driver" + dependencies: + "@fluentui-react-native/scripts": "workspace:*" + commander: "npm:^14.0.2" + webdriverio: "catalog:" + bin: + desktop-driver: ./config/cli.cjs + languageName: unknown + linkType: soft + "@fluentui-react-native/divider@workspace:*, @fluentui-react-native/divider@workspace:packages/components/Divider": version: 0.0.0-use.local resolution: "@fluentui-react-native/divider@workspace:packages/components/Divider" @@ -4849,6 +5029,66 @@ __metadata: languageName: unknown linkType: soft +"@fluentui-react-native/storybook-desktop-runtime@workspace:*, @fluentui-react-native/storybook-desktop-runtime@workspace:packages/agentic/storybook-desktop-runtime": + version: 0.0.0-use.local + resolution: "@fluentui-react-native/storybook-desktop-runtime@workspace:packages/agentic/storybook-desktop-runtime" + dependencies: + "@babel/core": "catalog:" + "@fluentui-react-native/callout": "workspace:*" + "@fluentui-react-native/default-theme": "workspace:*" + "@fluentui-react-native/design": "workspace:*" + "@fluentui-react-native/scripts": "workspace:*" + "@office-iss/react-native-win32": "npm:^0.81.0" + "@react-native/babel-preset": "npm:^0.81.0" + "@rnx-kit/metro-config": "catalog:" + "@rnx-kit/metro-resolver-symlinks": "catalog:" + "@storybook/react-native": "npm:^10.4.7" + "@storybook/react-native-theming": "npm:^10.4.7" + "@storybook/react-native-ui-common": "npm:^10.4.7" + "@storybook/react-native-ui-lite": "npm:^10.4.7" + "@types/react": "npm:~19.1.4" + oxc-resolver: "catalog:" + react: "npm:19.1.4" + react-native: "npm:^0.81.6" + react-native-macos: "npm:^0.81.0" + react-native-windows: "npm:^0.81.0" + storybook: "npm:^10.4.0" + peerDependencies: + "@office-iss/react-native-win32": ^0.81.0 + "@types/react": ~19.1.4 + react: 19.1.4 + react-native: ^0.81.6 + react-native-macos: ^0.81.0 + react-native-windows: ^0.81.0 + peerDependenciesMeta: + "@office-iss/react-native-win32": + optional: true + "@types/react": + optional: true + react-native-macos: + optional: true + react-native-windows: + optional: true + languageName: unknown + linkType: soft + +"@fluentui-react-native/storybook-desktop@workspace:*, @fluentui-react-native/storybook-desktop@workspace:packages/agentic/storybook-desktop": + version: 0.0.0-use.local + resolution: "@fluentui-react-native/storybook-desktop@workspace:packages/agentic/storybook-desktop" + dependencies: + "@babel/core": "catalog:" + "@fluentui-react-native/desktop-driver": "workspace:*" + "@fluentui-react-native/scripts": "workspace:*" + "@react-native/babel-preset": "npm:^0.81.0" + "@rnx-kit/tools-react-native": "catalog:" + commander: "npm:^14.0.2" + regexpu-core: "npm:^6.3.1" + bin: + storybook-desktop: ./config/cli.cjs + storybook-server: ./config/server-cli.cjs + languageName: unknown + linkType: soft + "@fluentui-react-native/styling-utils@workspace:*, @fluentui-react-native/styling-utils@workspace:packages/utils/styling": version: 0.0.0-use.local resolution: "@fluentui-react-native/styling-utils@workspace:packages/utils/styling" @@ -8319,36 +8559,6 @@ __metadata: languageName: node linkType: hard -"@react-native-windows/automation-channel@npm:0.81.32": - version: 0.81.32 - resolution: "@react-native-windows/automation-channel@npm:0.81.32" - dependencies: - "@typescript-eslint/eslint-plugin": "npm:^7.1.1" - "@typescript-eslint/parser": "npm:^7.1.1" - jsonrpc-lite: "npm:^2.2.0" - checksum: 10c0/f4488415f95670a94caf2c0ff3432972578e436274f0a02011fee889a4fa51b183b216c0e5eaebec0f2c98ab2ccff484bf43454494ce00f541fa32a639b2a3f4 - languageName: node - linkType: hard - -"@react-native-windows/automation@npm:0.81.32": - version: 0.81.32 - resolution: "@react-native-windows/automation@npm:0.81.32" - dependencies: - "@react-native-windows/automation-channel": "npm:0.81.32" - "@react-native-windows/find-dotnet-tools": "npm:0.0.0-canary.2" - "@react-native-windows/fs": "npm:0.81.1" - "@typescript-eslint/eslint-plugin": "npm:^7.1.1" - "@typescript-eslint/parser": "npm:^7.1.1" - chalk: "npm:^4.1.2" - readline-sync: "npm:1.4.10" - webdriverio: "npm:^6.9.0" - peerDependencies: - jest: ">=29.0.3" - jest-environment-node: ">=29.2.2" - checksum: 10c0/c9444036d2f3522e22aff80db04e5c01d4c988b6b4e889fdbd05f3b27f8c12a6c6fdf222dc50cb156b275fa0088c44a73750d3f743d0a7bffb294c211f543276 - languageName: node - linkType: hard - "@react-native-windows/cli@npm:0.81.8": version: 0.81.8 resolution: "@react-native-windows/cli@npm:0.81.8" @@ -8431,15 +8641,6 @@ __metadata: languageName: node linkType: hard -"@react-native-windows/find-dotnet-tools@npm:0.0.0-canary.2": - version: 0.0.0-canary.2 - resolution: "@react-native-windows/find-dotnet-tools@npm:0.0.0-canary.2" - dependencies: - "@react-native-windows/fs": "npm:^0.0.0-canary.72" - checksum: 10c0/63c84f8fb18cee2519ab069ede2949b62e139e5859422cd0201f422dba0c25f93022d1ee0fcb437f227d6062bf16f7c11af78ea81873435b7cdad4e18dbaf8d9 - languageName: node - linkType: hard - "@react-native-windows/find-dotnet-tools@npm:0.0.0-canary.3": version: 0.0.0-canary.3 resolution: "@react-native-windows/find-dotnet-tools@npm:0.0.0-canary.3" @@ -8459,15 +8660,6 @@ __metadata: languageName: node linkType: hard -"@react-native-windows/fs@npm:0.81.1": - version: 0.81.1 - resolution: "@react-native-windows/fs@npm:0.81.1" - dependencies: - graceful-fs: "npm:^4.2.8" - checksum: 10c0/3462a5c8b634ed76716276a6e7a7fd1697d8109a4dc16cae92928e54dca9859f400e5f8ec85d0bc0904df4730222de7ef9a03c04fcb91885e674c75cde40ce74 - languageName: node - linkType: hard - "@react-native-windows/fs@npm:0.81.2, @react-native-windows/fs@npm:^0.81.2": version: 0.81.2 resolution: "@react-native-windows/fs@npm:0.81.2" @@ -8477,16 +8669,6 @@ __metadata: languageName: node linkType: hard -"@react-native-windows/fs@npm:^0.0.0-canary.72": - version: 0.0.0-canary.72 - resolution: "@react-native-windows/fs@npm:0.0.0-canary.72" - dependencies: - graceful-fs: "npm:^4.2.8" - minimatch: "npm:^10.0.3" - checksum: 10c0/b9ea35e739ffdc970584e84225bdc2f94724efbf7bc64442078cad097793e7fd6909b0dd8cd0037cba2e686c33e909f2ae458cbc8303be5a8b8187671799e0d0 - languageName: node - linkType: hard - "@react-native-windows/package-utils@npm:0.81.2": version: 0.81.2 resolution: "@react-native-windows/package-utils@npm:0.81.2" @@ -9248,7 +9430,7 @@ __metadata: languageName: node linkType: hard -"@rnx-kit/tools-node@npm:^3.0.0, @rnx-kit/tools-node@npm:^3.0.3, @rnx-kit/tools-node@npm:^3.0.4, @rnx-kit/tools-node@npm:^3.0.6": +"@rnx-kit/tools-node@npm:^3.0.0, @rnx-kit/tools-node@npm:^3.0.3, @rnx-kit/tools-node@npm:^3.0.4, @rnx-kit/tools-node@npm:^3.0.5, @rnx-kit/tools-node@npm:^3.0.6": version: 3.0.6 resolution: "@rnx-kit/tools-node@npm:3.0.6" dependencies: @@ -9279,6 +9461,22 @@ __metadata: languageName: node linkType: hard +"@rnx-kit/tools-react-native@npm:^2.3.8": + version: 2.3.8 + resolution: "@rnx-kit/tools-react-native@npm:2.3.8" + dependencies: + "@rnx-kit/tools-filesystem": "npm:^0.2.0" + "@rnx-kit/tools-node": "npm:^3.0.5" + "@rnx-kit/types-bundle-config": "npm:^1.0.0" + peerDependencies: + "@react-native-community/cli-types": "*" + peerDependenciesMeta: + "@react-native-community/cli-types": + optional: true + checksum: 10c0/2881b698aaea20d4ea92f2bf0b08997255cd574b257c8942bf2a72bd3e692e00fea69cf2af31c3527495d78d8e8ece7c490e2250724c942153d9a904e60dcda8 + languageName: node + linkType: hard + "@rnx-kit/tools-shell@npm:^0.2.2": version: 0.2.2 resolution: "@rnx-kit/tools-shell@npm:0.2.2" @@ -9452,13 +9650,6 @@ __metadata: languageName: node linkType: hard -"@sindresorhus/is@npm:^4.0.0": - version: 4.6.0 - resolution: "@sindresorhus/is@npm:4.6.0" - checksum: 10c0/33b6fb1d0834ec8dd7689ddc0e2781c2bfd8b9c4e4bacbcb14111e0ae00621f2c264b8a7d36541799d74888b5dccdf422a891a5cb5a709ace26325eedc81e22e - languageName: node - linkType: hard - "@sindresorhus/merge-streams@npm:^4.0.0": version: 4.0.0 resolution: "@sindresorhus/merge-streams@npm:4.0.0" @@ -9555,18 +9746,6 @@ __metadata: languageName: node linkType: hard -"@storybook/mcp@npm:^0.7.0": - version: 0.7.0 - resolution: "@storybook/mcp@npm:0.7.0" - dependencies: - "@tmcp/adapter-valibot": "npm:^0.1.5" - "@tmcp/transport-http": "npm:^0.8.5" - tmcp: "npm:^1.19.3" - valibot: "npm:1.2.0" - checksum: 10c0/56c90e597684e55e40451da47fc41e581d21365f405990c3cfbaaf8a19b71da17ede83399ed1524c6a1726050b0e0ba55474de5344cdeefe0cf9045faccb92b8 - languageName: node - linkType: hard - "@storybook/mcp@npm:^0.8.0": version: 0.8.0 resolution: "@storybook/mcp@npm:0.8.0" @@ -9712,7 +9891,7 @@ __metadata: languageName: node linkType: hard -"@storybook/react@npm:^10.4.6, @storybook/react@npm:^10.5.4": +"@storybook/react@npm:^10.5.4": version: 10.5.6 resolution: "@storybook/react@npm:10.5.6" dependencies: @@ -9878,15 +10057,6 @@ __metadata: languageName: node linkType: hard -"@szmarczak/http-timer@npm:^4.0.5": - version: 4.0.6 - resolution: "@szmarczak/http-timer@npm:4.0.6" - dependencies: - defer-to-connect: "npm:^2.0.0" - checksum: 10c0/73946918c025339db68b09abd91fa3001e87fc749c619d2e9c2003a663039d4c3cb89836c98a96598b3d47dec2481284ba85355392644911f5ecd2336536697f - languageName: node - linkType: hard - "@testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -10084,18 +10254,6 @@ __metadata: languageName: node linkType: hard -"@types/cacheable-request@npm:^6.0.1": - version: 6.0.3 - resolution: "@types/cacheable-request@npm:6.0.3" - dependencies: - "@types/http-cache-semantics": "npm:*" - "@types/keyv": "npm:^3.1.4" - "@types/node": "npm:*" - "@types/responselike": "npm:^1.0.0" - checksum: 10c0/10816a88e4e5b144d43c1d15a81003f86d649776c7f410c9b5e6579d0ad9d4ca71c541962fb403077388b446e41af7ae38d313e46692144985f006ac5e11fa03 - languageName: node - linkType: hard - "@types/chai@npm:^5.2.2": version: 5.2.3 resolution: "@types/chai@npm:5.2.3" @@ -10139,13 +10297,6 @@ __metadata: languageName: node linkType: hard -"@types/http-cache-semantics@npm:*": - version: 4.2.0 - resolution: "@types/http-cache-semantics@npm:4.2.0" - checksum: 10c0/82dd33cbe7d4843f1e884a251c6a12d385b62274353b9db167462e7fbffdbb3a83606f9952203017c5b8cabbd7b9eef0cf240a3a9dedd20f69875c9701939415 - languageName: node - linkType: hard - "@types/invariant@npm:^2.2.0": version: 2.2.37 resolution: "@types/invariant@npm:2.2.37" @@ -10221,15 +10372,6 @@ __metadata: languageName: node linkType: hard -"@types/keyv@npm:^3.1.4": - version: 3.1.4 - resolution: "@types/keyv@npm:3.1.4" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/ff8f54fc49621210291f815fe5b15d809fd7d032941b3180743440bd507ecdf08b9e844625fa346af568c84bf34114eb378dcdc3e921a08ba1e2a08d7e3c809c - languageName: node - linkType: hard - "@types/micromatch@npm:^4.0.9": version: 4.0.9 resolution: "@types/micromatch@npm:4.0.9" @@ -10301,24 +10443,6 @@ __metadata: languageName: node linkType: hard -"@types/puppeteer-core@npm:^5.4.0": - version: 5.4.0 - resolution: "@types/puppeteer-core@npm:5.4.0" - dependencies: - "@types/puppeteer": "npm:*" - checksum: 10c0/e7480c2551a260aa115ebe7ab8916d4f3935a23301b12d7c162b98801b264f29d8c88723fc30da7ed95f40f0907ae9428effdd6204b4522b7b1183c9ed92ff35 - languageName: node - linkType: hard - -"@types/puppeteer@npm:*": - version: 5.4.7 - resolution: "@types/puppeteer@npm:5.4.7" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/f15bccf30526151c6e42c797b844f24eb7489f0180b391857c8d6902dfa96c7f48730540229a681505ca70e9197cdac0dfbeaca0c2537526358ad5656bef703d - languageName: node - linkType: hard - "@types/react-native@ignore:": version: 0.0.0-use.local resolution: "@types/react-native@ignore:" @@ -10359,15 +10483,6 @@ __metadata: languageName: node linkType: hard -"@types/responselike@npm:^1.0.0": - version: 1.0.3 - resolution: "@types/responselike@npm:1.0.3" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/a58ba341cb9e7d74f71810a88862da7b2a6fa42e2a1fc0ce40498f6ea1d44382f0640117057da779f74c47039f7166bf48fad02dc876f94e005c7afa50f5e129 - languageName: node - linkType: hard - "@types/sinonjs__fake-timers@npm:^8.1.5": version: 8.1.5 resolution: "@types/sinonjs__fake-timers@npm:8.1.5" @@ -10396,13 +10511,6 @@ __metadata: languageName: node linkType: hard -"@types/which@npm:^1.3.2": - version: 1.3.2 - resolution: "@types/which@npm:1.3.2" - checksum: 10c0/4d1f5f2d9fd8b86aa3a9283d4ccd5ea0752b0a5be9c57a9bd4e0862bf76c599dc664c6bdeb9534f1059515da1b052c0b8d24ecfbd70977cd4386f903234b3729 - languageName: node - linkType: hard - "@types/which@npm:^2.0.1": version: 2.0.2 resolution: "@types/which@npm:2.0.2" @@ -10464,29 +10572,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^7.1.1": - version: 7.18.0 - resolution: "@typescript-eslint/eslint-plugin@npm:7.18.0" - dependencies: - "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.18.0" - "@typescript-eslint/type-utils": "npm:7.18.0" - "@typescript-eslint/utils": "npm:7.18.0" - "@typescript-eslint/visitor-keys": "npm:7.18.0" - graphemer: "npm:^1.4.0" - ignore: "npm:^5.3.1" - natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^1.3.0" - peerDependencies: - "@typescript-eslint/parser": ^7.0.0 - eslint: ^8.56.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/2b37948fa1b0dab77138909dabef242a4d49ab93e4019d4ef930626f0a7d96b03e696cd027fa0087881c20e73be7be77c942606b4a76fa599e6b37f6985304c3 - languageName: node - linkType: hard - "@typescript-eslint/parser@npm:8.53.1": version: 8.53.1 resolution: "@typescript-eslint/parser@npm:8.53.1" @@ -10503,24 +10588,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:^7.1.1": - version: 7.18.0 - resolution: "@typescript-eslint/parser@npm:7.18.0" - dependencies: - "@typescript-eslint/scope-manager": "npm:7.18.0" - "@typescript-eslint/types": "npm:7.18.0" - "@typescript-eslint/typescript-estree": "npm:7.18.0" - "@typescript-eslint/visitor-keys": "npm:7.18.0" - debug: "npm:^4.3.4" - peerDependencies: - eslint: ^8.56.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/370e73fca4278091bc1b657f85e7d74cd52b24257ea20c927a8e17546107ce04fbf313fec99aed0cc2a145ddbae1d3b12e9cc2c1320117636dc1281bcfd08059 - languageName: node - linkType: hard - "@typescript-eslint/project-service@npm:8.53.1": version: 8.53.1 resolution: "@typescript-eslint/project-service@npm:8.53.1" @@ -10534,16 +10601,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.18.0": - version: 7.18.0 - resolution: "@typescript-eslint/scope-manager@npm:7.18.0" - dependencies: - "@typescript-eslint/types": "npm:7.18.0" - "@typescript-eslint/visitor-keys": "npm:7.18.0" - checksum: 10c0/038cd58c2271de146b3a594afe2c99290034033326d57ff1f902976022c8b0138ffd3cb893ae439ae41003b5e4bcc00cabf6b244ce40e8668f9412cc96d97b8e - languageName: node - linkType: hard - "@typescript-eslint/scope-manager@npm:8.53.1": version: 8.53.1 resolution: "@typescript-eslint/scope-manager@npm:8.53.1" @@ -10563,23 +10620,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.18.0": - version: 7.18.0 - resolution: "@typescript-eslint/type-utils@npm:7.18.0" - dependencies: - "@typescript-eslint/typescript-estree": "npm:7.18.0" - "@typescript-eslint/utils": "npm:7.18.0" - debug: "npm:^4.3.4" - ts-api-utils: "npm:^1.3.0" - peerDependencies: - eslint: ^8.56.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/ad92a38007be620f3f7036f10e234abdc2fdc518787b5a7227e55fd12896dacf56e8b34578723fbf9bea8128df2510ba8eb6739439a3879eda9519476d5783fd - languageName: node - linkType: hard - "@typescript-eslint/type-utils@npm:8.53.1": version: 8.53.1 resolution: "@typescript-eslint/type-utils@npm:8.53.1" @@ -10596,13 +10636,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:7.18.0": - version: 7.18.0 - resolution: "@typescript-eslint/types@npm:7.18.0" - checksum: 10c0/eb7371ac55ca77db8e59ba0310b41a74523f17e06f485a0ef819491bc3dd8909bb930120ff7d30aaf54e888167e0005aa1337011f3663dc90fb19203ce478054 - languageName: node - linkType: hard - "@typescript-eslint/types@npm:8.53.1, @typescript-eslint/types@npm:^8.53.1": version: 8.53.1 resolution: "@typescript-eslint/types@npm:8.53.1" @@ -10610,25 +10643,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.18.0": - version: 7.18.0 - resolution: "@typescript-eslint/typescript-estree@npm:7.18.0" - dependencies: - "@typescript-eslint/types": "npm:7.18.0" - "@typescript-eslint/visitor-keys": "npm:7.18.0" - debug: "npm:^4.3.4" - globby: "npm:^11.1.0" - is-glob: "npm:^4.0.3" - minimatch: "npm:^9.0.4" - semver: "npm:^7.6.0" - ts-api-utils: "npm:^1.3.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/0c7f109a2e460ec8a1524339479cf78ff17814d23c83aa5112c77fb345e87b3642616291908dcddea1e671da63686403dfb712e4a4435104f92abdfddf9aba81 - languageName: node - linkType: hard - "@typescript-eslint/typescript-estree@npm:8.53.1": version: 8.53.1 resolution: "@typescript-eslint/typescript-estree@npm:8.53.1" @@ -10648,20 +10662,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.18.0": - version: 7.18.0 - resolution: "@typescript-eslint/utils@npm:7.18.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:7.18.0" - "@typescript-eslint/types": "npm:7.18.0" - "@typescript-eslint/typescript-estree": "npm:7.18.0" - peerDependencies: - eslint: ^8.56.0 - checksum: 10c0/a25a6d50eb45c514469a01ff01f215115a4725fb18401055a847ddf20d1b681409c4027f349033a95c4ff7138d28c3b0a70253dfe8262eb732df4b87c547bd1e - languageName: node - linkType: hard - "@typescript-eslint/utils@npm:8.53.1": version: 8.53.1 resolution: "@typescript-eslint/utils@npm:8.53.1" @@ -10677,16 +10677,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.18.0": - version: 7.18.0 - resolution: "@typescript-eslint/visitor-keys@npm:7.18.0" - dependencies: - "@typescript-eslint/types": "npm:7.18.0" - eslint-visitor-keys: "npm:^3.4.3" - checksum: 10c0/538b645f8ff1d9debf264865c69a317074eaff0255e63d7407046176b0f6a6beba34a6c51d511f12444bae12a98c69891eb6f403c9f54c6c2e2849d1c1cb73c0 - languageName: node - linkType: hard - "@typescript-eslint/visitor-keys@npm:8.53.1": version: 8.53.1 resolution: "@typescript-eslint/visitor-keys@npm:8.53.1" @@ -11295,17 +11285,6 @@ __metadata: languageName: node linkType: hard -"@wdio/config@npm:6.12.1": - version: 6.12.1 - resolution: "@wdio/config@npm:6.12.1" - dependencies: - "@wdio/logger": "npm:6.10.10" - deepmerge: "npm:^4.0.0" - glob: "npm:^7.1.2" - checksum: 10c0/83b6a5f0ee76bcf0793cf8aa1c8609dafe34abac0ee71b2e29419c41a47afaaefa6dbfbc1dcca0398ccbe8c7ea1b7a67eaaf793be9cc2139f403d960e4fca189 - languageName: node - linkType: hard - "@wdio/config@npm:9.24.0": version: 9.24.0 resolution: "@wdio/config@npm:9.24.0" @@ -11412,25 +11391,6 @@ __metadata: languageName: node linkType: hard -"@wdio/logger@npm:6.10.10": - version: 6.10.10 - resolution: "@wdio/logger@npm:6.10.10" - dependencies: - chalk: "npm:^4.0.0" - loglevel: "npm:^1.6.0" - loglevel-plugin-prefix: "npm:^0.8.4" - strip-ansi: "npm:^6.0.0" - checksum: 10c0/598abaa5517eb73ebcf5131092bc91b7cbe6acd69e5629359b89e379ae63a58c22fd04501d1ecd50efa803afbb3e067f22a53ea2d332cfca6d9965d49a30090b - languageName: node - linkType: hard - -"@wdio/protocols@npm:6.12.0": - version: 6.12.0 - resolution: "@wdio/protocols@npm:6.12.0" - checksum: 10c0/7c503956ed494cf13548108f297cc38b8d3128b1c12b96f4a51b224c80a281646293786b4d004c3a8f38211f2d2c9a69ab4f1c9a3da826df244d026893845594 - languageName: node - linkType: hard - "@wdio/protocols@npm:9.24.0": version: 9.24.0 resolution: "@wdio/protocols@npm:9.24.0" @@ -11438,15 +11398,6 @@ __metadata: languageName: node linkType: hard -"@wdio/repl@npm:6.11.0": - version: 6.11.0 - resolution: "@wdio/repl@npm:6.11.0" - dependencies: - "@wdio/utils": "npm:6.11.0" - checksum: 10c0/2c4d8a2a85fa0f2819bcc6dc25ec0b1aed21fcf333fcc2f582d82bd2e62351d459803181b895263d5195a1907e8394ad4843f7085160fd44272881b20d0fb2cf - languageName: node - linkType: hard - "@wdio/repl@npm:9.16.2": version: 9.16.2 resolution: "@wdio/repl@npm:9.16.2" @@ -11539,15 +11490,6 @@ __metadata: languageName: node linkType: hard -"@wdio/utils@npm:6.11.0": - version: 6.11.0 - resolution: "@wdio/utils@npm:6.11.0" - dependencies: - "@wdio/logger": "npm:6.10.10" - checksum: 10c0/8f41913f9b1fcc94ac881f5bf50bac5e222be5954dbce91aadb0e376914ca47bb1157ff7a7cc4d133eb653c05695bde7201aeacf4d9e52308151112b622a6597 - languageName: node - linkType: hard - "@wdio/utils@npm:9.24.0": version: 9.24.0 resolution: "@wdio/utils@npm:9.24.0" @@ -11675,13 +11617,6 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:5": - version: 5.1.1 - resolution: "agent-base@npm:5.1.1" - checksum: 10c0/3baa3f01072c16e3955ce7802166e576cde9831af82b262aae1c780af49c0c84e82e64ba9ef9e7d1704fe29e9f0096a78a4f998ec137360fee3cb95186f97161 - languageName: node - linkType: hard - "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -12210,42 +12145,6 @@ __metadata: languageName: node linkType: hard -"archiver-utils@npm:^2.1.0": - version: 2.1.0 - resolution: "archiver-utils@npm:2.1.0" - dependencies: - glob: "npm:^7.1.4" - graceful-fs: "npm:^4.2.0" - lazystream: "npm:^1.0.0" - lodash.defaults: "npm:^4.2.0" - lodash.difference: "npm:^4.5.0" - lodash.flatten: "npm:^4.4.0" - lodash.isplainobject: "npm:^4.0.6" - lodash.union: "npm:^4.6.0" - normalize-path: "npm:^3.0.0" - readable-stream: "npm:^2.0.0" - checksum: 10c0/6ea5b02e440f3099aff58b18dd384f84ecfe18632e81d26c1011fe7dfdb80ade43d7a06cbf048ef0e9ee0f2c87a80cb24c0f0ac5e3a2c4d67641d6f0d6e36ece - languageName: node - linkType: hard - -"archiver-utils@npm:^3.0.4": - version: 3.0.4 - resolution: "archiver-utils@npm:3.0.4" - dependencies: - glob: "npm:^7.2.3" - graceful-fs: "npm:^4.2.0" - lazystream: "npm:^1.0.0" - lodash.defaults: "npm:^4.2.0" - lodash.difference: "npm:^4.5.0" - lodash.flatten: "npm:^4.4.0" - lodash.isplainobject: "npm:^4.0.6" - lodash.union: "npm:^4.6.0" - normalize-path: "npm:^3.0.0" - readable-stream: "npm:^3.6.0" - checksum: 10c0/9bb7e271e95ff33bdbdcd6f69f8860e0aeed3fcba352a74f51a626d1c32b404f20e3185d5214f171b24a692471d01702f43874d1a4f0d2e5f57bd0834bc54c14 - languageName: node - linkType: hard - "archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": version: 5.0.2 resolution: "archiver-utils@npm:5.0.2" @@ -12278,21 +12177,6 @@ __metadata: languageName: node linkType: hard -"archiver@npm:^5.0.0": - version: 5.3.2 - resolution: "archiver@npm:5.3.2" - dependencies: - archiver-utils: "npm:^2.1.0" - async: "npm:^3.2.4" - buffer-crc32: "npm:^0.2.1" - readable-stream: "npm:^3.6.0" - readdir-glob: "npm:^1.1.2" - tar-stream: "npm:^2.2.0" - zip-stream: "npm:^4.1.0" - checksum: 10c0/973384d749b3fa96f44ceda1603a65aaa3f24a267230d69a4df9d7b607d38d3ebc6c18c358af76eb06345b6b331ccb9eca07bd079430226b5afce95de22dfade - languageName: node - linkType: hard - "archiver@npm:^7.0.1": version: 7.0.1 resolution: "archiver@npm:7.0.1" @@ -12628,22 +12512,6 @@ __metadata: languageName: node linkType: hard -"at-least-node@npm:^1.0.0": - version: 1.0.0 - resolution: "at-least-node@npm:1.0.0" - checksum: 10c0/4c058baf6df1bc5a1697cf182e2029c58cd99975288a13f9e70068ef5d6f4e1f1fd7c4d2c3c4912eae44797d1725be9700995736deca441b39f3e66d8dee97ef - languageName: node - linkType: hard - -"atob@npm:^2.1.2": - version: 2.1.2 - resolution: "atob@npm:2.1.2" - bin: - atob: bin/atob.js - checksum: 10c0/ada635b519dc0c576bb0b3ca63a73b50eefacf390abb3f062558342a8d68f2db91d0c8db54ce81b0d89de3b0f000de71f3ae7d761fd7d8cc624278fe443d6c7e - languageName: node - linkType: hard - "available-typed-arrays@npm:^1.0.7": version: 1.0.7 resolution: "available-typed-arrays@npm:1.0.7" @@ -12945,7 +12813,7 @@ __metadata: languageName: node linkType: hard -"bl@npm:^4.0.3, bl@npm:^4.1.0": +"bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" dependencies: @@ -13085,13 +12953,6 @@ __metadata: languageName: node linkType: hard -"buffer-crc32@npm:^0.2.1, buffer-crc32@npm:^0.2.13": - version: 0.2.13 - resolution: "buffer-crc32@npm:0.2.13" - checksum: 10c0/cb0a8ddf5cf4f766466db63279e47761eb825693eeba6a5a95ee4ec8cb8f81ede70aa7f9d8aeec083e781d47154290eb5d4d26b3f7a465ec57fb9e7d59c47150 - languageName: node - linkType: hard - "buffer-crc32@npm:^1.0.0": version: 1.0.0 resolution: "buffer-crc32@npm:1.0.0" @@ -13116,7 +12977,7 @@ __metadata: languageName: node linkType: hard -"buffer@npm:^5.2.1, buffer@npm:^5.4.3, buffer@npm:^5.5.0": +"buffer@npm:^5.4.3, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: @@ -13161,28 +13022,6 @@ __metadata: languageName: node linkType: hard -"cacheable-lookup@npm:^5.0.3": - version: 5.0.4 - resolution: "cacheable-lookup@npm:5.0.4" - checksum: 10c0/a6547fb4954b318aa831cbdd2f7b376824bc784fb1fa67610e4147099e3074726072d9af89f12efb69121415a0e1f2918a8ddd4aafcbcf4e91fbeef4a59cd42c - languageName: node - linkType: hard - -"cacheable-request@npm:^7.0.2": - version: 7.0.4 - resolution: "cacheable-request@npm:7.0.4" - dependencies: - clone-response: "npm:^1.0.2" - get-stream: "npm:^5.1.0" - http-cache-semantics: "npm:^4.0.0" - keyv: "npm:^4.0.0" - lowercase-keys: "npm:^2.0.0" - normalize-url: "npm:^6.0.1" - responselike: "npm:^2.0.0" - checksum: 10c0/0834a7d17ae71a177bc34eab06de112a43f9b5ad05ebe929bec983d890a7d9f2bc5f1aa8bb67ea2b65e07a3bc74bea35fa62dd36dbac52876afe36fdcf83da41 - languageName: node - linkType: hard - "call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": version: 1.0.2 resolution: "call-bind-apply-helpers@npm:1.0.2" @@ -13361,13 +13200,6 @@ __metadata: languageName: node linkType: hard -"chownr@npm:^1.1.1": - version: 1.1.4 - resolution: "chownr@npm:1.1.4" - checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db - languageName: node - linkType: hard - "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -13375,20 +13207,6 @@ __metadata: languageName: node linkType: hard -"chrome-launcher@npm:^0.13.1": - version: 0.13.4 - resolution: "chrome-launcher@npm:0.13.4" - dependencies: - "@types/node": "npm:*" - escape-string-regexp: "npm:^1.0.5" - is-wsl: "npm:^2.2.0" - lighthouse-logger: "npm:^1.0.0" - mkdirp: "npm:^0.5.3" - rimraf: "npm:^3.0.2" - checksum: 10c0/f869fbbf1d04983ebbc0489d17c1ac38d08f70b6d0665bf9287d85362fc885394dfb3db4de6304e9ce4a64f6b829d8b6f55e0b13c58c80be72bda8043af32a87 - languageName: node - linkType: hard - "chrome-launcher@npm:^0.15.2": version: 0.15.2 resolution: "chrome-launcher@npm:0.15.2" @@ -13543,15 +13361,6 @@ __metadata: languageName: node linkType: hard -"clone-response@npm:^1.0.2": - version: 1.0.3 - resolution: "clone-response@npm:1.0.3" - dependencies: - mimic-response: "npm:^1.0.0" - checksum: 10c0/06a2b611824efb128810708baee3bd169ec9a1bf5976a5258cd7eb3f7db25f00166c6eee5961f075c7e38e194f373d4fdf86b8166ad5b9c7e82bbd2e333a6087 - languageName: node - linkType: hard - "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -13743,18 +13552,6 @@ __metadata: languageName: node linkType: hard -"compress-commons@npm:^4.1.2": - version: 4.1.2 - resolution: "compress-commons@npm:4.1.2" - dependencies: - buffer-crc32: "npm:^0.2.13" - crc32-stream: "npm:^4.0.2" - normalize-path: "npm:^3.0.0" - readable-stream: "npm:^3.6.0" - checksum: 10c0/e5fa03cb374ed89028e20226c70481e87286240392d5c6856f4e7fef40605c1892748648e20ed56597d390d76513b1b9bb4dbd658a1bbff41c9fa60107c74d3f - languageName: node - linkType: hard - "compress-commons@npm:^6.0.2": version: 6.0.2 resolution: "compress-commons@npm:6.0.2" @@ -13945,16 +13742,6 @@ __metadata: languageName: node linkType: hard -"crc32-stream@npm:^4.0.2": - version: 4.0.3 - resolution: "crc32-stream@npm:4.0.3" - dependencies: - crc-32: "npm:^1.2.0" - readable-stream: "npm:^3.4.0" - checksum: 10c0/127b0c66a947c54db37054fca86085722140644d3a75ebc61d4477bad19304d2936386b0461e8ee9e1c24b00e804cd7c2e205180e5bcb4632d20eccd60533bc4 - languageName: node - linkType: hard - "crc32-stream@npm:^6.0.0": version: 6.0.0 resolution: "crc32-stream@npm:6.0.0" @@ -14260,15 +14047,6 @@ __metadata: languageName: node linkType: hard -"decompress-response@npm:^6.0.0": - version: 6.0.0 - resolution: "decompress-response@npm:6.0.0" - dependencies: - mimic-response: "npm:^3.1.0" - checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e - languageName: node - linkType: hard - "dedent@npm:^1.0.0, dedent@npm:^1.7.2": version: 1.7.2 resolution: "dedent@npm:1.7.2" @@ -14302,7 +14080,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.0.0, deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.0, deepmerge@npm:^4.3.1": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.0, deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 @@ -14335,13 +14113,6 @@ __metadata: languageName: node linkType: hard -"defer-to-connect@npm:^2.0.0": - version: 2.0.1 - resolution: "defer-to-connect@npm:2.0.1" - checksum: 10c0/625ce28e1b5ad10cf77057b9a6a727bf84780c17660f6644dab61dd34c23de3001f03cedc401f7d30a4ed9965c2e8a7336e220a329146f2cf85d4eddea429782 - languageName: node - linkType: hard - "define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.4": version: 1.1.4 resolution: "define-data-property@npm:1.1.4" @@ -14485,30 +14256,6 @@ __metadata: languageName: node linkType: hard -"devtools-protocol@npm:0.0.818844": - version: 0.0.818844 - resolution: "devtools-protocol@npm:0.0.818844" - checksum: 10c0/5426f922699cb456b61ba8a951b753d2e250b1f7b3aed41bcddad40df5b7dda1dcdcf6d0d776314da53ec8fdeb961b31edadf20e3a5cb1bac50b5b9ec8b6cf51 - languageName: node - linkType: hard - -"devtools@npm:6.12.1": - version: 6.12.1 - resolution: "devtools@npm:6.12.1" - dependencies: - "@wdio/config": "npm:6.12.1" - "@wdio/logger": "npm:6.10.10" - "@wdio/protocols": "npm:6.12.0" - "@wdio/utils": "npm:6.11.0" - chrome-launcher: "npm:^0.13.1" - edge-paths: "npm:^2.1.0" - puppeteer-core: "npm:^5.1.0" - ua-parser-js: "npm:^0.7.21" - uuid: "npm:^8.0.0" - checksum: 10c0/ca03c3c29d7d58f377b2c4440f7aa0cbca2bfc88ae7f4951c5b3dfca337b7f2c5aa911f357c58c10bd3aa4bb4292c636f1fa5795c6696310685e23f293ac1ff2 - languageName: node - linkType: hard - "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -14694,16 +14441,6 @@ __metadata: languageName: node linkType: hard -"edge-paths@npm:^2.1.0": - version: 2.2.1 - resolution: "edge-paths@npm:2.2.1" - dependencies: - "@types/which": "npm:^1.3.2" - which: "npm:^2.0.2" - checksum: 10c0/57c96067a9c1349b4dce25146386cbbe76718a7abbfa19a93c6a55f2365d74a0dd20d3c3162dcec33e6fc57131ec54708d80cd259d69a2ab9cd738589663e0e5 - languageName: node - linkType: hard - "edge-paths@npm:^3.0.5": version: 3.0.5 resolution: "edge-paths@npm:3.0.5" @@ -14823,7 +14560,7 @@ __metadata: languageName: node linkType: hard -"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": +"end-of-stream@npm:^1.1.0": version: 1.4.5 resolution: "end-of-stream@npm:1.4.5" dependencies: @@ -15083,6 +14820,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:0.28.1": + version: 0.28.1 + resolution: "esbuild@npm:0.28.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.1" + "@esbuild/android-arm": "npm:0.28.1" + "@esbuild/android-arm64": "npm:0.28.1" + "@esbuild/android-x64": "npm:0.28.1" + "@esbuild/darwin-arm64": "npm:0.28.1" + "@esbuild/darwin-x64": "npm:0.28.1" + "@esbuild/freebsd-arm64": "npm:0.28.1" + "@esbuild/freebsd-x64": "npm:0.28.1" + "@esbuild/linux-arm": "npm:0.28.1" + "@esbuild/linux-arm64": "npm:0.28.1" + "@esbuild/linux-ia32": "npm:0.28.1" + "@esbuild/linux-loong64": "npm:0.28.1" + "@esbuild/linux-mips64el": "npm:0.28.1" + "@esbuild/linux-ppc64": "npm:0.28.1" + "@esbuild/linux-riscv64": "npm:0.28.1" + "@esbuild/linux-s390x": "npm:0.28.1" + "@esbuild/linux-x64": "npm:0.28.1" + "@esbuild/netbsd-arm64": "npm:0.28.1" + "@esbuild/netbsd-x64": "npm:0.28.1" + "@esbuild/openbsd-arm64": "npm:0.28.1" + "@esbuild/openbsd-x64": "npm:0.28.1" + "@esbuild/openharmony-arm64": "npm:0.28.1" + "@esbuild/sunos-x64": "npm:0.28.1" + "@esbuild/win32-arm64": "npm:0.28.1" + "@esbuild/win32-ia32": "npm:0.28.1" + "@esbuild/win32-x64": "npm:0.28.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/29cd456a79ce35ac2c7e05fe871330416b2c395c045d849653f843e51378d6e0d6e774d6dcd01b35f4e83238a29bf8decd04fcd34b3780c589a250b21e5f92bb + languageName: node + linkType: hard + "esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0, esbuild@npm:^0.28.0, esbuild@npm:^0.28.1": version: 0.28.2 resolution: "esbuild@npm:0.28.2" @@ -15993,13 +15819,6 @@ __metadata: languageName: node linkType: hard -"fs-constants@npm:^1.0.0": - version: 1.0.0 - resolution: "fs-constants@npm:1.0.0" - checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8 - languageName: node - linkType: hard - "fs-extra@npm:^11.2.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" @@ -16033,18 +15852,6 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^9.0.1": - version: 9.1.0 - resolution: "fs-extra@npm:9.1.0" - dependencies: - at-least-node: "npm:^1.0.0" - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^6.0.1" - universalify: "npm:^2.0.0" - checksum: 10c0/9b808bd884beff5cb940773018179a6b94a966381d005479f00adda6b44e5e3d4abf765135773d849cc27efe68c349e4a7b86acd7d3306d5932c14f3a4b17a92 - languageName: node - linkType: hard - "fs-minipass@npm:^3.0.0": version: 3.0.2 resolution: "fs-minipass@npm:3.0.2" @@ -16179,13 +15986,6 @@ __metadata: languageName: node linkType: hard -"get-port@npm:^5.1.1": - version: 5.1.1 - resolution: "get-port@npm:5.1.1" - checksum: 10c0/2873877a469b24e6d5e0be490724a17edb39fafc795d1d662e7bea951ca649713b4a50117a473f9d162312cb0e946597bd0e049ed2f866e79e576e8e213d3d1c - languageName: node - linkType: hard - "get-port@npm:^7.0.0": version: 7.1.0 resolution: "get-port@npm:7.1.0" @@ -16212,7 +16012,7 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^5.0.0, get-stream@npm:^5.1.0": +"get-stream@npm:^5.0.0": version: 5.2.0 resolution: "get-stream@npm:5.2.0" dependencies: @@ -16384,7 +16184,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.0.0, glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.2.3": +"glob@npm:^7.0.0, glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -16474,25 +16274,6 @@ __metadata: languageName: node linkType: hard -"got@npm:^11.0.2": - version: 11.8.6 - resolution: "got@npm:11.8.6" - dependencies: - "@sindresorhus/is": "npm:^4.0.0" - "@szmarczak/http-timer": "npm:^4.0.5" - "@types/cacheable-request": "npm:^6.0.1" - "@types/responselike": "npm:^1.0.0" - cacheable-lookup: "npm:^5.0.3" - cacheable-request: "npm:^7.0.2" - decompress-response: "npm:^6.0.0" - http2-wrapper: "npm:^1.0.0-beta.5.2" - lowercase-keys: "npm:^2.0.0" - p-cancelable: "npm:^2.0.0" - responselike: "npm:^2.0.0" - checksum: 10c0/754dd44877e5cf6183f1e989ff01c648d9a4719e357457bd4c78943911168881f1cfb7b2cb15d885e2105b3ad313adb8f017a67265dd7ade771afdb261ee8cb1 - languageName: node - linkType: hard - "graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.8, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -16500,20 +16281,13 @@ __metadata: languageName: node linkType: hard -"grapheme-splitter@npm:^1.0.2, grapheme-splitter@npm:^1.0.4": +"grapheme-splitter@npm:^1.0.4": version: 1.0.4 resolution: "grapheme-splitter@npm:1.0.4" checksum: 10c0/108415fb07ac913f17040dc336607772fcea68c7f495ef91887edddb0b0f5ff7bc1d1ab181b125ecb2f0505669ef12c9a178a3bbd2dd8e042d8c5f1d7c90331a languageName: node linkType: hard -"graphemer@npm:^1.4.0": - version: 1.4.0 - resolution: "graphemer@npm:1.4.0" - checksum: 10c0/e951259d8cd2e0d196c72ec711add7115d42eb9a8146c8eeda5b8d3ac91e5dd816b9cd68920726d9fd4490368e7ed86e9c423f40db87e2d8dfafa00fa17c3a31 - languageName: node - linkType: hard - "handle-thing@npm:^2.0.0": version: 2.0.1 resolution: "handle-thing@npm:2.0.1" @@ -16716,7 +16490,7 @@ __metadata: languageName: node linkType: hard -"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1": +"http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" checksum: 10c0/45b66a945cf13ec2d1f29432277201313babf4a01d9e52f44b31ca923434083afeca03f18417f599c9ab3d0e7b618ceb21257542338b57c54b710463b4a53e37 @@ -16760,26 +16534,6 @@ __metadata: languageName: node linkType: hard -"http2-wrapper@npm:^1.0.0-beta.5.2": - version: 1.0.3 - resolution: "http2-wrapper@npm:1.0.3" - dependencies: - quick-lru: "npm:^5.1.1" - resolve-alpn: "npm:^1.0.0" - checksum: 10c0/6a9b72a033e9812e1476b9d776ce2f387bc94bc46c88aea0d5dab6bd47d0a539b8178830e77054dd26d1142c866d515a28a4dc7c3ff4232c88ff2ebe4f5d12d1 - languageName: node - linkType: hard - -"https-proxy-agent@npm:^4.0.0": - version: 4.0.0 - resolution: "https-proxy-agent@npm:4.0.0" - dependencies: - agent-base: "npm:5" - debug: "npm:4" - checksum: 10c0/fbba3e037ec04e1850e867064a763b86dd884baae9c5f4ad380504e321068c9e9b5de79cf2f3a28ede7c36036dce905b58d9f51703c5b3884d887114f4887f77 - languageName: node - linkType: hard - "https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" @@ -16878,7 +16632,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.2.0, ignore@npm:^5.2.4, ignore@npm:^5.3.1": +"ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.3.2 resolution: "ignore@npm:5.3.2" checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 @@ -18189,7 +17943,7 @@ __metadata: languageName: node linkType: hard -"jest@npm:^29.2.1, jest@npm:^29.7.0": +"jest@npm:^29.2.1": version: 29.7.0 resolution: "jest@npm:29.7.0" dependencies: @@ -18379,13 +18133,6 @@ __metadata: languageName: node linkType: hard -"json-buffer@npm:3.0.1": - version: 3.0.1 - resolution: "json-buffer@npm:3.0.1" - checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 - languageName: node - linkType: hard - "json-parse-better-errors@npm:^1.0.1": version: 1.0.2 resolution: "json-parse-better-errors@npm:1.0.2" @@ -18476,13 +18223,6 @@ __metadata: languageName: node linkType: hard -"jsonrpc-lite@npm:^2.2.0": - version: 2.2.0 - resolution: "jsonrpc-lite@npm:2.2.0" - checksum: 10c0/6f2a87d23218204e57f16ed61f4a3842212f751cb2a089360774c4bdeb77fa79b39b8da20510ff5f44406ae170617648f2c4b84f8beb47491e86c3aac4c547a4 - languageName: node - linkType: hard - "jsx-ast-utils@npm:^2.4.1 || ^3.0.0": version: 3.3.3 resolution: "jsx-ast-utils@npm:3.3.3" @@ -18505,15 +18245,6 @@ __metadata: languageName: node linkType: hard -"keyv@npm:^4.0.0": - version: 4.5.4 - resolution: "keyv@npm:4.5.4" - dependencies: - json-buffer: "npm:3.0.1" - checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e - languageName: node - linkType: hard - "kind-of@npm:^6.0.2": version: 6.0.3 resolution: "kind-of@npm:6.0.3" @@ -18775,27 +18506,6 @@ __metadata: languageName: node linkType: hard -"lodash.defaults@npm:^4.2.0": - version: 4.2.0 - resolution: "lodash.defaults@npm:4.2.0" - checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707 - languageName: node - linkType: hard - -"lodash.difference@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.difference@npm:4.5.0" - checksum: 10c0/5d52859218a7df427547ff1fadbc397879709fe6c788b037df7d6d92b676122c92bd35ec85d364edb596b65dfc6573132f420c9b4ee22bb6b9600cd454c90637 - languageName: node - linkType: hard - -"lodash.flatten@npm:^4.4.0": - version: 4.4.0 - resolution: "lodash.flatten@npm:4.4.0" - checksum: 10c0/97e8f0d6b61fe4723c02ad0c6e67e51784c4a2c48f56ef283483e556ad01594cf9cec9c773e177bbbdbdb5d19e99b09d2487cb6b6e5dc405c2693e93b125bd3a - languageName: node - linkType: hard - "lodash.flattendeep@npm:^4.4.0": version: 4.4.0 resolution: "lodash.flattendeep@npm:4.4.0" @@ -18810,21 +18520,7 @@ __metadata: languageName: node linkType: hard -"lodash.isobject@npm:^3.0.2": - version: 3.0.2 - resolution: "lodash.isobject@npm:3.0.2" - checksum: 10c0/da4c8480d98b16835b59380b2fbd43c54081acd9466febb788ba77c434384349e0bec162d1c4e89f613f21687b2b6d8384d8a112b80da00c78d28d9915a5cdde - languageName: node - linkType: hard - -"lodash.isplainobject@npm:^4.0.6": - version: 4.0.6 - resolution: "lodash.isplainobject@npm:4.0.6" - checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb - languageName: node - linkType: hard - -"lodash.merge@npm:^4.6.1, lodash.merge@npm:^4.6.2": +"lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 @@ -18967,13 +18663,6 @@ __metadata: languageName: node linkType: hard -"lowercase-keys@npm:^2.0.0": - version: 2.0.0 - resolution: "lowercase-keys@npm:2.0.0" - checksum: 10c0/f82a2b3568910509da4b7906362efa40f5b54ea14c2584778ddb313226f9cbf21020a5db35f9b9a0e95847a9b781d548601f31793d736b22a2b8ae8eb9ab1082 - languageName: node - linkType: hard - "lru-cache@npm:11.5.2, lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": version: 11.5.2 resolution: "lru-cache@npm:11.5.2" @@ -19561,20 +19250,6 @@ __metadata: languageName: node linkType: hard -"mimic-response@npm:^1.0.0": - version: 1.0.1 - resolution: "mimic-response@npm:1.0.1" - checksum: 10c0/c5381a5eae997f1c3b5e90ca7f209ed58c3615caeee850e85329c598f0c000ae7bec40196580eef1781c60c709f47258131dab237cad8786f8f56750594f27fa - languageName: node - linkType: hard - -"mimic-response@npm:^3.1.0": - version: 3.1.0 - resolution: "mimic-response@npm:3.1.0" - checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362 - languageName: node - linkType: hard - "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -19589,7 +19264,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.0.3, minimatch@npm:^10.1.1, minimatch@npm:^10.2.2": +"minimatch@npm:^10.1.1, minimatch@npm:^10.2.2": version: 10.2.6 resolution: "minimatch@npm:10.2.6" dependencies: @@ -19731,14 +19406,7 @@ __metadata: languageName: node linkType: hard -"mkdirp-classic@npm:^0.5.2": - version: 0.5.3 - resolution: "mkdirp-classic@npm:0.5.3" - checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168 - languageName: node - linkType: hard - -"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3": +"mkdirp@npm:^0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -19970,7 +19638,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.5.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7": +"node-fetch@npm:^2.5.0, node-fetch@npm:^2.6.7": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -20094,13 +19762,6 @@ __metadata: languageName: node linkType: hard -"normalize-url@npm:^6.0.1": - version: 6.1.0 - resolution: "normalize-url@npm:6.1.0" - checksum: 10c0/95d948f9bdd2cfde91aa786d1816ae40f8262946e13700bf6628105994fe0ff361662c20af3961161c38a119dc977adeb41fc0b41b1745eb77edaaf9cb22db23 - languageName: node - linkType: hard - "npm-normalize-package-bin@npm:^4.0.0": version: 4.0.0 resolution: "npm-normalize-package-bin@npm:4.0.0" @@ -20829,13 +20490,6 @@ __metadata: languageName: node linkType: hard -"p-cancelable@npm:^2.0.0": - version: 2.1.1 - resolution: "p-cancelable@npm:2.1.1" - checksum: 10c0/8c6dc1f8dd4154fd8b96a10e55a3a832684c4365fb9108056d89e79fbf21a2465027c04a59d0d797b5ffe10b54a61a32043af287d5c4860f1e996cbdbc847f01 - languageName: node - linkType: hard - "p-defer@npm:^1.0.0": version: 1.0.0 resolution: "p-defer@npm:1.0.0" @@ -21521,7 +21175,7 @@ __metadata: languageName: node linkType: hard -"progress@npm:^2.0.1, progress@npm:^2.0.3": +"progress@npm:^2.0.3": version: 2.0.3 resolution: "progress@npm:2.0.3" checksum: 10c0/1697e07cb1068055dbe9fe858d242368ff5d2073639e652b75a7eb1f2a1a8d4afd404d719de23c7b48481a6aa0040686310e2dac2f53d776daa2176d3f96369c @@ -21591,7 +21245,7 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^1.0.0, proxy-from-env@npm:^1.1.0": +"proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0" checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b @@ -21622,26 +21276,6 @@ __metadata: languageName: node linkType: hard -"puppeteer-core@npm:^5.1.0": - version: 5.5.0 - resolution: "puppeteer-core@npm:5.5.0" - dependencies: - debug: "npm:^4.1.0" - devtools-protocol: "npm:0.0.818844" - extract-zip: "npm:^2.0.0" - https-proxy-agent: "npm:^4.0.0" - node-fetch: "npm:^2.6.1" - pkg-dir: "npm:^4.2.0" - progress: "npm:^2.0.1" - proxy-from-env: "npm:^1.0.0" - rimraf: "npm:^3.0.2" - tar-fs: "npm:^2.0.0" - unbzip2-stream: "npm:^1.3.3" - ws: "npm:^7.2.3" - checksum: 10c0/08a161a90779deed5e859a09195603606c3cc4c08cf68e4d8e5f4c610638f9250bd97a372ce469a0691e5cd06038546d60ed83ec6b0dead52bd65e6383a21692 - languageName: node - linkType: hard - "pure-rand@npm:^6.0.0": version: 6.1.0 resolution: "pure-rand@npm:6.1.0" @@ -21694,13 +21328,6 @@ __metadata: languageName: node linkType: hard -"quick-lru@npm:^5.1.1": - version: 5.1.1 - resolution: "quick-lru@npm:5.1.1" - checksum: 10c0/a24cba5da8cec30d70d2484be37622580f64765fb6390a928b17f60cd69e8dbd32a954b3ff9176fa1b86d86ff2ba05252fae55dc4d40d0291c60412b0ad096da - languageName: node - linkType: hard - "range-parser@npm:^1.2.1, range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -22166,7 +21793,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -22181,7 +21808,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:^3.0.6, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -22239,13 +21866,6 @@ __metadata: languageName: node linkType: hard -"readline-sync@npm:1.4.10": - version: 1.4.10 - resolution: "readline-sync@npm:1.4.10" - checksum: 10c0/0a4d0fe4ad501f8f005a3c9cbf3cc0ae6ca2ced93e9a1c7c46f226bdfcb6ef5d3f437ae7e9d2e1098ee13524a3739c830e4c8dbc7f543a693eecd293e41093a3 - languageName: node - linkType: hard - "recast@npm:^0.20.3": version: 0.20.5 resolution: "recast@npm:0.20.5" @@ -22431,13 +22051,6 @@ __metadata: languageName: node linkType: hard -"resolve-alpn@npm:^1.0.0": - version: 1.2.1 - resolution: "resolve-alpn@npm:1.2.1" - checksum: 10c0/b70b29c1843bc39781ef946c8cd4482e6d425976599c0f9c138cec8209e4e0736161bf39319b01676a847000085dfdaf63583c6fb4427bf751a10635bd2aa0c4 - languageName: node - linkType: hard - "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -22512,16 +22125,7 @@ __metadata: languageName: node linkType: hard -"responselike@npm:^2.0.0": - version: 2.0.1 - resolution: "responselike@npm:2.0.1" - dependencies: - lowercase-keys: "npm:^2.0.0" - checksum: 10c0/360b6deb5f101a9f8a4174f7837c523c3ec78b7ca8a7c1d45a1062b303659308a23757e318b1e91ed8684ad1205721142dd664d94771cd63499353fd4ee732b5 - languageName: node - linkType: hard - -"resq@npm:^1.11.0, resq@npm:^1.9.1": +"resq@npm:^1.11.0": version: 1.11.0 resolution: "resq@npm:1.11.0" dependencies: @@ -22578,13 +22182,6 @@ __metadata: languageName: node linkType: hard -"rgb2hex@npm:0.2.3": - version: 0.2.3 - resolution: "rgb2hex@npm:0.2.3" - checksum: 10c0/5f521812e770c68a7a5904b0765725354c41a87819654a3af2a3543c94991e887818b29eb6d71b7bce08748b7a4249a6d2c88d96cbbdbacfcaa555e04eb4ea34 - languageName: node - linkType: hard - "rgb2hex@npm:0.2.5": version: 0.2.5 resolution: "rgb2hex@npm:0.2.5" @@ -22795,7 +22392,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.8.5, semver@npm:^7.0.0, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.5": +"semver@npm:7.8.5, semver@npm:^7.0.0, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.5": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -22878,15 +22475,6 @@ __metadata: languageName: node linkType: hard -"serialize-error@npm:^8.0.0": - version: 8.1.0 - resolution: "serialize-error@npm:8.1.0" - dependencies: - type-fest: "npm:^0.20.2" - checksum: 10c0/8cfd89f43ca93e283c5f1d16178a536bdfac9bc6029f4a9df988610cc399bc4f2478d1f10ce40b9dff66b863a5158a19b438fbec929045c96d92174f6bca1e88 - languageName: node - linkType: hard - "serve-favicon@npm:2.5.1": version: 2.5.1 resolution: "serve-favicon@npm:2.5.1" @@ -23893,18 +23481,6 @@ __metadata: languageName: node linkType: hard -"tar-fs@npm:^2.0.0": - version: 2.1.5 - resolution: "tar-fs@npm:2.1.5" - dependencies: - chownr: "npm:^1.1.1" - mkdirp-classic: "npm:^0.5.2" - pump: "npm:^3.0.0" - tar-stream: "npm:^2.1.4" - checksum: 10c0/b987429214c3ab3be1f439b4e097dc310acf449c35f355956d801f59ef4cfde3b9827797ab2d2f087ed024e459eedc9b3cd860c9190b86e356ba348ade928684 - languageName: node - linkType: hard - "tar-fs@npm:^3.0.8": version: 3.1.1 resolution: "tar-fs@npm:3.1.1" @@ -23922,19 +23498,6 @@ __metadata: languageName: node linkType: hard -"tar-stream@npm:^2.1.4, tar-stream@npm:^2.2.0": - version: 2.2.0 - resolution: "tar-stream@npm:2.2.0" - dependencies: - bl: "npm:^4.0.3" - end-of-stream: "npm:^1.4.1" - fs-constants: "npm:^1.0.0" - inherits: "npm:^2.0.3" - readable-stream: "npm:^3.1.1" - checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692 - languageName: node - linkType: hard - "tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5": version: 3.1.7 resolution: "tar-stream@npm:3.1.7" @@ -24044,7 +23607,7 @@ __metadata: languageName: node linkType: hard -"through@npm:^2.3.8, through@npm:~2.3.4": +"through@npm:~2.3.4": version: 2.3.8 resolution: "through@npm:2.3.8" checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc @@ -24110,7 +23673,7 @@ __metadata: languageName: node linkType: hard -"tmcp@npm:^1.19.3, tmcp@npm:^1.19.4": +"tmcp@npm:^1.19.4": version: 1.19.4 resolution: "tmcp@npm:1.19.4" dependencies: @@ -24185,15 +23748,6 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^1.3.0": - version: 1.4.3 - resolution: "ts-api-utils@npm:1.4.3" - peerDependencies: - typescript: ">=4.2.0" - checksum: 10c0/e65dc6e7e8141140c23e1dc94984bf995d4f6801919c71d6dc27cf0cd51b100a91ffcfe5217626193e5bea9d46831e8586febdc7e172df3f1091a7384299e23a - languageName: node - linkType: hard - "ts-api-utils@npm:^2.4.0": version: 2.4.0 resolution: "ts-api-utils@npm:2.4.0" @@ -24301,13 +23855,6 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^0.20.2": - version: 0.20.2 - resolution: "type-fest@npm:0.20.2" - checksum: 10c0/dea9df45ea1f0aaa4e2d3bed3f9a0bfe9e5b2592bddb92eb1bf06e50bcf98dbb78189668cd8bc31a0511d3fc25539b4cd5c704497e53e93e2d40ca764b10bfc3 - languageName: node - linkType: hard - "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -24520,15 +24067,6 @@ __metadata: languageName: node linkType: hard -"ua-parser-js@npm:^0.7.21": - version: 0.7.41 - resolution: "ua-parser-js@npm:0.7.41" - bin: - ua-parser-js: script/cli.js - checksum: 10c0/b134bc0d8da10c76e07740a0ade61c193fd4c4d120ba2cb2530e26931f6b550dd60b6e801d8891f6d9c23dfebadf5590294739069edff94396f173cc8cc5767e - languageName: node - linkType: hard - "unbash@npm:^3.0.0": version: 3.0.0 resolution: "unbash@npm:3.0.0" @@ -24548,16 +24086,6 @@ __metadata: languageName: node linkType: hard -"unbzip2-stream@npm:^1.3.3": - version: 1.4.3 - resolution: "unbzip2-stream@npm:1.4.3" - dependencies: - buffer: "npm:^5.2.1" - through: "npm:^2.3.8" - checksum: 10c0/2ea2048f3c9db3499316ccc1d95ff757017ccb6f46c812d7c42466247e3b863fb178864267482f7f178254214247779daf68e85f50bd7736c3c97ba2d58b910a - languageName: node - linkType: hard - "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0" @@ -24763,15 +24291,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:11.1.1": - version: 11.1.1 - resolution: "uuid@npm:11.1.1" - bin: - uuid: dist/esm/bin/uuid - checksum: 10c0/9e3af58eba872ece5a5e76f4773a94fc78a0ef2c2444c38dbe6b42f41dadf76c01850fd783604f27986f6195e6286aef064d45987d401b2a33127b98ddf7c0c5 - languageName: node - linkType: hard - "uuid@npm:14.0.1": version: 14.0.1 resolution: "uuid@npm:14.0.1" @@ -24905,20 +24424,6 @@ __metadata: languageName: node linkType: hard -"webdriver@npm:6.12.1": - version: 6.12.1 - resolution: "webdriver@npm:6.12.1" - dependencies: - "@wdio/config": "npm:6.12.1" - "@wdio/logger": "npm:6.10.10" - "@wdio/protocols": "npm:6.12.0" - "@wdio/utils": "npm:6.11.0" - got: "npm:^11.0.2" - lodash.merge: "npm:^4.6.1" - checksum: 10c0/b34897f68a7aa64ea98c0aead9d6dc926b6a5741ef8e035a5aa5575c0793d31495624f2d5a6bef7494e3fe5dce0162f46229c96f191b6f1833f55359428559eb - languageName: node - linkType: hard - "webdriver@npm:9.24.0": version: 9.24.0 resolution: "webdriver@npm:9.24.0" @@ -24976,37 +24481,6 @@ __metadata: languageName: node linkType: hard -"webdriverio@npm:^6.9.0": - version: 6.12.1 - resolution: "webdriverio@npm:6.12.1" - dependencies: - "@types/puppeteer-core": "npm:^5.4.0" - "@wdio/config": "npm:6.12.1" - "@wdio/logger": "npm:6.10.10" - "@wdio/repl": "npm:6.11.0" - "@wdio/utils": "npm:6.11.0" - archiver: "npm:^5.0.0" - atob: "npm:^2.1.2" - css-shorthand-properties: "npm:^1.1.1" - css-value: "npm:^0.0.1" - devtools: "npm:6.12.1" - fs-extra: "npm:^9.0.1" - get-port: "npm:^5.1.1" - grapheme-splitter: "npm:^1.0.2" - lodash.clonedeep: "npm:^4.5.0" - lodash.isobject: "npm:^3.0.2" - lodash.isplainobject: "npm:^4.0.6" - lodash.zip: "npm:^4.2.0" - minimatch: "npm:^3.0.4" - puppeteer-core: "npm:^5.1.0" - resq: "npm:^1.9.1" - rgb2hex: "npm:0.2.3" - serialize-error: "npm:^8.0.0" - webdriver: "npm:6.12.1" - checksum: 10c0/c6c0d9a3b6e149ec00b9c7642fc56a821c5f7ff148569d2704e5b2e72fc10f008abeccea45aeea1c1c78273634f9222b5ae57fd85820a0b2e650e3d30784ebaa - languageName: node - linkType: hard - "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" @@ -25337,7 +24811,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7, ws@npm:^7.2.3, ws@npm:^7.5.10": +"ws@npm:^7, ws@npm:^7.5.10": version: 7.5.13 resolution: "ws@npm:7.5.13" peerDependencies: @@ -25637,17 +25111,6 @@ __metadata: languageName: node linkType: hard -"zip-stream@npm:^4.1.0": - version: 4.1.1 - resolution: "zip-stream@npm:4.1.1" - dependencies: - archiver-utils: "npm:^3.0.4" - compress-commons: "npm:^4.1.2" - readable-stream: "npm:^3.6.0" - checksum: 10c0/38f91ca116a38561cf184c29e035e9453b12c30eaf574e0993107a4a5331882b58c9a7f7b97f63910664028089fbde3296d0b3682d1ccb2ad96929e68f1b2b89 - languageName: node - linkType: hard - "zip-stream@npm:^6.0.1": version: 6.0.1 resolution: "zip-stream@npm:6.0.1"