@fluentui-react-native/storybook-desktop
v0.3.0
Published
CLI and configuration for React Native desktop Storybook applications
Keywords
Readme
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:
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:
{
"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:
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.
The src/config import graph must also load before the TypeScript build:
Storybook's prebuild loader falls back to CommonJS-transpiled source when
lib is absent. Use explicit .ts extensions for relative imports in that
graph; rewriteRelativeImportExtensions converts them to .js in build output.
CLI and API
For a focused authored-test lane within the owned smoke lifecycle, set
STORYBOOK_SMOKE_STORY and STORYBOOK_SMOKE_TAG before running
storybook smoke --<platform> --mode stories-and-tests. These override WDIO
story/tag settings and filter any legacy plans as well. Without overrides,
smoke runs configured WDIO cases across all matching stories and legacy
desktop-e2e plans. An explicit selector that matches no
tests fails instead of reporting a false pass. This retains normal native
lease/readiness and process cleanup; it does not change input capability policy.
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:
storybook-desktop server --win32
storybook-desktop build-driver --windows
storybook-desktop build-driver --macos
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-testsUse --config <path> for a differently named configuration file.
build-driver builds only the source-shipped native helper. prep first
ensures that helper, then installs CocoaPods on macOS or generates the React
Native Test App solution on Windows; Win32 prep now ensures the shared Windows
helper. 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.
Set platformOptions.macos.nativeDriver.macosSigningIdentity to use a stable
macOS code-signing identity. When omitted, the source build uses an ad hoc
signature and TCC permissions may need to be granted again after a rebuild.
The shared standalone macOS run command applies the enlistment-specific
xcconfig, matching the bundle identity used by driver and smoke.
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 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. It resolves the
verified native helper before starting Metro and registers a process-backed
target. The deterministic fake host remains test-only.
Navigation waits for both an authenticated runtime hello and the first
storyRendered event from that same connection. The hello alone can arrive
before Storybook installs its navigation handlers, particularly after a native
app restart. This initial render only gates startup: each requested story must
still acknowledge the matching request/run and pass native story-root
verification before its tests execute.
All component catalog tests use executable WDIO functions. The legacy plan
runner remains supported for compatibility and is covered by dedicated fixtures.
Plan extraction evaluates only the inline
static desktopDriver literal and supports TypeScript satisfies; dynamic
values fail with source context instead of being omitted.
Executable tests inside stories
Add a top-level wdio property to a CSF3 story. The story is the suite
(describe), and named functions are its test cases (it). Test bodies use
WebdriverIO directly rather than a custom action/expectation JSON format:
import type { StoryObj } from '@storybook/react-native';
import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing';
type Story = WdioStory<StoryObj<typeof Button>>;
export const Default: Story = {
args: { testID: 'save-button' },
wdio: {
'is enabled': async ({ browser, expect }) => {
await expect(await browser.$('~save-button')).toBeEnabled();
},
'supports native activation': async ({ browser, platform, skip }) => {
const features = browser.capabilities['furn:features'];
if (!features?.physicalClick) {
skip('Physical pointer input is unavailable.');
return;
}
const button = await browser.$('~save-button');
await button.click();
if (platform !== 'macos' && features.focus) {
await browser.waitUntil(async () => (await button.getProperty('focused')) === true);
}
},
},
};browser is the real WebdriverIO browser, expect is expect-webdriverio,
and desktop exposes the existing typed native assertions and story commands.
platform is the target endpoint, typed as 'macos' | 'windows' | 'win32';
it is not process.platform. Win32 stays distinct even though its WebDriver
platformName is windows. Native feature flags are typed under
browser.capabilities['furn:features'].
signal allows cooperative cancellation; skip(reason) records an explicit
skip (return from the callback after calling it). Native selectors and
supported WebDriver operations apply; there is no DOM or JavaScript execution
inside the app.
The original wdio: async (context) => { ... } form remains supported. It is
one test, called default for filtering. Named collections must be non-empty,
with unique literal names and inline function values; spreads, computed
names, methods, nested suites, and dynamic test registration are rejected.
This is intentionally a lightweight story-as-suite pattern, not injected
Mocha/Jest describe/it globals. Names can be discovered without executing
test code, and each case retains its own process and native session.
Use getProperty() to read a native element property before asserting it;
Jest's ordinary toHaveProperty() matcher inspects the JavaScript object,
not the native control.
Callbacks are extracted without importing React Native in Node. They must be
inline functions with no closures over the story module's imports, helpers,
args, or render state. Declare test-local values inside the callback. Import
Node-compatible helpers with a literal await import('node:assert/strict'),
await import('./test-helper.js'), or package specifier inside the
callback; these resolve from the original story file. Unsupported expressions
fail extraction with source context rather than disappearing.
The shared Babel config removes these callbacks before Metro resolves their
dependencies, on all three desktop endpoints. Import WdioStory with
import type, never import Node test libraries at story-module scope, and use
createDesktopStorybookBabelConfig in the app. Ordinary on-device story
rendering and controls remain unchanged.
Run the experiment from the consuming app:
yarn storybook test --macos --list
# Terminal 1: keep the driver supervisor running.
yarn storybook driver --macos
# Terminal 2: launch the native app, then run the inline tests.
yarn storybook run --macos
yarn storybook test --macos --story 'components-button--*'
yarn storybook test --macos --story 'components-button--*' --test '*activation*'Defaults come from storybook.config.mts; no Mocha, Jest, or WDIO config file
is needed:
export default makeDesktopStorybookConfig({
// ...app identity and story discovery...
wdio: {
timeoutMs: 30_000,
reporter: 'spec', // spec, tap, or dot
clickMode: 'auto',
// Optional: story: 'components-button--*', test: '*activation*', tag: 'my-test-tag'
},
});--list includes named cases and respects --test filtering. CLI filters,
--timeout-ms, --reporter, and --click-mode override those
defaults. By default test uses the saved driver's actual port and target,
including port-probing adjustments. --url and --target together select an
externally managed driver. On macOS the command refreshes an exact,
nonce-bound lease for the isolated app identity. Windows and Win32 require
the trusted lifecycle owner to provide the existing application lease;
the test runner does not attach by an ambiguous process name or title.
Node's built-in test runner executes each test in a separate process. Workers
exit naturally so HTTP handles can close; forced test exit can race Windows
libuv teardown. The supervisor still bounds and terminates hanging workers and
rejects nonzero exits, including exits after a callback reports success. The
supervisor owns one attached WebDriver session at a time, authenticates the
live manifest, navigates to and remounts the correct story before each callback, and deletes the
session after success, failure, timeout, or worker exit. The app and driver
remain running. Failed callbacks exit nonzero; no matches is an error.
Named cases run in declaration order, grouped by story. The timeout applies
per case, not to the entire suite. Assertion failures and timeouts do not
prevent other independent cases in that story from running; connection or
session-cleanup failures stop execution. Do not share element handles or
assume state survives between cases.
Reports and best-effort failure source/tree evidence live in
artifacts/<platform>/wdio. Generated executable files are removed after the
run. Named results include testName, and failure evidence uses distinct
paths for each case. Callback digests participate in manifest freshness checks: restart the
driver and reload the app after editing tests.
Button's Default replaces its legacy plans with named WDIO tests for
semantics, platform-specific focus, pointer activation, and PNG capture.
ExternallyDrivenSelection verifies actual activation and caller-owned state
updates through the visible status label, since macOS does not expose
checked on the native button role. Run callbacks directly with storybook test, or include them in
storybook smoke --<platform> --mode stories-and-tests. Smoke groups tests by
story ID, runs that story's remaining legacy desktop-e2e plans and selected wdio cases,
then advances to the next story. Non-default stories are included. Every test
gets a fresh preview; the currently selected sidebar page is not a prerequisite.
The aggregate smoke report contains static results in tests and executable
results in wdio. Per-story executable reports are under
artifacts/<platform>/desktop-driver/wdio.
For legacy JSON plans only, use the consuming app's Desktop Driver CLI:
yarn desktop-driver stories list \
--url http://127.0.0.1:<driver-port> \
--target <target-id>
yarn desktop-driver stories run \
--url http://127.0.0.1:<driver-port> \
--target <target-id> \
--tag desktop-e2e \
--artifacts artifacts/<platform>/desktop-driverThe 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
desktop-e2e plans and configured executable callbacks, grouped by story,
through the native provider. Storybook owns app launch and supplies an exact
nonce-bound process lease; WebDriver attaches and preserves the app until the
Storybook lifecycle performs final cleanup.
The reusable macOS lifecycle resolves the launched app by its isolated bundle
identifier and atomically records its PID, start time, executable, and nonce
before creating the attached WebDriver session.
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.
The Windows Fabric lifecycle restarts only its exact owned app after the full catalog traversal, then rewrites the nonce-bound application lease and runs authored tests against the warm Metro bundle. This avoids carrying accumulated Fabric story state into the native test phase while preserving the same server, ports, and process ownership.
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:
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.
Command logs and pipeline failures
The shared command runner writes stdout and stderr continuously to a single
log file per command under artifacts/storybook-commands in that command's
working directory. Both streams share one descriptor, preserving their write
order without holding an entire build log in memory.
Normal output contains a start record with the log path and a short completion
summary. A failed command replays its complete combined log between labeled
BEGIN/END records, then reports failure on stderr. Replay groups are
serialized so concurrently completing commands do not mix their output.
Owned background services also replay their logs when smoke fails.
Use the global --verbose option to replay successful-command logs as well:
yarn storybook --verbose test --macos --story 'components-button--*'
yarn storybook --verbose smoke --macos --mode stories-and-testsConsole replay is grouped at command completion; the files update while commands are running, so ongoing logs are available without shell redirection. The verbose setting is inherited by package-owned Windows and Win32 lifecycle subprocesses.
Failures also emit explicit [storybook] FAIL diagnostics identifying the
story, test, failed step when available, and execution phase. Static test
failures are reported as each test settles, before the next test starts.
Inline callback diagnostics are retained independently of the Node reporter,
including with dot; CLI boundaries preserve nested causes and aggregate
errors. A failed test cannot be hidden by a successful worker exit or by a
secondary evidence/cleanup failure.
Archive the command logs alongside the per-platform test artifacts in CI.
Logs can contain application output and test data; apply the same access and
retention policy as other test evidence. Programmatic callers can inject
output and errorOutput streams and set verbose on DesktopStorybookCli
or NodeDesktopCommandRunner.
The app integrates its generated Storybook view with the shared runtime:
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.
