Skip to content

Commit 8d57c75

Browse files
committed
feat: implement UI features for iOS
1 parent 56e0205 commit 8d57c75

File tree

6 files changed

+310
-38
lines changed

6 files changed

+310
-38
lines changed

packages/platform-ios/src/idb.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { spawn } from '@react-native-harness/tools';
2+
3+
export const startApp = async (
4+
udid: string,
5+
bundleId: string
6+
): Promise<void> => {
7+
await spawn('idb', ['launch', '--udid', udid, bundleId]);
8+
};
9+
10+
export const stopApp = async (
11+
udid: string,
12+
bundleId: string
13+
): Promise<void> => {
14+
await spawn('idb', ['terminate', '--udid', udid, bundleId]);
15+
};
16+
17+
export const tap = async (
18+
udid: string,
19+
x: number,
20+
y: number
21+
): Promise<void> => {
22+
await spawn('idb', ['ui', 'tap', '--udid', udid, x.toString(), y.toString()]);
23+
};
24+
25+
export const inputText = async (udid: string, text: string): Promise<void> => {
26+
await spawn('idb', ['ui', 'text', '--udid', udid, text]);
27+
};
28+
29+
export const screenshot = async (
30+
udid: string,
31+
destination: string
32+
): Promise<string> => {
33+
await spawn('idb', ['screenshot', '--udid', udid, destination]);
34+
return destination;
35+
};
36+
37+
// Queries
38+
39+
export const getDeviceIds = async (): Promise<string[]> => {
40+
const { stdout } = await spawn('idb', ['list-targets', '--json']);
41+
try {
42+
const targets = JSON.parse(stdout);
43+
return targets.map((t: { udid: string }) => t.udid);
44+
} catch {
45+
return [];
46+
}
47+
};
48+
49+
export const isAppInstalled = async (
50+
udid: string,
51+
bundleId: string
52+
): Promise<boolean> => {
53+
const { stdout } = await spawn('idb', ['list-apps', '--udid', udid, '--json']);
54+
try {
55+
const apps = JSON.parse(stdout);
56+
return apps.some((app: { bundle_id: string }) => app.bundle_id === bundleId);
57+
} catch {
58+
return false;
59+
}
60+
};
61+
62+
export const getUiHierarchy = async (udid: string): Promise<string> => {
63+
const { stdout } = await spawn('idb', ['ui', 'describe-all', '--udid', udid]);
64+
return stdout;
65+
};
66+
67+
export type DeviceInfo = {
68+
name: string;
69+
os_version: string;
70+
type: string;
71+
};
72+
73+
export const getDeviceInfo = async (
74+
udid: string
75+
): Promise<DeviceInfo | null> => {
76+
const { stdout } = await spawn('idb', ['list-targets', '--json']);
77+
try {
78+
const targets = JSON.parse(stdout);
79+
const target = targets.find((t: { udid: string }) => t.udid === udid);
80+
if (target) {
81+
return {
82+
name: target.name,
83+
os_version: target.os_version,
84+
type: target.type,
85+
};
86+
}
87+
return null;
88+
} catch {
89+
return null;
90+
}
91+
};
92+
93+
export const getEmulatorName = async (udid: string): Promise<string | null> => {
94+
const info = await getDeviceInfo(udid);
95+
return info?.name ?? null;
96+
};
97+
98+
export const isIdbInstalled = async (): Promise<boolean> => {
99+
try {
100+
await spawn('which', ['idb']);
101+
return true;
102+
} catch {
103+
return false;
104+
}
105+
};

