diff --git a/.changeset/macos-paper-storybook.md b/.changeset/macos-paper-storybook.md new file mode 100644 index 0000000000..88af0bff13 --- /dev/null +++ b/.changeset/macos-paper-storybook.md @@ -0,0 +1,12 @@ +--- +'@fluentui-react-native/storybook-desktop': minor +'@fluentui-react-native/components': patch +--- + +Add a macOS-only `--paper` option to prepare, build, launch, and smoke test Storybook with Paper while preserving Fabric as the default renderer. + +Use centered alignment in macOS Paper CompoundItemLayout stories to avoid the native Yoga baseline crash without changing the primitive or Fabric demonstrations. + +Measure LayoutStableText's hidden reserve Text directly in native alignment stories, avoiding Paper Text-versus-View pixel-rounding mismatches without relaxing the geometry assertions. + +Deduplicate compatible Storybook React, theming, and shared UI dependencies in the lockfile after desktop UI updates, keeping strict Metro duplicate checks enabled. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8d7860dbf0..f479f0396f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -244,6 +244,57 @@ jobs: apps/storybook/artifacts/storybook-commands ${{ steps.desktop-driver.outputs.doctor-path }} + macos-paper-storybook: + name: macOS Paper Storybook PR + runs-on: macos-26 + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up toolchain + uses: microsoft/react-native-test-app/.github/actions/setup-toolchain@c75754b0fedce160a4dcf7193fae1a6ece76efe2 # 5.4.11 + with: + platform: macos + node-version: 22 + + - name: Install dependencies + run: yarn + + - name: Build packages + run: yarn build + + - name: Set up macOS Desktop Driver + id: desktop-driver + uses: ./.github/actions/setup-desktop-driver + with: + platform: macos + disabled-input-features: physicalClick + + - name: Bundle macOS Paper + run: yarn storybook bundle --macos --paper + working-directory: apps/storybook + + - name: Pod install (Paper) + run: yarn storybook prep --macos --paper + working-directory: apps/storybook + + - name: Run through Paper storybook smoke tests + run: yarn storybook smoke --macos --paper --mode stories-and-tests + working-directory: apps/storybook + env: + CCACHE_DISABLE: 1 + + - name: Upload macOS Paper Storybook artifacts + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Storybook_macos_Paper_Dump + path: | + apps/storybook/artifacts/macos + apps/storybook/artifacts/storybook-commands + ${{ steps.desktop-driver.outputs.doctor-path }} + ios: name: iOS PR runs-on: macos-26 diff --git a/apps/storybook/AGENTS.md b/apps/storybook/AGENTS.md index 278e70f281..37f5a638fa 100644 --- a/apps/storybook/AGENTS.md +++ b/apps/storybook/AGENTS.md @@ -82,6 +82,8 @@ Read [`agent-map.yaml`](agent-map.yaml) first for the compact architecture, look 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. +- macOS defaults to Fabric. Add `--paper` to `prep`, `build`, `run`, or `smoke` for Paper; regenerate with `prep` + whenever switching renderers because both modes share the generated project and Pods. Keep the endpoint `macos`. - 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. diff --git a/apps/storybook/README.md b/apps/storybook/README.md index b34184c251..336aa29d10 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -95,6 +95,29 @@ yarn storybook run --macos Requires Xcode + CocoaPods. +Fabric is the default macOS renderer. Add `--paper` to use Paper with the same +macOS story catalog and native Desktop Driver: + +```sh +yarn storybook prep --macos --paper +yarn storybook build --macos --paper +yarn storybook smoke --macos --paper --mode stories-and-tests +``` + +For interactive development, use `yarn storybook driver --macos --paper` and +`yarn storybook run --macos --paper` in separate terminals. `--paper` is supported +only on macOS; it sets `RCT_NEW_ARCH_ENABLED=0` for child commands. Omitting it +selects Fabric explicitly, even if the parent environment disables the New Architecture. +Run `prep` again whenever switching renderers: the generated Xcode project and +Pods are shared, so Paper and Fabric cannot run concurrently from one enlistment. +To return to Fabric, run `yarn storybook prep --macos` before building or launching. +The **macOS Paper Storybook PR** job runs the full catalog traversal and authored +tests separately from the existing Fabric job. +CompoundItemLayout's demos use centered alignment in macOS Paper because the +current native text-baseline callback can return NaN and terminate Yoga +([upstream issue](https://github.com/microsoft/react-native-macos/issues/3121)). +Fabric and other platforms retain the baseline demonstration. The primitive itself is unchanged. + 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. @@ -165,6 +188,11 @@ 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; React, React Native, and application dependencies remain checked. +After upgrading Storybook UI or addon packages, run +`yarn dedupe '@storybook/*' storybook --strategy highest` from the repository +root to align compatible locked React, theming, and shared UI dependencies. +Keep these runtime packages covered by Metro's duplicate checks rather than +adding exclusions for mismatched versions. ## Running on Win32 diff --git a/apps/storybook/agent-map.yaml b/apps/storybook/agent-map.yaml index 8039f0170e..c70de0a9f6 100644 --- a/apps/storybook/agent-map.yaml +++ b/apps/storybook/agent-map.yaml @@ -65,6 +65,11 @@ native: disabled_input_env: FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES ci_policy: explicit-build-then-prebuilt-only macos: + renderer: + default: fabric + paper_flag: --paper + switching: rerun-prep-before-build-or-launch + concurrency: one-renderer-per-enlistment process: ReactTestApp app_bundle: macos/DerivedData/Build/Products/Debug/ReactTestApp.app coordinate_notes: diff --git a/apps/storybook/macos/Podfile b/apps/storybook/macos/Podfile index 731562ecad..b6d52576af 100644 --- a/apps/storybook/macos/Podfile +++ b/apps/storybook/macos/Podfile @@ -32,7 +32,7 @@ end # react-native-test-app runs codegen before autolinking, so seed its clean-install input first. write_storybook_autolinking_config!(File.expand_path('..', __dir__)) -use_test_app!(:fabric_enabled => true, :bridgeless_enabled => false) do |target| +use_test_app!(:fabric_enabled => ENV.fetch('RCT_NEW_ARCH_ENABLED', '1') != '0', :bridgeless_enabled => false) do |target| target.app do platform :osx, '14' end diff --git a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx index 9497fdb885..6654613fc2 100644 --- a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx +++ b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ import type { ReactNode } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { Platform, StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; @@ -55,7 +55,8 @@ const styles = StyleSheet.create({ width: 360, }, textBaseline: { - alignItems: 'baseline', + // macOS Paper baseline crash: https://github.com/microsoft/react-native-macos/issues/3121 + alignItems: Platform.OS === 'macos' && Reflect.get(globalThis, 'nativeFabricUIManager') == null ? 'center' : 'baseline', }, leading: { alignItems: 'center', diff --git a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx index 0a2e5c0d0c..718ddbd318 100644 --- a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx +++ b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import { StyleSheet, Text } from 'react-native'; +import { Platform, StyleSheet, Text } from 'react-native'; import { render } from '../../common/renderWithTheme'; @@ -42,4 +42,37 @@ describe('CompoundItemLayout', () => { await component.rerender(Primary} testID="text-row" />); expect(StyleSheet.flatten(component.getByTestId('text-row').props.style)).toMatchObject({ alignItems: 'center' }); }); + + it.each([ + ['macos', false, 'center'], + ['macos', true, 'baseline'], + ['windows', true, 'baseline'], + ['win32', false, 'baseline'], + ])('uses %s story alignment with Fabric=%s', (platform, fabric, alignment) => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'nativeFabricUIManager'); + const create = jest.fn(StyleSheet.create); + Object.defineProperty(globalThis, 'nativeFabricUIManager', { configurable: true, value: fabric ? {} : undefined }); + + try { + jest.isolateModules(() => { + jest.doMock('react-native', () => + Object.create(jest.requireActual('react-native'), { + Platform: { value: { ...Platform, OS: platform } }, + StyleSheet: { value: { ...StyleSheet, create } }, + }), + ); + require('./compound-item-layout.stories'); + }); + expect(create.mock.calls.map(([styles]) => styles)).toEqual( + expect.arrayContaining([expect.objectContaining({ textBaseline: { alignItems: alignment } })]), + ); + } finally { + jest.dontMock('react-native'); + if (descriptor) { + Object.defineProperty(globalThis, 'nativeFabricUIManager', descriptor); + } else { + Reflect.deleteProperty(globalThis, 'nativeFabricUIManager'); + } + } + }); }); diff --git a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx index 19ead348b2..43ed87a954 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx +++ b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx @@ -105,15 +105,13 @@ function AlignmentMeasurement() { const [visible, setVisible] = useState(); return ( - setReserve(event.nativeEvent.layout)} - > + Stable label} + reserve={ + setReserve(event.nativeEvent.layout)}> + Stable label + + } visible={ { const assert: typeof import('node:assert') = (await import('node:assert')).default; if (platform === 'macos') { - // Fabric paragraphs do not expose their testID to AX; use their native onLayout measurements. + // Measure both Text line boxes; Paper rounds Text and wrapper View frames differently. const status = await browser.$('~layout-stable-text-native-metrics'); await browser.waitUntil(async () => (await status.getText()).startsWith('{')); const metrics: { reserve: LayoutRectangle; visible: LayoutRectangle } = JSON.parse(await status.getText()); @@ -178,7 +176,7 @@ export const Overview: Story = { ); assert( Math.abs(metrics.visible.x) <= 1 && metrics.visible.width <= metrics.reserve.width + 1, - 'Visible text must retain leading alignment and fit the reserved width.', + `Visible text must retain leading alignment and fit the reserved width: ${JSON.stringify(metrics)}.`, ); return; } diff --git a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx index a852424366..0a9867ff81 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx +++ b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx @@ -56,6 +56,29 @@ describe('LayoutStableText', () => { expect(StyleSheet.flatten(component.getByTestId('stable-text').props.style)).toMatchObject({ width: 80 }); }); + it('preserves native layout measurements for both the hidden reserve and visible text', async () => { + const onReserveLayout = jest.fn(); + const onVisibleLayout = jest.fn(); + const component = await render( + Reserve} + visible={ + + Visible + + } + />, + ); + const reserveLayout = { nativeEvent: { layout: { x: 0, y: 0, width: 103, height: 24 } } }; + const visibleLayout = { nativeEvent: { layout: { x: 0, y: 4, width: 104, height: 16 } } }; + + await fireEvent(component.getByText('Reserve', { includeHiddenElements: true }), 'layout', reserveLayout); + await fireEvent(component.getByTestId('visible'), 'layout', visibleLayout); + + expect(onReserveLayout).toHaveBeenCalledWith(reserveLayout); + expect(onVisibleLayout).toHaveBeenCalledWith(visibleLayout); + }); + it('preserves visible text semantics and interaction through the overlay', async () => { const onPress = jest.fn(); const component = await render( diff --git a/packages/agentic/storybook-desktop/README.md b/packages/agentic/storybook-desktop/README.md index 57ce034bb3..0d337b2f91 100644 --- a/packages/agentic/storybook-desktop/README.md +++ b/packages/agentic/storybook-desktop/README.md @@ -129,6 +129,14 @@ storybook-desktop smoke --windows --mode stories-and-tests ``` Use `--config ` for a differently named configuration file. +macOS defaults to Fabric. Add `--paper` to macOS commands to set +`RCT_NEW_ARCH_ENABLED=0` for their child processes; commands without the flag +explicitly select Fabric. The endpoint, story catalog, and native driver remain +`macos`. Consuming Podfiles must honor `RCT_NEW_ARCH_ENABLED` when passing +`fabric_enabled` to `use_test_app!`. Run `prep` with the desired flag whenever +switching renderers because the generated native project and Pods are shared. +The matching API option is `new DesktopStorybookCli(config, { paper: true })`. + `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 diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index eba279386d..5dd87cd1c8 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -286,6 +286,58 @@ describe('DesktopStorybookCli', () => { args: ['run', '--platform', 'windows', '--solution', 'windows/AgenticStorybook.sln'], }); await expect(cli.build('win32')).rejects.toThrow('build is not configured for win32'); + expect(runner.foreground[0].env).not.toHaveProperty('RCT_NEW_ARCH_ENABLED'); + }); + + test.each([true, false])('applies the macOS renderer to every command with paper=%s', async (paper) => { + const runner = new RecordingRunner(); + const cli = new DesktopStorybookCli(makeConfig(), { ...nativeDriverTestOptions, paper, runner }); + + await cli.prep('macos', { driver: false }); + await cli.bundle('macos'); + await cli.build('macos'); + await cli.run('macos'); + + expect(runner.foreground.map(({ command }) => command)).toEqual(['pod', 'sb-rn-get-stories', 'rnx-cli', 'rnx-cli', 'rnx-cli']); + expect(runner.foreground.every(({ env }) => env.RCT_NEW_ARCH_ENABLED === (paper ? '0' : '1'))).toBe(true); + }); + + test('switches back to Fabric explicitly after Paper preparation', async () => { + const runner = new RecordingRunner(); + const paper = new DesktopStorybookCli(makeConfig(), { ...nativeDriverTestOptions, paper: true, runner }); + const fabric = new DesktopStorybookCli(makeConfig(), { ...nativeDriverTestOptions, runner }); + + await paper.prep('macos', { driver: false }); + await fabric.prep('macos', { driver: false }); + + expect(runner.foreground.map(({ env }) => env.RCT_NEW_ARCH_ENABLED)).toEqual(['0', '1']); + }); + + test('rotates the authenticated bridge nonce when switching macOS renderers', async () => { + const options = { + ...nativeDriverTestOptions, + createStoryManifest: createEmptyStoryManifest, + isPortAvailable: async () => true, + output: { write: () => true }, + runner: new RecordingRunner(), + }; + const manifestPath = path.join(storybookRoot, 'storybook-desktop.generated', 'driver-manifest.macos.json'); + + try { + await new DesktopStorybookCli(makeConfig(), options).driver('macos'); + const fabricManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + await new DesktopStorybookCli(makeConfig(), { ...options, paper: true }).driver('macos'); + const paperManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + await new DesktopStorybookCli(makeConfig(), { ...options, paper: true }).driver('macos'); + const repeatedPaperManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + expect(fabricManifest.renderer).toBe('fabric'); + expect(paperManifest.renderer).toBe('paper'); + expect(paperManifest.bridgeNonce).not.toBe(fabricManifest.bridgeNonce); + expect(repeatedPaperManifest.bridgeNonce).toBe(paperManifest.bridgeNonce); + } finally { + fs.rmSync(manifestPath, { force: true }); + } }); test('runs the standalone macOS app with the enlistment-specific identity', async () => { @@ -655,6 +707,87 @@ describe('DesktopStorybookCli', () => { }); describe('createDesktopStorybookCommand', () => { + test.each(['prep', 'bundle', 'build', 'run'] as const)('forwards --paper to macOS %s', async (action) => { + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ ...nativeDriverTestOptions, config: makeConfig(), runner }); + + await program.parseAsync(['node', 'storybook', action, '--macos', '--paper', ...(action === 'prep' ? ['--no-driver'] : [])]); + + expect(runner.foreground.length).toBeGreaterThan(0); + expect(runner.foreground.every(({ env }) => env.RCT_NEW_ARCH_ENABLED === '0' && env[FURN_STORYBOOK_PLATFORM] === 'macos')).toBe(true); + }); + + test('keeps the Paper smoke endpoint and propagates the renderer through launch and cleanup', async () => { + const runner = new RecordingRunner(); + const runSmokeTests = jest.fn(async () => ({ + endpoint: 'macos' as const, + finishedAt: '2026-10-02T08:00:01.000Z', + manifest: { platform: 'macos-digest', portable: 'portable-digest' }, + platformName: 'macos' as const, + runId: 'paper-smoke', + schemaVersion: 1 as const, + startedAt: '2026-10-02T08:00:00.000Z', + status: 'passed' as const, + targetId: 'agenticstorybook-macos', + tests: [], + })); + const program = createDesktopStorybookCommand({ + ...nativeDriverTestOptions, + config: makeConfig({ macos: { run: { command: 'launch-storybook' }, smoke: { stop: { command: 'stop-storybook' } } } }), + createStoryManifest: createEmptyStoryManifest, + fetch: async () => new Response(JSON.stringify({ entries: { story: { id: 'first--story', type: 'story' } } })), + isPortAvailable: async () => true, + output: { write: () => true }, + runner, + runSmokeTests, + }); + + await program.parseAsync(['node', 'storybook', 'smoke', '--macos', '--paper', '--mode', 'stories-and-tests']); + + expect(runner.foreground.map(({ command }) => command)).toEqual(['launch-storybook', 'stop-storybook']); + expect([...runner.foreground, ...runner.background].every(({ env }) => env.RCT_NEW_ARCH_ENABLED === '0')).toBe(true); + expect(runSmokeTests).toHaveBeenCalledWith(expect.objectContaining({ platform: 'macos', targetId: 'agenticstorybook-macos' })); + expect( + JSON.parse(fs.readFileSync(path.join(storybookRoot, 'storybook-desktop.generated', 'driver-manifest.macos.json'), 'utf8')), + ).toMatchObject({ + endpoint: 'macos', + renderer: 'paper', + }); + expect(runner.stopped).toBe(2); + }); + + test.each(['windows', 'win32'])('rejects --paper with explicit --%s', async (platform) => { + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ config: makeConfig(), runner }); + program.commands.forEach((command) => { + command.exitOverride(); + command.configureOutput({ writeErr: () => {} }); + }); + + await expect(program.parseAsync(['node', 'storybook', 'build', `--${platform}`, '--paper'])).rejects.toThrow( + 'cannot be used with option', + ); + expect(runner.foreground).toEqual([]); + }); + + test.each(['windows', 'win32'])('rejects --paper with the implicit %s endpoint', async (platform) => { + const previousPlatform = process.env[FURN_STORYBOOK_PLATFORM]; + process.env[FURN_STORYBOOK_PLATFORM] = platform; + const runner = new RecordingRunner(); + const program = createDesktopStorybookCommand({ config: makeConfig(), runner }); + + try { + await expect(program.parseAsync(['node', 'storybook', 'build', '--paper'])).rejects.toThrow('--paper is only supported on macOS'); + expect(runner.foreground).toEqual([]); + } finally { + if (previousPlatform === undefined) { + delete process.env[FURN_STORYBOOK_PLATFORM]; + } else { + process.env[FURN_STORYBOOK_PLATFORM] = previousPlatform; + } + } + }); + test('forwards verbose logging through nested lifecycle commands', async () => { const runner = new RecordingRunner(); const command = createDesktopStorybookCommand({ config: makeConfig(), runner }); diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts index 314cb13648..4dd4d310be 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -52,6 +52,8 @@ export type DesktopStorybookPrepOptions = { }; export type DesktopStorybookCliOptions = { + /** Use the macOS Paper renderer instead of the default Fabric renderer. */ + paper?: boolean; buildNativeDriver?: typeof buildNativeDesktopDriver; createStoryManifest?: typeof createDesktopStoryManifest; runner?: DesktopCommandRunner; @@ -79,6 +81,7 @@ export type DesktopStorybookTestOptions = DesktopStorybookWdioOptions & { export class DesktopStorybookCli { readonly config: DesktopStorybookConfig; readonly instance: DesktopStorybookInstance; + readonly paper: boolean; private readonly runner: DesktopCommandRunner; private readonly buildNativeDriver: typeof buildNativeDesktopDriver; @@ -94,6 +97,7 @@ export class DesktopStorybookCli { constructor(config: DesktopStorybookConfig, options: DesktopStorybookCliOptions = {}) { this.config = config; + this.paper = options.paper ?? false; this.instance = createDesktopStorybookInstance({ projectRoot: config.projectRoot, bundleIdentifierPrefix: config.macosBundleIdentifier, @@ -256,6 +260,8 @@ export class DesktopStorybookCli { manifest.endpoint !== platform || !('instanceId' in manifest) || manifest.instanceId !== this.instance.id || + !('renderer' in manifest) || + manifest.renderer !== this.renderer(platform) || !('driverPort' in manifest) || typeof manifest.driverPort !== 'number' || !Number.isInteger(manifest.driverPort) || @@ -275,6 +281,7 @@ export class DesktopStorybookCli { { ...this.instance, endpoint: platform, + renderer: this.renderer(platform), targetId: `${this.config.appName}-${platform}`.toLowerCase(), testIDPrefix: this.config.testIDPrefix, }, @@ -489,18 +496,24 @@ export class DesktopStorybookCli { ): Promise { const storyManifest = await this.createStoryManifest(this.config, platform); const outputPath = path.join(this.config.projectRoot, 'storybook-desktop.generated', `driver-manifest.${platform}.json`); + const renderer = this.renderer(platform); const driverManifest = createDesktopStorybookDriverManifest({ - bridgeNonce: readReusableBridgeNonce(outputPath, instance, storyManifest.platformManifestDigest), + bridgeNonce: readReusableBridgeNonce(outputPath, instance, storyManifest.platformManifestDigest, renderer), config: this.config, instance, nativeDriver, platform, + renderer, storyManifest, }); writeDesktopStorybookDriverManifest(driverManifest, outputPath); return outputPath; } + private renderer(platform: Platforms): 'fabric' | 'paper' { + return platform === 'win32' || (platform === 'macos' && this.paper) ? 'paper' : 'fabric'; + } + private async resolveDriver(platform: Platforms): Promise { const options = this.requireNativeDriverOptions(platform); return this.resolveNativeDriver({ @@ -655,6 +668,7 @@ export class DesktopStorybookCli { env: { ...command.env, [FURN_STORYBOOK_PLATFORM]: platform, + ...(platform === 'macos' ? { RCT_NEW_ARCH_ENABLED: this.paper ? '0' : '1' } : {}), [STORYBOOK_VERBOSE]: this.verbose ? '1' : '0', ...(instance ? { @@ -676,6 +690,7 @@ function readReusableBridgeNonce( manifestPath: string, instance: DesktopStorybookInstance, platformManifestDigest: string, + renderer: 'fabric' | 'paper', ): string | undefined { if (!fs.existsSync(manifestPath)) { return undefined; @@ -685,6 +700,7 @@ function readReusableBridgeNonce( return current.instanceId === instance.id && current.storybookPort === instance.storybookPort && current.driverPort === instance.driverPort && + current.renderer === renderer && current.platformManifestDigest === platformManifestDigest && typeof current.bridgeNonce === 'string' ? current.bridgeNonce diff --git a/packages/agentic/storybook-desktop/src/cli/README.md b/packages/agentic/storybook-desktop/src/cli/README.md index decc16468f..6fe4534865 100644 --- a/packages/agentic/storybook-desktop/src/cli/README.md +++ b/packages/agentic/storybook-desktop/src/cli/README.md @@ -38,6 +38,13 @@ 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. +macOS uses Fabric unless `--paper` is present. Use +`yarn storybook prep --macos --paper`, then pass `--paper` to `build`, `run`, +or `smoke` to exercise Paper. Regenerate with `prep` whenever switching modes; +the Xcode project and Pods are shared, not parallel renderer-specific outputs. +Both modes retain the `macos` endpoint and story catalog. `--paper` is rejected +for Windows and Win32. + Prefer an explicit option for isolated commands: ```sh diff --git a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts index 21de734d72..479df1e533 100644 --- a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts +++ b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts @@ -15,6 +15,7 @@ import { loadDesktopStorybookConfig } from './loadConfig.js'; type PlatformFlags = { macos?: boolean; + paper?: boolean; win32?: boolean; windows?: boolean; }; @@ -24,6 +25,7 @@ type ManifestFlags = PlatformFlags & { out?: string }; type SmokeFlags = PlatformFlags & { mode: DesktopSmokeMode }; type BuildDriverFlags = PlatformFlags & DesktopStorybookBuildDriverOptions; type PrepFlags = PlatformFlags & { driver: DesktopStorybookPrepOptions['driver'] }; +type GetApi = (flags: PlatformFlags) => Promise; export type CreateDesktopStorybookCommandOptions = DesktopStorybookCliOptions & { config?: DesktopStorybookConfig; @@ -38,7 +40,7 @@ export function createDesktopStorybookCommand(options: CreateDesktopStorybookCom .option('-v, --verbose', 'replay full captured output for successful commands as well as failures'); let apiPromise: Promise | undefined; - const getApi = () => + const getApi = (flags: PlatformFlags) => (apiPromise ??= Promise.resolve( options.config ?? loadDesktopStorybookConfig(program.opts<{ config?: string }>().config, options.cwd), ).then( @@ -50,10 +52,12 @@ export function createDesktopStorybookCommand(options: CreateDesktopStorybookCom fetch: options.fetch, output: options.output, errorOutput: options.errorOutput, + paper: flags.paper ?? options.paper, verbose: program.opts<{ verbose?: boolean }>().verbose ?? options.verbose, isPortAvailable: options.isPortAvailable, runSmokeTests: options.runSmokeTests, resolveNativeDriver: options.resolveNativeDriver, + writeMacOSApplicationLease: options.writeMacOSApplicationLease, }), )); @@ -72,7 +76,7 @@ export function createDesktopStorybookCommand(options: CreateDesktopStorybookCom return program; } -function addTestCommand(program: Command, getApi: () => Promise): void { +function addTestCommand(program: Command, getApi: GetApi): void { const command = program .command('test') .description('Run executable wdio story callbacks against the running Storybook app.') @@ -87,7 +91,7 @@ function addTestCommand(program: Command, getApi: () => Promise', 'native click semantics').choices(['auto', 'physical', 'accessibility'])); addPlatformOptions(command); command.action(async (flags: PlatformFlags & DesktopStorybookTestOptions) => { - const api = await getApi(); + const api = await getApi(flags); await api.test(resolvePlatform(flags, api), { clickMode: flags.clickMode, list: flags.list, @@ -102,29 +106,29 @@ function addTestCommand(program: Command, getApi: () => Promise Promise): void { +function addPrepCommand(program: Command, getApi: GetApi): void { const command = program .command('prep') .description('Prepare the native helper, dependencies, and generated projects.') .option('--no-driver', 'prepare only the native app project'); addPlatformOptions(command); command.action(async (flags: PrepFlags) => { - const api = await getApi(); + const api = await getApi(flags); await api.prep(resolvePlatform(flags, api), { driver: flags.driver }); }); } -function addBuildDriverCommand(program: Command, getApi: () => Promise): void { +function addBuildDriverCommand(program: Command, getApi: GetApi): void { const command = program.command('build-driver').description('Build the native Desktop Driver helper without preparing the app.'); command.option('--force', 'publish a new immutable helper selection'); addPlatformOptions(command); command.action(async (flags: BuildDriverFlags) => { - const api = await getApi(); + const api = await getApi(flags); await api.buildDriver(resolvePlatform(flags, api), { force: flags.force }); }); } -function addDriverCommand(program: Command, getApi: () => Promise): void { +function addDriverCommand(program: Command, getApi: GetApi): void { const command = program .command('driver') .description('Start the Storybook channel, MCP, and embedded Desktop Driver servers.') @@ -132,25 +136,25 @@ function addDriverCommand(program: Command, getApi: () => Promise', 'Storybook channel port; defaults to the enlistment-specific port', parsePort); addPlatformOptions(command); command.action(async (flags: ServerFlags) => { - const api = await getApi(); + const api = await getApi(flags); await api.driver(resolvePlatform(flags, api), { host: flags.host, port: flags.port }); }); } -function addManifestCommand(program: Command, getApi: () => Promise): void { +function addManifestCommand(program: Command, getApi: GetApi): 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(); + const api = await getApi(flags); await api.manifest(resolvePlatform(flags, api), flags.out); }); } -function addInstanceCommand(program: Command, getApi: () => Promise): void { +function addInstanceCommand(program: Command, getApi: GetApi): 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(); + const api = await getApi(flags); api.printInstance(resolvePlatform(flags, api)); }); } @@ -159,22 +163,17 @@ export async function runDesktopStorybookCli(argv: readonly string[] = process.a await createDesktopStorybookCommand().parseAsync([...argv]); } -function addActionCommand( - program: Command, - action: 'bundle' | 'run' | 'build', - description: string, - getApi: () => Promise, -): void { +function addActionCommand(program: Command, action: 'bundle' | 'run' | 'build', description: string, getApi: GetApi): void { const command = program.command(action).description(description); addPlatformOptions(command); command.action(async (flags: PlatformFlags) => { - const api = await getApi(); + const api = await getApi(flags); const platform = resolvePlatform(flags, api); await api[action](platform); }); } -function addSmokeCommand(program: Command, getApi: () => Promise): void { +function addSmokeCommand(program: Command, getApi: GetApi): void { const command = program .command('smoke') .description('Launch the app, traverse every story, optionally run authored tests, and shut the app down.') @@ -185,12 +184,12 @@ function addSmokeCommand(program: Command, getApi: () => Promise { - const api = await getApi(); + const api = await getApi(flags); await api.smoke(resolvePlatform(flags, api), { mode: flags.mode }); }); } -function addServerCommand(program: Command, getApi: () => Promise): void { +function addServerCommand(program: Command, getApi: GetApi): void { const command = program .command('server') .description('Start the Storybook channel and MCP server.') @@ -198,7 +197,7 @@ function addServerCommand(program: Command, getApi: () => Promise', 'server port; defaults to STORYBOOK_WS_PORT or 7007', parsePort); addPlatformOptions(command); command.action(async (flags: ServerFlags) => { - const api = await getApi(); + const api = await getApi(flags); await api.server(resolvePlatform(flags, api), { host: flags.host, port: flags.port, @@ -208,9 +207,10 @@ function addServerCommand(program: Command, getApi: () => Promise { bundleIdentifier: instance.bundleIdentifier, leasePath: path.join(storybookRoot, 'storybook-desktop.generated', 'application-lease.macos.json'), }); + expect(macosManifest.renderer).toBe('fabric'); + }); + + test('records macOS Paper without changing the endpoint or native identity', () => { + const config = makeDesktopStorybookConfig({ projectRoot: storybookRoot }); + const instance = createDesktopStorybookInstance({ + bundleIdentifierPrefix: config.macosBundleIdentifier, + projectRoot: config.projectRoot, + }); + const manifest = createDesktopStorybookDriverManifest({ + config, + instance, + nativeDriver, + platform: 'macos', + renderer: 'paper', + storyManifest: { ...storyManifest, endpoint: 'macos' }, + }); + + expect(manifest).toMatchObject({ + application: { bundleIdentifier: instance.bundleIdentifier }, + endpoint: 'macos', + renderer: 'paper', + targetId: 'agenticstorybook-macos', + }); }); test('removes a stale application lease before writing a new manifest', () => { diff --git a/packages/agentic/storybook-desktop/src/driver/driverManifest.ts b/packages/agentic/storybook-desktop/src/driver/driverManifest.ts index 0f7b2af9dd..efc6034582 100644 --- a/packages/agentic/storybook-desktop/src/driver/driverManifest.ts +++ b/packages/agentic/storybook-desktop/src/driver/driverManifest.ts @@ -39,6 +39,7 @@ export type CreateDesktopStorybookDriverManifestOptions = { instance: DesktopStorybookInstance; nativeDriver: NativeDriverArtifact; platform: Platforms; + renderer?: DesktopStorybookDriverManifest['renderer']; storyManifest: DesktopStoryManifest; }; @@ -48,6 +49,7 @@ export function createDesktopStorybookDriverManifest({ instance, nativeDriver, platform, + renderer = platform === 'win32' ? 'paper' : 'fabric', storyManifest, }: CreateDesktopStorybookDriverManifestOptions): DesktopStorybookDriverManifest { const nativeOptions = config.getNativeDriverOptions(platform); @@ -72,7 +74,7 @@ export function createDesktopStorybookDriverManifest({ nativeDriver, platformManifestDigest: storyManifest.platformManifestDigest, portablePlanDigest: storyManifest.portablePlanDigest, - renderer: platform === 'win32' ? 'paper' : 'fabric', + renderer, schemaVersion: 2, storyManifest, storybookPort: instance.storybookPort, diff --git a/yarn.lock b/yarn.lock index fd6e7252c8..01cc006503 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9989,24 +9989,6 @@ __metadata: languageName: node linkType: hard -"@storybook/react-dom-shim@npm:10.5.6": - version: 10.5.6 - resolution: "@storybook/react-dom-shim@npm:10.5.6" - peerDependencies: - "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - "@types/react-dom": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.6 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/d6393cf9532fb7f2dddedeeac6bef35d9abcf3bfa48c1d8bf5a1be4d2167746d252f638e15feb6a24f1a4b21d51d4df1870e70acfb1e1d663aedada0cf9f1c4d - languageName: node - linkType: hard - "@storybook/react-dom-shim@npm:10.6.0": version: 10.6.0 resolution: "@storybook/react-dom-shim@npm:10.6.0" @@ -10025,19 +10007,7 @@ __metadata: languageName: node linkType: hard -"@storybook/react-native-theming@npm:^10.4.7, @storybook/react-native-theming@npm:^10.5.4": - version: 10.5.4 - resolution: "@storybook/react-native-theming@npm:10.5.4" - dependencies: - polished: "npm:^4.3.1" - peerDependencies: - react: "*" - react-native: ">=0.57.0" - checksum: 10c0/da1d153288315406885a647d6397fb02166e2cb621f059aabed37b8dd439d6a6ec5bf9ba3aa4779e9b5fa03c209aff64d6af6bcc4771a21278a2aeaeac0c2f04 - languageName: node - linkType: hard - -"@storybook/react-native-theming@npm:^10.6.0": +"@storybook/react-native-theming@npm:^10.4.7, @storybook/react-native-theming@npm:^10.5.4, @storybook/react-native-theming@npm:^10.6.0": version: 10.6.0 resolution: "@storybook/react-native-theming@npm:10.6.0" dependencies: @@ -10049,25 +10019,7 @@ __metadata: languageName: node linkType: hard -"@storybook/react-native-ui-common@npm:^10.4.7, @storybook/react-native-ui-common@npm:^10.5.4": - version: 10.5.4 - resolution: "@storybook/react-native-ui-common@npm:10.5.4" - dependencies: - "@nozbe/microfuzz": "npm:^1.0.0" - "@storybook/react": "npm:^10.5.4" - "@storybook/react-native-theming": "npm:^10.5.4" - es-toolkit: "npm:^1.49.0" - memoizerific: "npm:^1.11.3" - ts-dedent: "npm:^2.3.0" - peerDependencies: - react: "*" - react-native: ">=0.57.0" - storybook: ^10.5.4 - checksum: 10c0/ba33fee75b282eeded4116651e88ac0d59ee0f49207f2d6f4a7a994b9a2023d6ea41303b6708c37a48cd9a13e1064eb08b8b182817f0cbc9b6ed0a71367ca8dc - languageName: node - linkType: hard - -"@storybook/react-native-ui-common@npm:^10.6.0": +"@storybook/react-native-ui-common@npm:^10.4.7, @storybook/react-native-ui-common@npm:^10.5.4, @storybook/react-native-ui-common@npm:^10.6.0": version: 10.6.0 resolution: "@storybook/react-native-ui-common@npm:10.6.0" dependencies: @@ -10170,33 +10122,7 @@ __metadata: languageName: node linkType: hard -"@storybook/react@npm:^10.5.4": - version: 10.5.6 - resolution: "@storybook/react@npm:10.5.6" - dependencies: - "@storybook/global": "npm:^5.0.0" - "@storybook/react-dom-shim": "npm:10.5.6" - react-docgen: "npm:^8.0.2" - react-docgen-typescript: "npm:^2.2.2" - peerDependencies: - "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - "@types/react-dom": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.6 - typescript: ">= 4.9.x" - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - typescript: - optional: true - checksum: 10c0/74eff8c1b3a52235a62a43d0e1646ef8f9f557d31745d9a313d735da98d1800950a76d16544b54d6756c237df573f0e4303af2197efc0c141f29e1b4e67399f3 - languageName: node - linkType: hard - -"@storybook/react@npm:^10.6.0": +"@storybook/react@npm:^10.5.4, @storybook/react@npm:^10.6.0": version: 10.6.0 resolution: "@storybook/react@npm:10.6.0" dependencies: