From c99b05f3a994052f9025b6cbba77a2253d14849d Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sun, 15 Jun 2025 15:02:34 +0100 Subject: [PATCH 01/11] feat: experimental playfn --- examples/expo-example/.rnstorybook/index.tsx | 6 +- examples/expo-example/.rnstorybook/main.ts | 3 +- .../.rnstorybook/storybook.requires.ts | 2 +- .../ActionExample/Actions.stories.tsx | 39 +++++++++- .../components/ActionExample/Actions.tsx | 6 +- examples/expo-example/metro.config.js | 9 ++- examples/expo-example/package.json | 2 +- .../react-native/src/metro/withStorybook.ts | 21 +----- .../src/webserver/handle-playfn-event.ts | 20 +++++ packages/react-native/src/webserver/types.ts | 74 +++++++++++++++++++ .../react-native/src/webserver/webserver.ts | 29 ++++++++ 11 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 packages/react-native/src/webserver/handle-playfn-event.ts create mode 100644 packages/react-native/src/webserver/types.ts create mode 100644 packages/react-native/src/webserver/webserver.ts diff --git a/examples/expo-example/.rnstorybook/index.tsx b/examples/expo-example/.rnstorybook/index.tsx index 3e35c83376..e49e3bd3c9 100644 --- a/examples/expo-example/.rnstorybook/index.tsx +++ b/examples/expo-example/.rnstorybook/index.tsx @@ -1,6 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { theme, ThemeProvider } from '@storybook/react-native-theming'; -// import { LiteUI } from '@storybook/react-native-ui-lite'; +import { LiteUI } from '@storybook/react-native-ui-lite'; import { SafeAreaView, StatusBar } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { view } from './storybook.requires'; @@ -13,7 +13,7 @@ const StorybookUIRoot = view.getStorybookUI({ getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, - enableWebsockets: false, + enableWebsockets: true, // onDeviceUI: !isScreenshotTesting, host: 'localhost', port: 7007, @@ -34,7 +34,7 @@ const StorybookUIRoot = view.getStorybookUI({ }, */ onDeviceUI: true, - // CustomUIComponent: LiteUI, + CustomUIComponent: LiteUI, }); const StorybookUI = () => { diff --git a/examples/expo-example/.rnstorybook/main.ts b/examples/expo-example/.rnstorybook/main.ts index a0f13d9972..541e36415e 100644 --- a/examples/expo-example/.rnstorybook/main.ts +++ b/examples/expo-example/.rnstorybook/main.ts @@ -18,8 +18,9 @@ const main: StorybookConfig = { 'storybook-addon-deep-controls', './local-addon-example', ], + reactNative: { - playFn: false, + playFn: true, }, framework: '@storybook/react-native', diff --git a/examples/expo-example/.rnstorybook/storybook.requires.ts b/examples/expo-example/.rnstorybook/storybook.requires.ts index 4dc3e148b0..3b2f99fa9f 100644 --- a/examples/expo-example/.rnstorybook/storybook.requires.ts +++ b/examples/expo-example/.rnstorybook/storybook.requires.ts @@ -56,7 +56,7 @@ global.STORIES = normalizedStories; module?.hot?.accept?.(); const options = { - "playFn": false + "playFn": true } if (!global.view) { diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index 9933e415d5..4c6b52ff07 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-native'; import { ActionButton } from './Actions'; -import { fn } from 'storybook/test'; +import { fn, expect } from 'storybook/test'; +import { addons } from 'storybook/internal/preview-api'; const meta = { component: ActionButton, @@ -25,9 +26,43 @@ export default meta; type Story = StoryObj; +// class NativeEvents { +// channel: Channel; +// constructor() { +// this.channel = addons.getChannel(); +// } +// tap = async (x: number, y: number) => { +// console.log('tap', x, y); +// this.channel.emit('nativeEvent', { +// type: 'tap', +// x, +// y, +// }); +// }; +// } +// const nativeEvents = new NativeEvents(); + export const Basic: Story = { args: { text: 'Press me!', - onPress: fn(), + onPress: fn((e) => { + e.persist(); + }), + }, + play: async ({ args }) => { + await new Promise((resolve) => setTimeout(resolve, 3000)); + const channel = addons.getChannel(); + + channel.emit('nativeEvent', { + type: 'tap', + x: 200, + y: 100, + duration: 0.2, + }); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + expect(args.onPress).toHaveBeenCalled(); + // await nativeEvents.tap(200, 100); }, }; diff --git a/examples/expo-example/components/ActionExample/Actions.tsx b/examples/expo-example/components/ActionExample/Actions.tsx index d3f0dc3b67..4b5d245a33 100644 --- a/examples/expo-example/components/ActionExample/Actions.tsx +++ b/examples/expo-example/components/ActionExample/Actions.tsx @@ -1,13 +1,13 @@ -import { TouchableOpacity, Text, StyleSheet } from 'react-native'; +import { TouchableOpacity, Text, StyleSheet, TouchableOpacityProps } from 'react-native'; export interface ActionButtonProps { - onPress?: () => void; + onPress?: TouchableOpacityProps['onPress']; text: string; } export const ActionButton = ({ onPress, text }: ActionButtonProps) => { return ( - + {text} ); diff --git a/examples/expo-example/metro.config.js b/examples/expo-example/metro.config.js index f05ba5bfa3..c8716ff0e1 100644 --- a/examples/expo-example/metro.config.js +++ b/examples/expo-example/metro.config.js @@ -22,7 +22,14 @@ defaultConfig.resolver.nodeModulesPaths = [ const withStorybook = require('@storybook/react-native/metro/withStorybook'); -module.exports = withStorybook(defaultConfig); +const storybookOptions = { + websockets: { + port: 7007, + host: 'localhost', + }, +}; + +module.exports = withStorybook(defaultConfig, storybookOptions); /* , { enabled: process.env.STORYBOOK_ENABLED === 'true', diff --git a/examples/expo-example/package.json b/examples/expo-example/package.json index 27c81fb416..c436e6bc4a 100644 --- a/examples/expo-example/package.json +++ b/examples/expo-example/package.json @@ -7,7 +7,7 @@ "android": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --android", "ios": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --ios", "web": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --web", - "storybook": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start -c", + "storybook": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start", "storybook:test": "EXPO_PUBLIC_SCREENSHOT_TESTING=true EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start -c", "storybook:web": "storybook dev -p 6006", "build-web-storybook": "storybook build", diff --git a/packages/react-native/src/metro/withStorybook.ts b/packages/react-native/src/metro/withStorybook.ts index 31af82e5b4..43ebd5ed73 100644 --- a/packages/react-native/src/metro/withStorybook.ts +++ b/packages/react-native/src/metro/withStorybook.ts @@ -1,7 +1,8 @@ import * as path from 'path'; import { generate } from '../../scripts/generate'; -import { WebSocketServer, WebSocket, Data } from 'ws'; + import type { MetroConfig } from 'metro-config'; +import { setupWebsocketServer } from 'src/webserver/webserver'; /** * Options for configuring WebSockets used for syncing storybook instances or sending events to storybook. @@ -128,23 +129,7 @@ function withStorybook( const port = websockets.port ?? 7007; const host = websockets.host ?? 'localhost'; - const wss = new WebSocketServer({ port, host }); - - wss.on('connection', function connection(ws: WebSocket) { - console.log('WebSocket connection established'); - - ws.on('error', console.error); - - ws.on('message', function message(data: Data) { - try { - const json = JSON.parse(data.toString()); - - wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(json))); - } catch (error) { - console.error(error); - } - }); - }); + setupWebsocketServer({ port, host }); } generate({ diff --git a/packages/react-native/src/webserver/handle-playfn-event.ts b/packages/react-native/src/webserver/handle-playfn-event.ts new file mode 100644 index 0000000000..6ed5394509 --- /dev/null +++ b/packages/react-native/src/webserver/handle-playfn-event.ts @@ -0,0 +1,20 @@ +import { isTapEventMessage, NativeEventMessage } from './types'; +import { execSync } from 'node:child_process'; + +const tap = ({ duration, udid, x, y }: { x: number; y: number; udid: string; duration: number }) => + `idb ui tap --udid ${udid} --duration ${duration} ${x} ${y}`; + +const describe = (udid: string) => `idb ui describe-all --udid ${udid} --json --nested`; +//0868A689-5B78-4F52-A63A-60CAA848BB88 +export const handlePlayfnEvent = (json: NativeEventMessage, deviceId: string) => { + const event = json.args[0]; + console.log('json', json); + console.log('event', event); + if (isTapEventMessage(event)) { + console.log('tap event', event); + const { x, y, duration = 1 } = event; + const command = tap({ x, y, udid: deviceId, duration }); + console.log(command); + execSync(command); + } +}; diff --git a/packages/react-native/src/webserver/types.ts b/packages/react-native/src/webserver/types.ts new file mode 100644 index 0000000000..f87d40a13a --- /dev/null +++ b/packages/react-native/src/webserver/types.ts @@ -0,0 +1,74 @@ +interface BaseMessage { + type: string; + args: unknown[]; + from: string; +} + +interface BaseNativeEventData { + timestamp: number; + // deviceId?: string; + platform?: 'ios' | 'android'; +} + +interface TapEventData extends BaseNativeEventData { + type: 'tap'; + x: number; + y: number; + duration?: number; +} + +interface SwipeEventData extends BaseNativeEventData { + type: 'swipe'; + startX: number; + startY: number; + endX: number; + endY: number; + duration: number; +} + +interface LongPressEventData extends BaseNativeEventData { + type: 'longPress'; + x: number; + y: number; + duration: number; +} + +interface DoubleTapEventData extends BaseNativeEventData { + type: 'doubleTap'; + x: number; + y: number; +} + +interface ScreenshotEventData extends BaseNativeEventData { + type: 'screenshot'; + base64?: string; + path?: string; +} + +interface OrientationChangeEventData extends BaseNativeEventData { + type: 'orientationChange'; + orientation: 'portrait' | 'landscape'; +} + +export type NativeEventData = + | TapEventData + | SwipeEventData + | LongPressEventData + | DoubleTapEventData + | ScreenshotEventData + | OrientationChangeEventData; + +export interface NativeEventMessage extends BaseMessage { + type: 'nativeEvent'; + args: [NativeEventData]; +} + +export type WebSocketMessage = BaseMessage | NativeEventMessage; + +export const isNativeEventMessage = (message: WebSocketMessage): message is NativeEventMessage => { + return message.type === 'nativeEvent' && message.args.length > 0; +}; + +export const isTapEventMessage = (event: NativeEventData): event is TapEventData => { + return event?.type === 'tap'; +}; diff --git a/packages/react-native/src/webserver/webserver.ts b/packages/react-native/src/webserver/webserver.ts new file mode 100644 index 0000000000..0c552d281a --- /dev/null +++ b/packages/react-native/src/webserver/webserver.ts @@ -0,0 +1,29 @@ +import { WebSocketServer, WebSocket, Data } from 'ws'; +import { isNativeEventMessage, WebSocketMessage } from './types'; +import { handlePlayfnEvent } from './handle-playfn-event'; + +export const setupWebsocketServer = ({ port, host }: { port: number; host: string }) => { + const wss = new WebSocketServer({ port, host }); + + wss.on('connection', function connection(ws: WebSocket) { + console.log('WebSocket connection established'); + + ws.on('error', console.error); + + ws.on('message', function message(data: Data) { + try { + const json = JSON.parse(data.toString()) as WebSocketMessage; + + console.log('event type', json.type); + + if (isNativeEventMessage(json)) { + handlePlayfnEvent(json, '0868A689-5B78-4F52-A63A-60CAA848BB88'); + } else { + wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(json))); + } + } catch (error) { + console.error(error); + } + }); + }); +}; From bd4fcbc8d825df0e3bb6cc19ea52765feb9ff1db Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sun, 15 Jun 2025 16:31:42 +0100 Subject: [PATCH 02/11] fix: use device id from config --- .../ActionExample/Actions.stories.tsx | 68 ++++++++++++------- examples/expo-example/metro.config.js | 1 + packages/react-native/package.json | 16 ++++- .../react-native/src/metro/withStorybook.ts | 8 ++- .../src/webserver/handle-playfn-event.ts | 29 ++++++-- packages/react-native/src/webserver/types.ts | 26 ++++++- .../react-native/src/webserver/webserver.ts | 21 ++++-- packages/react-native/tsup.config.ts | 14 +++- 8 files changed, 143 insertions(+), 40 deletions(-) diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index 4c6b52ff07..855b98e8ba 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-native'; import { ActionButton } from './Actions'; import { fn, expect } from 'storybook/test'; import { addons } from 'storybook/internal/preview-api'; +import Channel from 'storybook/internal/channels'; +import { ServerEventData } from '@storybook/react-native/webserver'; const meta = { component: ActionButton, @@ -26,21 +28,44 @@ export default meta; type Story = StoryObj; -// class NativeEvents { -// channel: Channel; -// constructor() { -// this.channel = addons.getChannel(); -// } -// tap = async (x: number, y: number) => { -// console.log('tap', x, y); -// this.channel.emit('nativeEvent', { -// type: 'tap', -// x, -// y, -// }); -// }; -// } -// const nativeEvents = new NativeEvents(); +class NativeEvents { + channel: Channel; + sessionId: string; + constructor() { + this.sessionId = Date.now().toString(36) + Math.random().toString(36).substring(2); + this.channel = addons.getChannel(); + } + + delay = async (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }; + + tap = async (x: number, y: number) => { + return new Promise(async (resolve) => { + await this.delay(500); + + this.channel.emit('nativeEvent', { + type: 'tap', + x: 200, + y: 100, + duration: 0.2, + sessionId: this.sessionId, + }); + + this.channel.once('serverEvent', async (event: ServerEventData) => { + console.log('serverEvent', event); + + if (event?.type === 'tapCompleted') { + if (event?.success) { + console.log('tap completed'); + await this.delay(200); + resolve(true); + } + } + }); + }); + }; +} export const Basic: Story = { args: { @@ -50,19 +75,10 @@ export const Basic: Story = { }), }, play: async ({ args }) => { - await new Promise((resolve) => setTimeout(resolve, 3000)); - const channel = addons.getChannel(); - - channel.emit('nativeEvent', { - type: 'tap', - x: 200, - y: 100, - duration: 0.2, - }); + const nativeEvents = new NativeEvents(); - await new Promise((resolve) => setTimeout(resolve, 3000)); + await nativeEvents.tap(200, 100); expect(args.onPress).toHaveBeenCalled(); - // await nativeEvents.tap(200, 100); }, }; diff --git a/examples/expo-example/metro.config.js b/examples/expo-example/metro.config.js index c8716ff0e1..bf222b310f 100644 --- a/examples/expo-example/metro.config.js +++ b/examples/expo-example/metro.config.js @@ -26,6 +26,7 @@ const storybookOptions = { websockets: { port: 7007, host: 'localhost', + deviceId: '0868A689-5B78-4F52-A63A-60CAA848BB88', }, }; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 57a52ee5de..934a855363 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -26,7 +26,21 @@ "./metro/withStorybook": "./dist/metro/withStorybook.js", "./preview": "./dist/preview.js", "./scripts/generate": "./scripts/generate.js", - "./preset": "./preset.js" + "./preset": "./preset.js", + "./webserver": "./dist/webserver/webserver.js" + }, + "typesVersions": { + "*": { + "webserver": [ + "./dist/webserver/webserver.d.ts" + ], + "metro/withStorybook": [ + "./dist/metro/withStorybook.d.ts" + ], + "preview": [ + "./dist/preview.d.ts" + ] + } }, "files": [ "bin/**/*", diff --git a/packages/react-native/src/metro/withStorybook.ts b/packages/react-native/src/metro/withStorybook.ts index 43ebd5ed73..b85f963c90 100644 --- a/packages/react-native/src/metro/withStorybook.ts +++ b/packages/react-native/src/metro/withStorybook.ts @@ -17,6 +17,11 @@ interface WebsocketsOptions { * The host WebSocket server will bind to. Defaults to 'localhost'. */ host?: string; + + /** + * The device ID to use for test events over the WebSocket server. + */ + deviceId?: string; } /** @@ -128,8 +133,9 @@ function withStorybook( if (websockets) { const port = websockets.port ?? 7007; const host = websockets.host ?? 'localhost'; + const deviceId = websockets.deviceId; - setupWebsocketServer({ port, host }); + setupWebsocketServer({ port, host, deviceId }); } generate({ diff --git a/packages/react-native/src/webserver/handle-playfn-event.ts b/packages/react-native/src/webserver/handle-playfn-event.ts index 6ed5394509..ed3a6b6776 100644 --- a/packages/react-native/src/webserver/handle-playfn-event.ts +++ b/packages/react-native/src/webserver/handle-playfn-event.ts @@ -1,20 +1,39 @@ -import { isTapEventMessage, NativeEventMessage } from './types'; +import { isTapEventMessage, NativeEventMessage, ServerEventMessage } from './types'; import { execSync } from 'node:child_process'; const tap = ({ duration, udid, x, y }: { x: number; y: number; udid: string; duration: number }) => `idb ui tap --udid ${udid} --duration ${duration} ${x} ${y}`; const describe = (udid: string) => `idb ui describe-all --udid ${udid} --json --nested`; -//0868A689-5B78-4F52-A63A-60CAA848BB88 -export const handlePlayfnEvent = (json: NativeEventMessage, deviceId: string) => { + +export const handlePlayfnEvent = ({ + json, + sendEvent, + deviceId, +}: { + sendEvent: (eventData: ServerEventMessage) => void; + json: NativeEventMessage; + deviceId?: string; +}) => { + if (!deviceId) { + console.warn('No device ID provided'); + return; + } + const event = json.args[0]; - console.log('json', json); - console.log('event', event); + if (isTapEventMessage(event)) { console.log('tap event', event); const { x, y, duration = 1 } = event; const command = tap({ x, y, udid: deviceId, duration }); + console.log(command); execSync(command); + + sendEvent({ + type: 'serverEvent', + args: [{ type: 'tapCompleted', success: true, sessionId: json.args[0].sessionId }], + from: 'playfn', + }); } }; diff --git a/packages/react-native/src/webserver/types.ts b/packages/react-native/src/webserver/types.ts index f87d40a13a..bae7c45ab6 100644 --- a/packages/react-native/src/webserver/types.ts +++ b/packages/react-native/src/webserver/types.ts @@ -8,6 +8,7 @@ interface BaseNativeEventData { timestamp: number; // deviceId?: string; platform?: 'ios' | 'android'; + sessionId: string; } interface TapEventData extends BaseNativeEventData { @@ -63,12 +64,35 @@ export interface NativeEventMessage extends BaseMessage { args: [NativeEventData]; } -export type WebSocketMessage = BaseMessage | NativeEventMessage; +export interface TapCompletedEventData { + type: 'tapCompleted'; + success: boolean; + sessionId: string; +} + +export interface SwipeCompletedEventData { + type: 'swipeCompleted'; + success: boolean; + sessionId: string; +} + +export type ServerEventData = TapCompletedEventData | SwipeCompletedEventData; + +export interface ServerEventMessage extends BaseMessage { + type: 'serverEvent'; + args: [ServerEventData]; +} + +export type WebSocketMessage = BaseMessage | NativeEventMessage | ServerEventMessage; export const isNativeEventMessage = (message: WebSocketMessage): message is NativeEventMessage => { return message.type === 'nativeEvent' && message.args.length > 0; }; +export const isServerEventMessage = (message: WebSocketMessage): message is ServerEventMessage => { + return message.type === 'serverEvent' && message.args.length > 0; +}; + export const isTapEventMessage = (event: NativeEventData): event is TapEventData => { return event?.type === 'tap'; }; diff --git a/packages/react-native/src/webserver/webserver.ts b/packages/react-native/src/webserver/webserver.ts index 0c552d281a..5b297fd545 100644 --- a/packages/react-native/src/webserver/webserver.ts +++ b/packages/react-native/src/webserver/webserver.ts @@ -2,7 +2,15 @@ import { WebSocketServer, WebSocket, Data } from 'ws'; import { isNativeEventMessage, WebSocketMessage } from './types'; import { handlePlayfnEvent } from './handle-playfn-event'; -export const setupWebsocketServer = ({ port, host }: { port: number; host: string }) => { +export const setupWebsocketServer = ({ + port, + host, + deviceId, +}: { + port: number; + host: string; + deviceId?: string; +}) => { const wss = new WebSocketServer({ port, host }); wss.on('connection', function connection(ws: WebSocket) { @@ -14,12 +22,14 @@ export const setupWebsocketServer = ({ port, host }: { port: number; host: strin try { const json = JSON.parse(data.toString()) as WebSocketMessage; - console.log('event type', json.type); + const sendEvent = (eventData) => { + wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(eventData))); + }; if (isNativeEventMessage(json)) { - handlePlayfnEvent(json, '0868A689-5B78-4F52-A63A-60CAA848BB88'); + handlePlayfnEvent({ json, sendEvent, deviceId }); } else { - wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(json))); + sendEvent(json); } } catch (error) { console.error(error); @@ -27,3 +37,6 @@ export const setupWebsocketServer = ({ port, host }: { port: number; host: strin }); }); }; + +export * from './types'; +export * from './handle-playfn-event'; diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts index c273fea392..6e4f21d09f 100644 --- a/packages/react-native/tsup.config.ts +++ b/packages/react-native/tsup.config.ts @@ -2,12 +2,22 @@ import { defineConfig } from 'tsup'; export default defineConfig((options) => { return { - entry: ['src/index.ts', 'src/preview.ts', 'src/metro/withStorybook.ts'], + entry: [ + 'src/index.ts', + 'src/preview.ts', + 'src/metro/withStorybook.ts', + 'src/webserver/webserver.ts', + ], // minify: !options.watch, clean: !options.watch, dts: !options.watch ? { - entry: ['src/index.ts', 'src/preview.ts', 'src/metro/withStorybook.ts'], + entry: [ + 'src/index.ts', + 'src/preview.ts', + 'src/metro/withStorybook.ts', + 'src/webserver/webserver.ts', + ], resolve: true, } : false, From bdd7f80203f7d6d3eb3050881a888dd9044cedff Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sun, 15 Jun 2025 16:36:42 +0100 Subject: [PATCH 03/11] fix: use device id from config --- .../expo-example/components/ActionExample/Actions.stories.tsx | 2 +- packages/react-native/src/metro/withStorybook.ts | 2 +- packages/react-native/src/webserver/handle-playfn-event.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index 855b98e8ba..d213f92db9 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-native'; import { ActionButton } from './Actions'; import { fn, expect } from 'storybook/test'; import { addons } from 'storybook/internal/preview-api'; -import Channel from 'storybook/internal/channels'; +import { Channel } from 'storybook/internal/channels'; import { ServerEventData } from '@storybook/react-native/webserver'; const meta = { diff --git a/packages/react-native/src/metro/withStorybook.ts b/packages/react-native/src/metro/withStorybook.ts index b85f963c90..5ab264c06d 100644 --- a/packages/react-native/src/metro/withStorybook.ts +++ b/packages/react-native/src/metro/withStorybook.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import { generate } from '../../scripts/generate'; import type { MetroConfig } from 'metro-config'; -import { setupWebsocketServer } from 'src/webserver/webserver'; +import { setupWebsocketServer } from '../webserver/webserver'; /** * Options for configuring WebSockets used for syncing storybook instances or sending events to storybook. diff --git a/packages/react-native/src/webserver/handle-playfn-event.ts b/packages/react-native/src/webserver/handle-playfn-event.ts index ed3a6b6776..edf249da52 100644 --- a/packages/react-native/src/webserver/handle-playfn-event.ts +++ b/packages/react-native/src/webserver/handle-playfn-event.ts @@ -4,7 +4,7 @@ import { execSync } from 'node:child_process'; const tap = ({ duration, udid, x, y }: { x: number; y: number; udid: string; duration: number }) => `idb ui tap --udid ${udid} --duration ${duration} ${x} ${y}`; -const describe = (udid: string) => `idb ui describe-all --udid ${udid} --json --nested`; +//const describe = (udid: string) => `idb ui describe-all --udid ${udid} --json --nested`; export const handlePlayfnEvent = ({ json, From 8cdc151516d9415308f56eb08c40ae054c5c744b Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sun, 15 Jun 2025 16:52:44 +0100 Subject: [PATCH 04/11] fix: typepath --- packages/react-native-ui-lite/package.json | 2 +- packages/react-native-ui-lite/src/Layout.tsx | 13 ++++++++----- packages/react-native/package.json | 3 +++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/react-native-ui-lite/package.json b/packages/react-native-ui-lite/package.json index 43c2cb8f8a..736d97acd5 100644 --- a/packages/react-native-ui-lite/package.json +++ b/packages/react-native-ui-lite/package.json @@ -18,7 +18,7 @@ }, "react-native": "src/index.tsx", "main": "dist/index.js", - "types": "src/index.tsx", + "types": "dist/index.d.ts", "license": "MIT", "files": [ "dist/**/*", diff --git a/packages/react-native-ui-lite/src/Layout.tsx b/packages/react-native-ui-lite/src/Layout.tsx index af2a235d38..af87bb0892 100644 --- a/packages/react-native-ui-lite/src/Layout.tsx +++ b/packages/react-native-ui-lite/src/Layout.tsx @@ -21,6 +21,7 @@ import { ViewStyle, } from 'react-native'; import { SET_CURRENT_STORY } from 'storybook/internal/core-events'; +import type { Selection } from '@storybook/react-native-ui-common'; import { addons } from 'storybook/internal/manager-api'; import { type API_IndexHash } from 'storybook/internal/types'; import { AddonsTabs, MobileAddonsPanel, MobileAddonsPanelRef } from './MobileAddonsPanel'; @@ -166,10 +167,12 @@ export const Layout = ({ const mobileMenuDrawerRef = useRef(null); const addonPanelRef = useRef(null); - const setSelection = useCallback(({ storyId: newStoryId }: { storyId: string }) => { - const channel = addons.getChannel(); + const setSelection = useCallback((selection: Selection) => { + if (selection) { + const channel = addons.getChannel(); - channel.emit(SET_CURRENT_STORY, { storyId: newStoryId }); + channel.emit(SET_CURRENT_STORY, { storyId: selection.storyId }); + } }, []); return ( @@ -239,7 +242,7 @@ export const Layout = ({ testID="mobile-menu-button" style={navButtonStyle} hitSlop={navButtonHitSlop} - onPress={() => mobileMenuDrawerRef.current?.setMobileMenuOpen(true)} + onPress={() => mobileMenuDrawerRef.current?.setMobileMenuOpen?.(true)} > @@ -249,7 +252,7 @@ export const Layout = ({ addonPanelRef.current.setAddonsPanelOpen(true)} + onPress={() => addonPanelRef.current?.setAddonsPanelOpen?.(true)} Icon={BottomBarToggleIcon} /> diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 934a855363..2b816f1bc3 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -31,6 +31,9 @@ }, "typesVersions": { "*": { + "*": [ + "./dist/index.d.ts" + ], "webserver": [ "./dist/webserver/webserver.d.ts" ], From a86207c205af533aaa978afa7438563862ee7049 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sun, 15 Jun 2025 17:04:11 +0100 Subject: [PATCH 05/11] temp patch for storybook --- .../storybook-npm-9.0.9-e896aae9bd.patch | 31 ++++++++++++++++++ examples/expo-example/package.json | 2 +- packages/react-native/package.json | 2 +- yarn.lock | 32 +++++++++++++++++-- 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 .yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch diff --git a/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch b/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch new file mode 100644 index 0000000000..51daff8dc4 --- /dev/null +++ b/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch @@ -0,0 +1,31 @@ +diff --git a/dist/preview-api/index.js b/dist/preview-api/index.js +index d7788050671535b3a88d7a3879b70f7eb210988e..6f0ad2d1435dd92a70262425d75ec4a589b70c7c 100644 +--- a/dist/preview-api/index.js ++++ b/dist/preview-api/index.js +@@ -5812,7 +5812,7 @@ var Kr = class Kr { + F.reason && m.add(F.reason); + }, "onUnhandledRejection"); + if (this.renderOptions.autoplay && r && g && this.phase !== "errored") { +- window.addEventListener("error", b), window.addEventListener("unhandledrejection", S), this.disableKeyListeners = !0; ++ window?.addEventListener?.("error", b), window?.addEventListener?.("unhandledrejection", S), this.disableKeyListeners = !0; + try { + if (E ? await g(y) : (y.mount = async () => { + throw new bc({ playFunction: g.toString() }); +@@ -5829,7 +5829,7 @@ var Kr = class Kr { + if (!P && m.size > 0 && this.channel.emit( + gc, + Array.from(m).map(Ci) +- ), this.disableKeyListeners = !1, window.removeEventListener("unhandledrejection", S), window.removeEventListener("error", b), x.aborted) ++ ), this.disableKeyListeners = !1, window?.removeEventListener?.("unhandledrejection", S), window?.removeEventListener?.("error", b), x.aborted) + return; + } + await this.runPhase(x, "completing", async () => { +@@ -5896,7 +5896,7 @@ var Kr = class Kr { + } + await new Promise((r) => setTimeout(r, 0)); + } +- window.location.reload(), await new Promise(() => { ++ window?.location?.reload?.(), await new Promise(() => { + }); + } + }; diff --git a/examples/expo-example/package.json b/examples/expo-example/package.json index c436e6bc4a..ac2e7ba736 100644 --- a/examples/expo-example/package.json +++ b/examples/expo-example/package.json @@ -53,7 +53,7 @@ "react-native-safe-area-context": "5.4.0", "react-native-svg": "15.11.2", "react-native-web": "^0.20.0", - "storybook": "^9.0.9", + "storybook": "patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch", "storybook-addon-deep-controls": "^0.9.2", "ws": "^8.18.0" }, diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 2b816f1bc3..71299f9eee 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -89,7 +89,7 @@ "react": "19.0.0", "react-native": "0.79.2", "react-test-renderer": "^19.1.0", - "storybook": "^9.0.9", + "storybook": "patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch", "tsup": "^7.2.0", "typescript": "~5.8.3" }, diff --git a/yarn.lock b/yarn.lock index e3c7fb3bb0..b25d2ca11e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7596,7 +7596,7 @@ __metadata: react-native-url-polyfill: "npm:^2.0.0" react-test-renderer: "npm:^19.1.0" setimmediate: "npm:^1.0.5" - storybook: "npm:^9.0.9" + storybook: "patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch" tsup: "npm:^7.2.0" type-fest: "npm:~2.19" typescript: "npm:~5.8.3" @@ -14280,7 +14280,7 @@ __metadata: react-native-safe-area-context: "npm:5.4.0" react-native-svg: "npm:15.11.2" react-native-web: "npm:^0.20.0" - storybook: "npm:^9.0.9" + storybook: "patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch" storybook-addon-deep-controls: "npm:^0.9.2" typescript: "npm:~5.8.3" universal-test-renderer: "npm:^0.6.0" @@ -25518,7 +25518,7 @@ __metadata: languageName: node linkType: hard -"storybook@npm:^9.0.9": +"storybook@npm:9.0.9": version: 9.0.9 resolution: "storybook@npm:9.0.9" dependencies: @@ -25544,6 +25544,32 @@ __metadata: languageName: node linkType: hard +"storybook@patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch": + version: 9.0.9 + resolution: "storybook@patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch::version=9.0.9&hash=273ec3" + dependencies: + "@storybook/global": "npm:^5.0.0" + "@testing-library/jest-dom": "npm:^6.6.3" + "@testing-library/user-event": "npm:^14.6.1" + "@vitest/expect": "npm:3.0.9" + "@vitest/spy": "npm:3.0.9" + better-opn: "npm:^3.0.2" + 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" + esbuild-register: "npm:^3.5.0" + recast: "npm:^0.23.5" + semver: "npm:^7.6.2" + ws: "npm:^8.18.0" + peerDependencies: + prettier: ^2 || ^3 + peerDependenciesMeta: + prettier: + optional: true + bin: + storybook: ./bin/index.cjs + checksum: 10/5361149c1c7d53a76f9d75ab9f608f7577bc8ddd57d6b5fb3670b78dc4be3959ef66a78b1673b3d6efd338822d1adcad398123959a800fc9461b11b7b9c8b539 + languageName: node + linkType: hard + "stream-buffers@npm:2.2.x": version: 2.2.0 resolution: "stream-buffers@npm:2.2.0" From 001b767eb436e37274f5cc8053cfabae39e14b3b Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Mon, 16 Jun 2025 13:45:08 +0100 Subject: [PATCH 06/11] fix: make a basic screen get by text --- .../ActionExample/Actions.stories.tsx | 57 ++------- packages/react-native/package.json | 6 +- .../src/webserver/NativeEvents.ts | 114 ++++++++++++++++++ .../src/webserver/handle-playfn-event.ts | 72 ++++++++++- packages/react-native/src/webserver/types.ts | 31 ++++- packages/react-native/tsup.config.ts | 2 + 6 files changed, 231 insertions(+), 51 deletions(-) create mode 100644 packages/react-native/src/webserver/NativeEvents.ts diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index d213f92db9..dc3d578281 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -1,9 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-native'; +import { NativeScreen } from '@storybook/react-native/NativeEvents'; +import { expect, fn } from 'storybook/test'; import { ActionButton } from './Actions'; -import { fn, expect } from 'storybook/test'; -import { addons } from 'storybook/internal/preview-api'; -import { Channel } from 'storybook/internal/channels'; -import { ServerEventData } from '@storybook/react-native/webserver'; const meta = { component: ActionButton, @@ -27,46 +25,9 @@ You use it like this: export default meta; type Story = StoryObj; - -class NativeEvents { - channel: Channel; - sessionId: string; - constructor() { - this.sessionId = Date.now().toString(36) + Math.random().toString(36).substring(2); - this.channel = addons.getChannel(); - } - - delay = async (ms: number) => { - return new Promise((resolve) => setTimeout(resolve, ms)); - }; - - tap = async (x: number, y: number) => { - return new Promise(async (resolve) => { - await this.delay(500); - - this.channel.emit('nativeEvent', { - type: 'tap', - x: 200, - y: 100, - duration: 0.2, - sessionId: this.sessionId, - }); - - this.channel.once('serverEvent', async (event: ServerEventData) => { - console.log('serverEvent', event); - - if (event?.type === 'tapCompleted') { - if (event?.success) { - console.log('tap completed'); - await this.delay(200); - resolve(true); - } - } - }); - }); - }; -} - +const delay = async (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; export const Basic: Story = { args: { text: 'Press me!', @@ -75,9 +36,13 @@ export const Basic: Story = { }), }, play: async ({ args }) => { - const nativeEvents = new NativeEvents(); + const screen = new NativeScreen(); - await nativeEvents.tap(200, 100); + await delay(500); + const button = await screen.getByText('Press me!'); + await delay(500); + await button.tap(0.5); + await delay(500); expect(args.onPress).toHaveBeenCalled(); }, diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 71299f9eee..89dddf87cb 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -27,7 +27,8 @@ "./preview": "./dist/preview.js", "./scripts/generate": "./scripts/generate.js", "./preset": "./preset.js", - "./webserver": "./dist/webserver/webserver.js" + "./webserver": "./dist/webserver/webserver.js", + "./NativeEvents": "./dist/webserver/NativeEvents.js" }, "typesVersions": { "*": { @@ -42,6 +43,9 @@ ], "preview": [ "./dist/preview.d.ts" + ], + "NativeEvents": [ + "./dist/webserver/NativeEvents.d.ts" ] } }, diff --git a/packages/react-native/src/webserver/NativeEvents.ts b/packages/react-native/src/webserver/NativeEvents.ts new file mode 100644 index 0000000000..62d904db3e --- /dev/null +++ b/packages/react-native/src/webserver/NativeEvents.ts @@ -0,0 +1,114 @@ +import { Channel } from 'storybook/internal/channels'; +import { addons } from 'storybook/internal/preview-api'; +import { ElementData, ServerEventData } from './types'; + +export class NativeElement { + channel: Channel; + sessionId: string; + + elementData: ElementData; + center?: { x: number; y: number }; + + constructor(channel: Channel, sessionId: string, elementData: ElementData) { + this.channel = channel; + this.sessionId = sessionId; + this.elementData = elementData; + if (elementData.frame) { + this.center = { + x: Math.round(elementData.frame?.x + elementData.frame?.width / 2), + y: Math.round(elementData.frame?.y + elementData.frame?.height / 2), + }; + } + } + + tap = async (duration?: number) => { + if (!this.center) { + throw new Error('Element has no center'); + } + + return tap({ + x: this.center.x, + y: this.center.y, + channel: this.channel, + sessionId: this.sessionId, + duration: duration, + }); + }; +} + +const tap = ({ + x, + y, + channel, + sessionId, + duration, +}: { + x: number; + y: number; + channel: Channel; + sessionId: string; + duration?: number; +}) => + new Promise(async (resolve) => { + channel.emit('nativeEvent', { + type: 'tap', + x: x, + y: y, + duration: duration || 0.2, + sessionId: sessionId, + }); + + channel.once('serverEvent', async (event: ServerEventData) => { + console.log('serverEvent', event); + + if (event?.type === 'tapCompleted') { + if (event?.success) { + resolve(true); + } + } + }); + }); + +export class NativeScreen { + channel: Channel; + sessionId: string; + + constructor() { + this.sessionId = Date.now().toString(36) + Math.random().toString(36).substring(2); + this.channel = addons.getChannel(); + } + + tap = async (x: number, y: number, duration?: number) => { + return tap({ + x: x, + y: y, + channel: this.channel, + sessionId: this.sessionId, + duration: duration, + }); + }; + + getByText = async (text: string): Promise => { + return new Promise(async (resolve, reject) => { + this.channel.emit('nativeEvent', { + type: 'getByText', + text: text, + sessionId: this.sessionId, + }); + + this.channel.once('serverEvent', async (event: ServerEventData) => { + console.log('serverEvent', event); + + if (event?.type === 'getByTextCompleted') { + if (event?.success) { + console.log('getByText completed'); + + resolve(new NativeElement(this.channel, this.sessionId, event.element)); + } else { + reject(new Error('Failed to get element by text')); + } + } + }); + }); + }; +} diff --git a/packages/react-native/src/webserver/handle-playfn-event.ts b/packages/react-native/src/webserver/handle-playfn-event.ts index edf249da52..305bdeb747 100644 --- a/packages/react-native/src/webserver/handle-playfn-event.ts +++ b/packages/react-native/src/webserver/handle-playfn-event.ts @@ -1,10 +1,38 @@ -import { isTapEventMessage, NativeEventMessage, ServerEventMessage } from './types'; +import { + isGetByTextEventMessage, + isTapEventMessage, + NativeEventMessage, + ServerEventMessage, +} from './types'; import { execSync } from 'node:child_process'; const tap = ({ duration, udid, x, y }: { x: number; y: number; udid: string; duration: number }) => `idb ui tap --udid ${udid} --duration ${duration} ${x} ${y}`; -//const describe = (udid: string) => `idb ui describe-all --udid ${udid} --json --nested`; +const describe = (udid: string) => `idb ui describe-all --udid ${udid} --json --nested`; + +export type AXElement = { + AXFrame: string; + AXUniqueId: string | null; + frame: { + y: number; + x: number; + width: number; + height: number; + }; + role_description: string; + AXLabel: string | null; + content_required: boolean; + type: string; + title: string | null; + help: string | null; + custom_actions: any[]; // You can type this more strictly if you know the structure + AXValue: any; // Use a more specific type if possible + enabled: boolean; + role: string; + children: AXElement[]; + subrole: string | null; +}; export const handlePlayfnEvent = ({ json, @@ -36,4 +64,44 @@ export const handlePlayfnEvent = ({ from: 'playfn', }); } + if (isGetByTextEventMessage(event)) { + console.log('getByText event', event); + const { text } = event; + const command = describe(deviceId); + + const result = execSync(command); + + const json = JSON.parse(result.toString()); + + const elements = findElementsByAXLabel(json, text); + console.log('elements', elements); + + sendEvent({ + type: 'serverEvent', + args: [ + { + type: 'getByTextCompleted', + success: true, + sessionId: event.sessionId, + element: elements[0], + }, + ], + from: 'playfn', + }); + } }; + +export function findElementsByAXLabel(elements: AXElement[], text: string): AXElement[] { + const matches: AXElement[] = []; + + for (const el of elements) { + if (el.AXLabel === text) { + matches.push(el); + } + if (el.children && el.children.length > 0) { + matches.push(...findElementsByAXLabel(el.children, text)); + } + } + + return matches; +} diff --git a/packages/react-native/src/webserver/types.ts b/packages/react-native/src/webserver/types.ts index bae7c45ab6..706e46cfce 100644 --- a/packages/react-native/src/webserver/types.ts +++ b/packages/react-native/src/webserver/types.ts @@ -51,13 +51,19 @@ interface OrientationChangeEventData extends BaseNativeEventData { orientation: 'portrait' | 'landscape'; } +interface GetByTextEventData extends BaseNativeEventData { + type: 'getByText'; + text: string; +} + export type NativeEventData = | TapEventData | SwipeEventData | LongPressEventData | DoubleTapEventData | ScreenshotEventData - | OrientationChangeEventData; + | OrientationChangeEventData + | GetByTextEventData; export interface NativeEventMessage extends BaseMessage { type: 'nativeEvent'; @@ -76,7 +82,24 @@ export interface SwipeCompletedEventData { sessionId: string; } -export type ServerEventData = TapCompletedEventData | SwipeCompletedEventData; +export type ElementData = { + frame?: { x: number; y: number; width: number; height: number }; + role?: string; + type?: string; + label?: string; +}; + +export interface GetByTextCompletedEventData { + type: 'getByTextCompleted'; + success: boolean; + sessionId: string; + element: ElementData; +} + +export type ServerEventData = + | TapCompletedEventData + | SwipeCompletedEventData + | GetByTextCompletedEventData; export interface ServerEventMessage extends BaseMessage { type: 'serverEvent'; @@ -96,3 +119,7 @@ export const isServerEventMessage = (message: WebSocketMessage): message is Serv export const isTapEventMessage = (event: NativeEventData): event is TapEventData => { return event?.type === 'tap'; }; + +export const isGetByTextEventMessage = (event: NativeEventData): event is GetByTextEventData => { + return event?.type === 'getByText'; +}; diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts index 6e4f21d09f..4bb786b77b 100644 --- a/packages/react-native/tsup.config.ts +++ b/packages/react-native/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig((options) => { 'src/preview.ts', 'src/metro/withStorybook.ts', 'src/webserver/webserver.ts', + 'src/webserver/NativeEvents.ts', ], // minify: !options.watch, clean: !options.watch, @@ -17,6 +18,7 @@ export default defineConfig((options) => { 'src/preview.ts', 'src/metro/withStorybook.ts', 'src/webserver/webserver.ts', + 'src/webserver/NativeEvents.ts', ], resolve: true, } From 12fc8aa70b87fd81d2dcc02f934fe68022c16cbf Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Mon, 16 Jun 2025 13:51:14 +0100 Subject: [PATCH 07/11] fix: remove delay --- .../components/ActionExample/Actions.stories.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index dc3d578281..0227f80558 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -25,9 +25,7 @@ You use it like this: export default meta; type Story = StoryObj; -const delay = async (ms: number) => { - return new Promise((resolve) => setTimeout(resolve, ms)); -}; + export const Basic: Story = { args: { text: 'Press me!', @@ -38,11 +36,8 @@ export const Basic: Story = { play: async ({ args }) => { const screen = new NativeScreen(); - await delay(500); const button = await screen.getByText('Press me!'); - await delay(500); await button.tap(0.5); - await delay(500); expect(args.onPress).toHaveBeenCalled(); }, From 3faf7ae8fa2fd8680f94b5e149480581dfb1ae51 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Thu, 19 Jun 2025 20:25:49 +0100 Subject: [PATCH 08/11] remove patch --- .../storybook-npm-9.0.9-e896aae9bd.patch | 31 ------------------- examples/expo-example/package.json | 2 +- packages/react-native/package.json | 2 +- 3 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 .yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch diff --git a/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch b/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch deleted file mode 100644 index 51daff8dc4..0000000000 --- a/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/dist/preview-api/index.js b/dist/preview-api/index.js -index d7788050671535b3a88d7a3879b70f7eb210988e..6f0ad2d1435dd92a70262425d75ec4a589b70c7c 100644 ---- a/dist/preview-api/index.js -+++ b/dist/preview-api/index.js -@@ -5812,7 +5812,7 @@ var Kr = class Kr { - F.reason && m.add(F.reason); - }, "onUnhandledRejection"); - if (this.renderOptions.autoplay && r && g && this.phase !== "errored") { -- window.addEventListener("error", b), window.addEventListener("unhandledrejection", S), this.disableKeyListeners = !0; -+ window?.addEventListener?.("error", b), window?.addEventListener?.("unhandledrejection", S), this.disableKeyListeners = !0; - try { - if (E ? await g(y) : (y.mount = async () => { - throw new bc({ playFunction: g.toString() }); -@@ -5829,7 +5829,7 @@ var Kr = class Kr { - if (!P && m.size > 0 && this.channel.emit( - gc, - Array.from(m).map(Ci) -- ), this.disableKeyListeners = !1, window.removeEventListener("unhandledrejection", S), window.removeEventListener("error", b), x.aborted) -+ ), this.disableKeyListeners = !1, window?.removeEventListener?.("unhandledrejection", S), window?.removeEventListener?.("error", b), x.aborted) - return; - } - await this.runPhase(x, "completing", async () => { -@@ -5896,7 +5896,7 @@ var Kr = class Kr { - } - await new Promise((r) => setTimeout(r, 0)); - } -- window.location.reload(), await new Promise(() => { -+ window?.location?.reload?.(), await new Promise(() => { - }); - } - }; diff --git a/examples/expo-example/package.json b/examples/expo-example/package.json index ac2e7ba736..1815bcc36c 100644 --- a/examples/expo-example/package.json +++ b/examples/expo-example/package.json @@ -53,7 +53,7 @@ "react-native-safe-area-context": "5.4.0", "react-native-svg": "15.11.2", "react-native-web": "^0.20.0", - "storybook": "patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch", + "storybook": "^9.0.12", "storybook-addon-deep-controls": "^0.9.2", "ws": "^8.18.0" }, diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 89dddf87cb..958d9b51b4 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -93,7 +93,7 @@ "react": "19.0.0", "react-native": "0.79.2", "react-test-renderer": "^19.1.0", - "storybook": "patch:storybook@npm%3A9.0.9#~/.yarn/patches/storybook-npm-9.0.9-e896aae9bd.patch", + "storybook": "^9.0.12", "tsup": "^7.2.0", "typescript": "~5.8.3" }, From d5a3bff2a294e2a94aad8bf0df8080f05df51be6 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Thu, 19 Jun 2025 20:31:33 +0100 Subject: [PATCH 09/11] re-enable websockets --- examples/expo-example/.rnstorybook/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/expo-example/.rnstorybook/index.tsx b/examples/expo-example/.rnstorybook/index.tsx index 8d591adc51..dc0e333313 100644 --- a/examples/expo-example/.rnstorybook/index.tsx +++ b/examples/expo-example/.rnstorybook/index.tsx @@ -12,7 +12,7 @@ const StorybookUIRoot = view.getStorybookUI({ getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, - enableWebsockets: false, + enableWebsockets: true, // onDeviceUI: !isScreenshotTesting, host: 'localhost', port: 7007, From 4146c87ed93573f286365eb2587c00ae7255a465 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Thu, 19 Jun 2025 20:59:45 +0100 Subject: [PATCH 10/11] feat: fill in form --- .../LoginForm/LoginForm.stories.tsx | 37 +++++++++- .../LoginDocsExample/TextInput/TextInput.tsx | 1 + .../src/webserver/NativeEvents.ts | 67 ++++++++++++++++++ .../src/webserver/handle-playfn-event.ts | 68 +++++++++++++++++++ packages/react-native/src/webserver/types.ts | 39 ++++++++++- 5 files changed, 209 insertions(+), 3 deletions(-) diff --git a/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx b/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx index 0bc28e60d8..9b64c95bca 100644 --- a/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx +++ b/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-native'; -import { fn } from 'storybook/test'; +import { NativeScreen } from '@storybook/react-native/NativeEvents'; +import { expect, fn } from 'storybook/test'; import { LoginForm } from './LoginForm'; const meta = { @@ -47,3 +48,37 @@ export const LongErrors: Story = { 'Your password must be at least 8 characters long and contain both letters and numbers for security.', }, }; + +export const InteractiveLogin: Story = { + args: { + onSubmit: fn(), + }, + play: async ({ args }) => { + const screen = new NativeScreen(); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Find and tap the email input field by placeholder + const emailInput = await screen.getByPlaceholder('Enter your email'); + await emailInput.tap(); + + // Type email address + await emailInput.type('user@example.com'); + + // Find and tap the password input field by placeholder + const passwordInput = await screen.getByPlaceholder('Enter your password'); + await passwordInput.tap(); + + // Type password + await passwordInput.type('securePassword123'); + + // Find and tap the sign in button by text + const signInButton = await screen.getByText('Sign In'); + await signInButton.tap(); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Verify that onSubmit was called with the correct arguments + expect(args.onSubmit).toHaveBeenCalledWith('user@example.com', 'securePassword123'); + }, +}; diff --git a/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.tsx b/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.tsx index 265de105d5..159da1031f 100644 --- a/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.tsx +++ b/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.tsx @@ -27,6 +27,7 @@ export const TextInput: React.FC = ({ value={value} onChangeText={onChangeText} secureTextEntry={secureTextEntry} + autoCapitalize="none" /> {error && {error}} diff --git a/packages/react-native/src/webserver/NativeEvents.ts b/packages/react-native/src/webserver/NativeEvents.ts index 62d904db3e..439670cde0 100644 --- a/packages/react-native/src/webserver/NativeEvents.ts +++ b/packages/react-native/src/webserver/NativeEvents.ts @@ -34,6 +34,14 @@ export class NativeElement { duration: duration, }); }; + + type = async (text: string) => { + return typeText({ + text: text, + channel: this.channel, + sessionId: this.sessionId, + }); + }; } const tap = ({ @@ -69,6 +77,33 @@ const tap = ({ }); }); +const typeText = ({ + text, + channel, + sessionId, +}: { + text: string; + channel: Channel; + sessionId: string; +}) => + new Promise(async (resolve) => { + channel.emit('nativeEvent', { + type: 'typeText', + text: text, + sessionId: sessionId, + }); + + channel.once('serverEvent', async (event: ServerEventData) => { + console.log('serverEvent', event); + + if (event?.type === 'typeTextCompleted') { + if (event?.success) { + resolve(true); + } + } + }); + }); + export class NativeScreen { channel: Channel; sessionId: string; @@ -88,6 +123,14 @@ export class NativeScreen { }); }; + type = async (text: string) => { + return typeText({ + text: text, + channel: this.channel, + sessionId: this.sessionId, + }); + }; + getByText = async (text: string): Promise => { return new Promise(async (resolve, reject) => { this.channel.emit('nativeEvent', { @@ -111,4 +154,28 @@ export class NativeScreen { }); }); }; + + getByPlaceholder = async (placeholder: string): Promise => { + return new Promise(async (resolve, reject) => { + this.channel.emit('nativeEvent', { + type: 'getByPlaceholder', + placeholder: placeholder, + sessionId: this.sessionId, + }); + + this.channel.once('serverEvent', async (event: ServerEventData) => { + console.log('serverEvent', event); + + if (event?.type === 'getByPlaceholderCompleted') { + if (event?.success) { + console.log('getByPlaceholder completed'); + + resolve(new NativeElement(this.channel, this.sessionId, event.element)); + } else { + reject(new Error('Failed to get element by placeholder')); + } + } + }); + }); + }; } diff --git a/packages/react-native/src/webserver/handle-playfn-event.ts b/packages/react-native/src/webserver/handle-playfn-event.ts index 305bdeb747..feafb39e99 100644 --- a/packages/react-native/src/webserver/handle-playfn-event.ts +++ b/packages/react-native/src/webserver/handle-playfn-event.ts @@ -1,6 +1,8 @@ import { isGetByTextEventMessage, isTapEventMessage, + isTypeTextEventMessage, + isGetByPlaceholderEventMessage, NativeEventMessage, ServerEventMessage, } from './types'; @@ -11,6 +13,9 @@ const tap = ({ duration, udid, x, y }: { x: number; y: number; udid: string; dur const describe = (udid: string) => `idb ui describe-all --udid ${udid} --json --nested`; +const typeText = ({ text, udid }: { text: string; udid: string }) => + `idb ui text --udid ${udid} ${text}`; + export type AXElement = { AXFrame: string; AXUniqueId: string | null; @@ -64,9 +69,12 @@ export const handlePlayfnEvent = ({ from: 'playfn', }); } + if (isGetByTextEventMessage(event)) { console.log('getByText event', event); + const { text } = event; + const command = describe(deviceId); const result = execSync(command); @@ -89,6 +97,50 @@ export const handlePlayfnEvent = ({ from: 'playfn', }); } + + if (isTypeTextEventMessage(event)) { + console.log('typeText event', event); + + const { text } = event; + + const command = typeText({ text, udid: deviceId }); + + console.log(command); + + execSync(command); + + sendEvent({ + type: 'serverEvent', + args: [{ type: 'typeTextCompleted', success: true, sessionId: json.args[0].sessionId }], + from: 'playfn', + }); + } + + if (isGetByPlaceholderEventMessage(event)) { + console.log('getByPlaceholder event', event); + const { placeholder } = event; + const command = describe(deviceId); + + const result = execSync(command); + + const json = JSON.parse(result.toString()); + + const elements = findElementsByAXValue(json, placeholder); + console.log('elements', elements); + + sendEvent({ + type: 'serverEvent', + args: [ + { + type: 'getByPlaceholderCompleted', + success: true, + sessionId: event.sessionId, + element: elements[0], + }, + ], + from: 'playfn', + }); + } }; export function findElementsByAXLabel(elements: AXElement[], text: string): AXElement[] { @@ -105,3 +157,19 @@ export function findElementsByAXLabel(elements: AXElement[], text: string): AXEl return matches; } + +export function findElementsByAXValue(elements: AXElement[], value: string): AXElement[] { + const matches: AXElement[] = []; + + for (const el of elements) { + if (el.AXValue === value) { + matches.push(el); + } + + if (el.children && el.children.length > 0) { + matches.push(...findElementsByAXValue(el.children, value)); + } + } + + return matches; +} diff --git a/packages/react-native/src/webserver/types.ts b/packages/react-native/src/webserver/types.ts index 706e46cfce..9c9b4e8ca7 100644 --- a/packages/react-native/src/webserver/types.ts +++ b/packages/react-native/src/webserver/types.ts @@ -56,6 +56,16 @@ interface GetByTextEventData extends BaseNativeEventData { text: string; } +interface TypeTextEventData extends BaseNativeEventData { + type: 'typeText'; + text: string; +} + +interface GetByPlaceholderEventData extends BaseNativeEventData { + type: 'getByPlaceholder'; + placeholder: string; +} + export type NativeEventData = | TapEventData | SwipeEventData @@ -63,7 +73,9 @@ export type NativeEventData = | DoubleTapEventData | ScreenshotEventData | OrientationChangeEventData - | GetByTextEventData; + | GetByTextEventData + | TypeTextEventData + | GetByPlaceholderEventData; export interface NativeEventMessage extends BaseMessage { type: 'nativeEvent'; @@ -96,10 +108,25 @@ export interface GetByTextCompletedEventData { element: ElementData; } +export interface TypeTextCompletedEventData { + type: 'typeTextCompleted'; + success: boolean; + sessionId: string; +} + +export interface GetByPlaceholderCompletedEventData { + type: 'getByPlaceholderCompleted'; + success: boolean; + sessionId: string; + element: ElementData; +} + export type ServerEventData = | TapCompletedEventData | SwipeCompletedEventData - | GetByTextCompletedEventData; + | GetByTextCompletedEventData + | TypeTextCompletedEventData + | GetByPlaceholderCompletedEventData; export interface ServerEventMessage extends BaseMessage { type: 'serverEvent'; @@ -123,3 +150,11 @@ export const isTapEventMessage = (event: NativeEventData): event is TapEventData export const isGetByTextEventMessage = (event: NativeEventData): event is GetByTextEventData => { return event?.type === 'getByText'; }; + +export const isTypeTextEventMessage = (event: NativeEventData): event is TypeTextEventData => { + return event?.type === 'typeText'; +}; + +export const isGetByPlaceholderEventMessage = (event: NativeEventData): event is GetByPlaceholderEventData => { + return event?.type === 'getByPlaceholder'; +}; From fa70c17fcf722c037408c30ca75f5b2ff6b22b6e Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Thu, 19 Jun 2025 21:09:28 +0100 Subject: [PATCH 11/11] fix: formatting --- packages/react-native/src/webserver/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native/src/webserver/types.ts b/packages/react-native/src/webserver/types.ts index 9c9b4e8ca7..0a4e5130a6 100644 --- a/packages/react-native/src/webserver/types.ts +++ b/packages/react-native/src/webserver/types.ts @@ -155,6 +155,8 @@ export const isTypeTextEventMessage = (event: NativeEventData): event is TypeTex return event?.type === 'typeText'; }; -export const isGetByPlaceholderEventMessage = (event: NativeEventData): event is GetByPlaceholderEventData => { +export const isGetByPlaceholderEventMessage = ( + event: NativeEventData +): event is GetByPlaceholderEventData => { return event?.type === 'getByPlaceholder'; };