packages/platform-ios/src/instance.ts

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AppNotInstalledError,
33
DeviceNotFoundError,
4+
ElementReference,
45
HarnessPlatformRunner,
56
} from '@react-native-harness/platforms';
67
import {
@@ -10,7 +11,8 @@ import {
1011
} from './config.js';
1112
import * as simctl from './xcrun/simctl.js';
1213
import * as devicectl from './xcrun/devicectl.js';
13-
import { getDeviceName } from './utils.js';
14+
import * as idb from './idb.js';
15+
import { getDeviceName, parseUiHierarchy, getElementByPath, getUiHierarchy, findElementsByTestId } from './utils.js';
1416
import { tmpdir } from 'node:os';
1517
import { join } from 'node:path';
1618
import { randomUUID } from 'node:crypto';
@@ -44,20 +46,11 @@ export const getAppleSimulatorPlatformInstance = async (
4446
throw new Error('Simulator is not booted');
4547
}
4648

47-
const isAvailable = await simctl.isAppInstalled(udid, config.bundleId);
48-
49-
if (!isAvailable) {
50-
throw new AppNotInstalledError(
51-
config.bundleId,
52-
getDeviceName(config.device)
53-
);
54-
}
55-
5649
return {
5750
startApp: async () => {
5851
await simctl.startApp(udid, config.bundleId);
5952
},
60-
restartApp: async () => {
53+
restartApp: async () => {
6154
await simctl.stopApp(udid, config.bundleId);
6255
await simctl.startApp(udid, config.bundleId);
6356
},
@@ -69,31 +62,50 @@ export const getAppleSimulatorPlatformInstance = async (
6962
},
7063
queries: {
7164
getUiHierarchy: async () => {
72-
throw new Error('Not implemented yet');
65+
return await getUiHierarchy(udid);
7366
},
74-
findByTestId: async () => {
75-
throw new Error('Not implemented yet');
67+
findByTestId: async (testId: string) => {
68+
const hierarchy = await getUiHierarchy(udid);
69+
const matches = findElementsByTestId(hierarchy, testId);
70+
if (matches.length === 0) {
71+
throw new Error(`Element with testID "${testId}" not found`);
72+
}
73+
return { id: matches[0].path.join('.') };
7674
},
77-
findAllByTestId: async () => {
78-
throw new Error('Not implemented yet');
75+
findAllByTestId: async (testId: string) => {
76+
const hierarchy = await getUiHierarchy(udid);
77+
const matches = findElementsByTestId(hierarchy, testId);
78+
return matches.map((match) => ({ id: match.path.join('.') }));
7979
},
8080
},
8181
actions: {
82-
tap: async () => {
83-
throw new Error('Not implemented yet');
82+
tap: async (x: number, y: number) => {
83+
await idb.tap(udid, x, y);
8484
},
85-
inputText: async () => {
86-
throw new Error('Not implemented yet');
85+
inputText: async (text: string) => {
86+
await idb.inputText(udid, text);
8787
},
88-
tapElement: async () => {
89-
throw new Error('Not implemented yet');
88+
tapElement: async (element: ElementReference) => {
89+
const hierarchy = await getUiHierarchy(udid);
90+
const uiElement = getElementByPath(hierarchy, element.id);
91+
92+
if (!uiElement) {
93+
throw new Error(
94+
`Element with identifier "${element.id}" not found in UI hierarchy.`
95+
);
96+
}
97+
98+
const centerX = uiElement.rect.x + uiElement.rect.width / 2;
99+
const centerY = uiElement.rect.y + uiElement.rect.height / 2;
100+
101+
await idb.tap(udid, centerX, centerY);
90102
},
91103
screenshot: async () => {
92104
const tempPath = join(
93105
tmpdir(),
94106
`harness-screenshot-${randomUUID()}.png`
95107
);
96-
await simctl.screenshot(udid, tempPath);
108+
await idb.screenshot(udid, tempPath);
97109
return { path: tempPath };
98110
},
99111
},
@@ -120,6 +132,11 @@ export const getApplePhysicalDevicePlatformInstance = async (
120132
);
121133
}
122134

135+
const getUiHierarchy = async () => {
136+
const json = await idb.getUiHierarchy(deviceId);
137+
return parseUiHierarchy(json);
138+
};
139+
123140
return {
124141
startApp: async () => {
125142
await devicectl.startApp(deviceId, config.bundleId);
@@ -135,28 +152,50 @@ export const getApplePhysicalDevicePlatformInstance = async (
135152
await devicectl.stopApp(deviceId, config.bundleId);
136153
},
137154
queries: {
138-
getUiHierarchy: async () => {
139-
throw new Error('Not implemented yet');
155+
getUiHierarchy,
156+
findByTestId: async (testId: string) => {
157+
const hierarchy = await getUiHierarchy();
158+
const matches = findElementsByTestId(hierarchy, testId);
159+
if (matches.length === 0) {
160+
throw new Error(`Element with testID "${testId}" not found`);
161+
}
162+
return { id: matches[0].path.join('.') };
140163
},
141-
findByTestId: async () => {
142-
throw new Error('Not implemented yet');
143-
},
144-
findAllByTestId: async () => {
145-
throw new Error('Not implemented yet');
164+
findAllByTestId: async (testId: string) => {
165+
const hierarchy = await getUiHierarchy();
166+
const matches = findElementsByTestId(hierarchy, testId);
167+
return matches.map((match) => ({ id: match.path.join('.') }));
146168
},
147169
},
148170
actions: {
149-
tap: async () => {
150-
throw new Error('Not implemented yet');
171+
tap: async (x: number, y: number) => {
172+
await idb.tap(deviceId, x, y);
151173
},
152-
inputText: async () => {
153-
throw new Error('Not implemented yet');
174+
inputText: async (text: string) => {
175+
await idb.inputText(deviceId, text);
154176
},
155-
tapElement: async () => {
156-
throw new Error('Not implemented yet');
177+
tapElement: async (element: ElementReference) => {
178+
const hierarchy = await getUiHierarchy();
179+
const uiElement = getElementByPath(hierarchy, element.id);
180+
181+
if (!uiElement) {
182+
throw new Error(
183+
`Element with identifier "${element.id}" not found in UI hierarchy.`
184+
);
185+
}
186+
187+
const centerX = uiElement.rect.x + uiElement.rect.width / 2;
188+
const centerY = uiElement.rect.y + uiElement.rect.height / 2;
189+
190+
await idb.tap(deviceId, centerX, centerY);
157191
},
158192
screenshot: async () => {
159-
throw new Error('Screenshot is not supported on physical iOS devices');
193+
const tempPath = join(
194+
tmpdir(),
195+
`harness-screenshot-${randomUUID()}.png`
196+
);
197+
await idb.screenshot(deviceId, tempPath);
198+
return { path: tempPath };
160199
},
161200
},
162201
};

0 commit comments

Comments
 (0)