Skip to content

Commit 147871f

Browse files
committed
feat: initial impl
1 parent 9aa6254 commit 147871f

23 files changed

Lines changed: 641 additions & 11 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { View, Text } from 'react-native';
2+
import {
3+
describe,
4+
test,
5+
expect,
6+
render,
7+
screen,
8+
userEvent,
9+
} from 'react-native-harness';
10+
11+
describe('Queries', () => {
12+
test('should find element by testID', async () => {
13+
await render(
14+
<View>
15+
<View testID="this-is-test-id">
16+
<Text>This is a view with a testID</Text>
17+
</View>
18+
</View>
19+
);
20+
const element = await screen.findByTestId('this-is-test-id');
21+
expect(element).toBeDefined();
22+
expect(element.id).toBeDefined();
23+
});
24+
25+
test('should find all elements by testID', async () => {
26+
await render(
27+
<View>
28+
<View testID="this-is-test-id">
29+
<Text>First element</Text>
30+
</View>
31+
<View testID="this-is-test-id">
32+
<Text>Second element</Text>
33+
</View>
34+
</View>
35+
);
36+
const elements = await screen.findAllByTestId('this-is-test-id');
37+
expect(elements).toBeDefined();
38+
expect(Array.isArray(elements)).toBe(true);
39+
expect(elements.length).toBe(2);
40+
});
41+
42+
test('should tap element found by testID', async () => {
43+
await render(
44+
<View>
45+
<View testID="this-is-test-id">
46+
<Text>This is a view with a testID</Text>
47+
</View>
48+
</View>
49+
);
50+
const element = await screen.findByTestId('this-is-test-id');
51+
await userEvent.tap(element);
52+
// If tap succeeds without throwing, the test passes
53+
expect(element).toBeDefined();
54+
});
55+
});

