diff --git a/examples/expo-example/.rnstorybook/index.tsx b/examples/expo-example/.rnstorybook/index.tsx index bf2646c8ea..dc0e333313 100644 --- a/examples/expo-example/.rnstorybook/index.tsx +++ b/examples/expo-example/.rnstorybook/index.tsx @@ -1,10 +1,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; // import { LiteUI } from '@storybook/react-native-ui-lite'; -import { SafeAreaView, StatusBar, View } from 'react-native'; +// import { SafeAreaView, StatusBar, View } from 'react-native'; import { view } from './storybook.requires'; -const isScreenshotTesting = process.env.EXPO_PUBLIC_SCREENSHOT_TESTING === 'true'; +// const isScreenshotTesting = process.env.EXPO_PUBLIC_SCREENSHOT_TESTING === 'true'; const StorybookUIRoot = view.getStorybookUI({ shouldPersistSelection: true, @@ -12,23 +12,24 @@ const StorybookUIRoot = view.getStorybookUI({ getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, - enableWebsockets: false, + enableWebsockets: true, + // onDeviceUI: !isScreenshotTesting, host: 'localhost', port: 7007, // CustomUIComponent: LiteUI, - CustomUIComponent: isScreenshotTesting - ? ({ children, story }) => { - return ( - - - ); - } - : undefined, + // CustomUIComponent: isScreenshotTesting + // ? ({ children, story }) => { + // return ( + // + // + // ); + // } + // : undefined, }); export default StorybookUIRoot; 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..0227f80558 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 { NativeScreen } from '@storybook/react-native/NativeEvents'; +import { expect, fn } from 'storybook/test'; import { ActionButton } from './Actions'; -import { fn } from 'storybook/test'; const meta = { component: ActionButton, @@ -28,6 +29,16 @@ type Story = StoryObj; export const Basic: Story = { args: { text: 'Press me!', - onPress: fn(), + onPress: fn((e) => { + e.persist(); + }), + }, + play: async ({ args }) => { + const screen = new NativeScreen(); + + const button = await screen.getByText('Press me!'); + await button.tap(0.5); + + expect(args.onPress).toHaveBeenCalled(); }, }; 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/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/examples/expo-example/metro.config.js b/examples/expo-example/metro.config.js index f05ba5bfa3..bf222b310f 100644 --- a/examples/expo-example/metro.config.js +++ b/examples/expo-example/metro.config.js @@ -22,7 +22,15 @@ defaultConfig.resolver.nodeModulesPaths = [ const withStorybook = require('@storybook/react-native/metro/withStorybook'); -module.exports = withStorybook(defaultConfig); +const storybookOptions = { + websockets: { + port: 7007, + host: 'localhost', + deviceId: '0868A689-5B78-4F52-A63A-60CAA848BB88', + }, +}; + +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 d4e70c9103..c9844ce437 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-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 5c25665d88..15c2bcb2f7 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -26,7 +26,28 @@ "./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", + "./NativeEvents": "./dist/webserver/NativeEvents.js" + }, + "typesVersions": { + "*": { + "*": [ + "./dist/index.d.ts" + ], + "webserver": [ + "./dist/webserver/webserver.d.ts" + ], + "metro/withStorybook": [ + "./dist/metro/withStorybook.d.ts" + ], + "preview": [ + "./dist/preview.d.ts" + ], + "NativeEvents": [ + "./dist/webserver/NativeEvents.d.ts" + ] + } }, "files": [ "bin/**/*", diff --git a/packages/react-native/src/metro/withStorybook.ts b/packages/react-native/src/metro/withStorybook.ts index 31af82e5b4..5ab264c06d 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 '../webserver/webserver'; /** * Options for configuring WebSockets used for syncing storybook instances or sending events to storybook. @@ -16,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; } /** @@ -127,24 +133,9 @@ function withStorybook( if (websockets) { const port = websockets.port ?? 7007; const host = websockets.host ?? 'localhost'; + const deviceId = websockets.deviceId; - 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, deviceId }); } generate({ diff --git a/packages/react-native/src/webserver/NativeEvents.ts b/packages/react-native/src/webserver/NativeEvents.ts new file mode 100644 index 0000000000..439670cde0 --- /dev/null +++ b/packages/react-native/src/webserver/NativeEvents.ts @@ -0,0 +1,181 @@ +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, + }); + }; + + type = async (text: string) => { + return typeText({ + text: text, + channel: this.channel, + sessionId: this.sessionId, + }); + }; +} + +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); + } + } + }); + }); + +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; + + 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, + }); + }; + + 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', { + 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')); + } + } + }); + }); + }; + + 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 new file mode 100644 index 0000000000..feafb39e99 --- /dev/null +++ b/packages/react-native/src/webserver/handle-playfn-event.ts @@ -0,0 +1,175 @@ +import { + isGetByTextEventMessage, + isTapEventMessage, + isTypeTextEventMessage, + isGetByPlaceholderEventMessage, + 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 typeText = ({ text, udid }: { text: string; udid: string }) => + `idb ui text --udid ${udid} ${text}`; + +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, + sendEvent, + deviceId, +}: { + sendEvent: (eventData: ServerEventMessage) => void; + json: NativeEventMessage; + deviceId?: string; +}) => { + if (!deviceId) { + console.warn('No device ID provided'); + return; + } + + const event = json.args[0]; + + 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', + }); + } + + 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', + }); + } + + 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[] { + 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; +} + +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 new file mode 100644 index 0000000000..0a4e5130a6 --- /dev/null +++ b/packages/react-native/src/webserver/types.ts @@ -0,0 +1,162 @@ +interface BaseMessage { + type: string; + args: unknown[]; + from: string; +} + +interface BaseNativeEventData { + timestamp: number; + // deviceId?: string; + platform?: 'ios' | 'android'; + sessionId: string; +} + +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'; +} + +interface GetByTextEventData extends BaseNativeEventData { + type: 'getByText'; + text: string; +} + +interface TypeTextEventData extends BaseNativeEventData { + type: 'typeText'; + text: string; +} + +interface GetByPlaceholderEventData extends BaseNativeEventData { + type: 'getByPlaceholder'; + placeholder: string; +} + +export type NativeEventData = + | TapEventData + | SwipeEventData + | LongPressEventData + | DoubleTapEventData + | ScreenshotEventData + | OrientationChangeEventData + | GetByTextEventData + | TypeTextEventData + | GetByPlaceholderEventData; + +export interface NativeEventMessage extends BaseMessage { + type: 'nativeEvent'; + args: [NativeEventData]; +} + +export interface TapCompletedEventData { + type: 'tapCompleted'; + success: boolean; + sessionId: string; +} + +export interface SwipeCompletedEventData { + type: 'swipeCompleted'; + success: boolean; + sessionId: string; +} + +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 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 + | TypeTextCompletedEventData + | GetByPlaceholderCompletedEventData; + +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'; +}; + +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'; +}; diff --git a/packages/react-native/src/webserver/webserver.ts b/packages/react-native/src/webserver/webserver.ts new file mode 100644 index 0000000000..5b297fd545 --- /dev/null +++ b/packages/react-native/src/webserver/webserver.ts @@ -0,0 +1,42 @@ +import { WebSocketServer, WebSocket, Data } from 'ws'; +import { isNativeEventMessage, WebSocketMessage } from './types'; +import { handlePlayfnEvent } from './handle-playfn-event'; + +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) { + 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; + + const sendEvent = (eventData) => { + wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(eventData))); + }; + + if (isNativeEventMessage(json)) { + handlePlayfnEvent({ json, sendEvent, deviceId }); + } else { + sendEvent(json); + } + } catch (error) { + console.error(error); + } + }); + }); +}; + +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..4bb786b77b 100644 --- a/packages/react-native/tsup.config.ts +++ b/packages/react-native/tsup.config.ts @@ -2,12 +2,24 @@ 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', + 'src/webserver/NativeEvents.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', + 'src/webserver/NativeEvents.ts', + ], resolve: true, } : false,