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 (
-
-
-
- {children}
-
-
- );
- }
- : undefined,
+ // CustomUIComponent: isScreenshotTesting
+ // ? ({ children, story }) => {
+ // return (
+ //
+ //
+ //
+ // {children}
+ //
+ //
+ // );
+ // }
+ // : 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,