packages/bridge/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
}
2828
},
2929
"dependencies": {
30+
"@react-native-harness/platforms": "workspace:*",
3031
"@react-native-harness/tools": "workspace:*",
3132
"birpc": "^2.4.0",
3233
"tslib": "^2.3.0",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type {
2+
ElementReference,
3+
HarnessPlatformRunner,
4+
} from '@react-native-harness/platforms';
5+
import type { BridgeServerFunctions } from './shared.js';
6+
7+
export const createPlatformBridgeFunctions = (
8+
platformRunner: HarnessPlatformRunner
9+
): Partial<BridgeServerFunctions> => {
10+
return {
11+
'platform.actions.tap': async (x: number, y: number) => {
12+
await platformRunner.actions.tap(x, y);
13+
},
14+
'platform.actions.inputText': async (text: string) => {
15+
await platformRunner.actions.inputText(text);
16+
},
17+
'platform.actions.tapElement': async (element: ElementReference) => {
18+
await platformRunner.actions.tapElement(element);
19+
},
20+
'platform.queries.getUiHierarchy': async () => {
21+
return await platformRunner.queries.getUiHierarchy();
22+
},
23+
'platform.queries.findByTestId': async (testId: string) => {
24+
return await platformRunner.queries.findByTestId(testId);
25+
},
26+
'platform.queries.findAllByTestId': async (testId: string) => {
27+
return await platformRunner.queries.findAllByTestId(testId);
28+
},
29+
};
30+
};

packages/bridge/src/server.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type {
1010
} from './shared.js';
1111
import { deserialize, serialize } from './serializer.js';
1212
import { DeviceNotRespondingError } from './errors.js';
13+
import { createPlatformBridgeFunctions } from './platform-bridge.js';
14+
import type { HarnessPlatformRunner } from '@react-native-harness/platforms';
1315

1416
export type BridgeServerOptions = {
1517
port: number;
@@ -36,6 +38,7 @@ export type BridgeServer = {
3638
event: T,
3739
listener: BridgeServerEvents[T]
3840
) => void;
41+
updatePlatformFunctions: (platformRunner: HarnessPlatformRunner) => void;
3942
dispose: () => void;
4043
};
4144

@@ -51,15 +54,35 @@ export const getBridgeServer = async ({
5154
const emitter = new EventEmitter();
5255
const clients = new Set<WebSocket>();
5356

57+
const baseFunctions: BridgeServerFunctions = {
58+
reportReady: (device) => {
59+
emitter.emit('ready', device);
60+
},
61+
emitEvent: (_, data) => {
62+
emitter.emit('event', data);
63+
},
64+
'platform.actions.tap': async () => {
65+
throw new Error('Platform functions not initialized');
66+
},
67+
'platform.actions.inputText': async () => {
68+
throw new Error('Platform functions not initialized');
69+
},
70+
'platform.actions.tapElement': async () => {
71+
throw new Error('Platform functions not initialized');
72+
},
73+
'platform.queries.getUiHierarchy': async () => {
74+
throw new Error('Platform functions not initialized');
75+
},
76+
'platform.queries.findByTestId': async () => {
77+
throw new Error('Platform functions not initialized');
78+
},
79+
'platform.queries.findAllByTestId': async () => {
80+
throw new Error('Platform functions not initialized');
81+
},
82+
};
83+
5484
const group = createBirpcGroup<BridgeClientFunctions, BridgeServerFunctions>(
55-
{
56-
reportReady: (device) => {
57-
emitter.emit('ready', device);
58-
},
59-
emitEvent: (_, data) => {
60-
emitter.emit('event', data);
61-
},
62-
} satisfies BridgeServerFunctions,
85+
baseFunctions,
6386
[],
6487
{
6588
timeout,
@@ -69,6 +92,13 @@ export const getBridgeServer = async ({
6992
}
7093
);
7194

95+
const updatePlatformFunctions = (
96+
platformRunner: HarnessPlatformRunner
97+
): void => {
98+
const platformFunctions = createPlatformBridgeFunctions(platformRunner);
99+
Object.assign(baseFunctions, platformFunctions);
100+
};
101+
72102
wss.on('connection', (ws: WebSocket) => {
73103
logger.debug('Client connected to the bridge');
74104
ws.on('close', () => {
@@ -104,6 +134,7 @@ export const getBridgeServer = async ({
104134
on: emitter.on.bind(emitter),
105135
once: emitter.once.bind(emitter),
106136
off: emitter.off.bind(emitter),
137+
updatePlatformFunctions,
107138
dispose,
108139
};
109140
};

packages/bridge/src/shared.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ import type {
44
} from './shared/test-runner.js';
55
import type { TestCollectorEvents } from './shared/test-collector.js';
66
import type { BundlerEvents } from './shared/bundler.js';
7+
import type {
8+
UIElement,
9+
ElementReference,
10+
} from '@react-native-harness/platforms';
11+
12+
export type {
13+
UIElement,
14+
ElementReference,
15+
} from '@react-native-harness/platforms';
716

817
export type {
918
TestCollectorEvents,
@@ -75,4 +84,14 @@ export type BridgeServerFunctions = {
7584
event: TEvent['type'],
7685
data: TEvent
7786
) => void;
87+
'platform.actions.tap': (x: number, y: number) => Promise<void>;
88+
'platform.actions.inputText': (text: string) => Promise<void>;
89+
'platform.actions.tapElement': (element: ElementReference) => Promise<void>;
90+
'platform.queries.getUiHierarchy': () => Promise<UIElement>;
91+
'platform.queries.findByTestId': (
92+
testId: string
93+
) => Promise<ElementReference>;
94+
'platform.queries.findAllByTestId': (
95+
testId: string
96+
) => Promise<ElementReference[]>;
7897
};

packages/bridge/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
{
77
"path": "../tools"
88
},
9+
{
10+
"path": "../platforms"
11+
},
912
{
1013
"path": "./tsconfig.lib.json"
1114
}

packages/bridge/tsconfig.lib.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
"references": [
1515
{
1616
"path": "../tools/tsconfig.lib.json"
17+
},
18+
{
19+
"path": "../platforms/tsconfig.lib.json"
1720
}
1821
]
1922
}

packages/jest/src/harness.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ const getHarnessInternal = async (
2929
}),
3030
]);
3131

32+
// Wire up platform functions to bridge
33+
serverBridge.updatePlatformFunctions(platformInstance);
34+
3235
const dispose = async () => {
3336
await Promise.all([
3437
serverBridge.dispose(),

packages/platform-android/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"@react-native-harness/platforms": "workspace:*",
2020
"@react-native-harness/tools": "workspace:*",
2121
"zod": "^3.25.67",
22-
"tslib": "^2.3.0"
22+
"tslib": "^2.3.0",
23+
"fast-xml-parser": "^4.3.2"
2324
},
2425
"license": "MIT"
2526
}

packages/platform-android/src/adb.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,39 @@ export const isBootCompleted = async (adbId: string): Promise<boolean> => {
9797
export const stopEmulator = async (adbId: string): Promise<void> => {
9898
await spawn('adb', ['-s', adbId, 'emu', 'kill']);
9999
};
100+
101+
export const getUiHierarchy = async (adbId: string): Promise<string> => {
102+
const dumpPath = '/data/local/tmp/uidump.xml';
103+
await spawn('adb', ['-s', adbId, 'shell', 'uiautomator', 'dump', dumpPath]);
104+
const { stdout } = await spawn('adb', [
105+
'-s',
106+
adbId,
107+
'shell',
108+
'cat',
109+
dumpPath,
110+
]);
111+
await spawn('adb', ['-s', adbId, 'shell', 'rm', dumpPath]);
112+
return stdout;
113+
};
114+
115+
export const tap = async (
116+
adbId: string,
117+
x: number,
118+
y: number
119+
): Promise<void> => {
120+
await spawn('adb', [
121+
'-s',
122+
adbId,
123+
'shell',
124+
'input',
125+
'tap',
126+
x.toString(),
127+
y.toString(),
128+
]);
129+
};
130+
131+
export const inputText = async (adbId: string, text: string): Promise<void> => {
132+
// ADB input text requires spaces to be escaped as %s
133+
const escapedText = text.replace(/ /g, '%s');
134+
await spawn('adb', ['-s', adbId, 'shell', 'input', 'text', escapedText]);
135+
};

0 commit comments

Comments
 (0)