Skip to content

Commit f0e5077

Browse files
test: add device selection tools
1 parent dee87fd commit f0e5077

18 files changed

Lines changed: 1980 additions & 191 deletions

src/backends/idb-backend.test.ts

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
22

3-
import { parseIdbHierarchy, mapIdbElement } from './idb-backend.js';
3+
import { parseIdbHierarchy, mapIdbElement, IdbBackend } from './idb-backend.js';
4+
import * as execModule from '../utils/exec.js';
5+
6+
vi.mock('../utils/exec.js', () => ({
7+
exec: vi.fn(),
8+
execStrict: vi.fn(),
9+
isCommandAvailable: vi.fn(),
10+
}));
11+
12+
vi.mock('../utils/platform.js', () => ({
13+
resolveIdbPath: vi.fn().mockResolvedValue('/usr/local/bin/idb'),
14+
detectPlatform: vi.fn(),
15+
detectAllDevices: vi.fn(),
16+
MultipleDevicesError: class extends Error {},
17+
}));
18+
19+
const mockExec = vi.mocked(execModule.exec);
20+
const mockExecStrict = vi.mocked(execModule.execStrict);
421

522
describe('parseIdbHierarchy', () => {
623
it('parses a JSON array of elements', () => {
@@ -146,3 +163,129 @@ describe('mapIdbElement', () => {
146163
expect(result.type).toBe('Unknown');
147164
});
148165
});
166+
167+
describe('IdbBackend simctl fallback', () => {
168+
const udid = 'AAAA1111-BBBB-CCCC-DDDD-EEEE2222FFFF';
169+
let backend: IdbBackend;
170+
171+
beforeEach(() => {
172+
vi.clearAllMocks();
173+
mockExec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
174+
backend = new IdbBackend(udid);
175+
});
176+
177+
describe('openApp', () => {
178+
it('uses idb launch when it succeeds', async () => {
179+
mockExecStrict.mockResolvedValue('');
180+
181+
await backend.openApp('io.metamask');
182+
183+
expect(mockExecStrict).toHaveBeenCalledWith('/usr/local/bin/idb', [
184+
'launch',
185+
'io.metamask',
186+
'--udid',
187+
udid,
188+
]);
189+
expect(mockExecStrict).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it('falls back to simctl launch when idb fails', async () => {
193+
mockExecStrict
194+
.mockRejectedValueOnce(new Error('companion conflict'))
195+
.mockResolvedValueOnce('');
196+
197+
await backend.openApp('io.metamask');
198+
199+
expect(mockExecStrict).toHaveBeenCalledTimes(2);
200+
expect(mockExecStrict).toHaveBeenLastCalledWith('xcrun', [
201+
'simctl',
202+
'launch',
203+
udid,
204+
'io.metamask',
205+
]);
206+
});
207+
});
208+
209+
describe('closeApp', () => {
210+
it('uses idb terminate when it succeeds', async () => {
211+
mockExecStrict.mockResolvedValue('');
212+
213+
await backend.closeApp('io.metamask');
214+
215+
expect(mockExecStrict).toHaveBeenCalledWith('/usr/local/bin/idb', [
216+
'terminate',
217+
'io.metamask',
218+
'--udid',
219+
udid,
220+
]);
221+
});
222+
223+
it('falls back to simctl terminate when idb fails', async () => {
224+
mockExecStrict
225+
.mockRejectedValueOnce(new Error('companion conflict'))
226+
.mockResolvedValueOnce('');
227+
228+
await backend.closeApp('io.metamask');
229+
230+
expect(mockExecStrict).toHaveBeenLastCalledWith('xcrun', [
231+
'simctl',
232+
'terminate',
233+
udid,
234+
'io.metamask',
235+
]);
236+
});
237+
});
238+
239+
describe('getAppState', () => {
240+
it('uses idb list-apps when it succeeds', async () => {
241+
mockExecStrict.mockResolvedValue(
242+
'io.metamask | MetaMask | Running | 12345\n',
243+
);
244+
245+
const result = await backend.getAppState('io.metamask');
246+
247+
expect(result).toStrictEqual({
248+
bundleId: 'io.metamask',
249+
state: 'Running',
250+
pid: 12345,
251+
});
252+
});
253+
254+
it('falls back to simctl listapps when idb fails', async () => {
255+
mockExecStrict.mockRejectedValue(new Error('companion conflict'));
256+
mockExec.mockImplementation(async (cmd) => {
257+
if (cmd === 'xcrun') {
258+
return {
259+
exitCode: 0,
260+
stdout: 'CFBundleIdentifier = "io.metamask"',
261+
stderr: '',
262+
};
263+
}
264+
return { exitCode: 0, stdout: '', stderr: '' };
265+
});
266+
267+
const result = await backend.getAppState('io.metamask');
268+
269+
expect(result).toStrictEqual({
270+
bundleId: 'io.metamask',
271+
state: 'Running',
272+
});
273+
});
274+
275+
it('returns Not Installed when simctl fallback finds no match', async () => {
276+
mockExecStrict.mockRejectedValue(new Error('companion conflict'));
277+
mockExec.mockResolvedValue({
278+
exitCode: 0,
279+
stdout: 'CFBundleIdentifier = "com.apple.Maps"',
280+
stderr: '',
281+
});
282+
283+
const result = await backend.getAppState('io.metamask');
284+
285+
expect(result).toStrictEqual({
286+
bundleId: 'io.metamask',
287+
state: 'Not Installed',
288+
});
289+
});
290+
});
291+
});

src/backends/idb-backend.ts

Lines changed: 72 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
describeElement,
2121
computeSwipeEnd,
2222
} from '../utils/element.js';
23-
import { execStrict, exec, isCommandAvailable } from '../utils/exec.js';
23+
import { execStrict, exec } from '../utils/exec.js';
24+
import { resolveIdbPath } from '../utils/platform.js';
2425

2526
export class IdbBackend implements DeviceBackend {
2627
readonly platform = 'ios' as const;
@@ -29,6 +30,8 @@ export class IdbBackend implements DeviceBackend {
2930

3031
readonly #udid: string;
3132

33+
#idbPath = 'idb';
34+
3235
#recordingProcess: ChildProcess | null = null;
3336

3437
#recordingPath: string | null = null;
@@ -42,20 +45,14 @@ export class IdbBackend implements DeviceBackend {
4245
return;
4346
}
4447

45-
if (!(await isCommandAvailable('idb'))) {
46-
throw new Error(
47-
'idb is not installed.\n' +
48-
'Install: brew tap facebook/fb && brew install idb-companion && pip3 install fb-idb',
49-
);
50-
}
51-
52-
await exec('idb', ['connect', this.#udid]);
48+
this.#idbPath = await resolveIdbPath();
49+
await exec(this.#idbPath, ['connect', this.#udid]);
5350
this.#connected = true;
5451
}
5552

5653
async getDeviceInfo(): Promise<DeviceInfo> {
5754
await this.ensureConnected();
58-
const raw = await execStrict('idb', [
55+
const raw = await execStrict(this.#idbPath, [
5956
'describe',
6057
'--udid',
6158
this.#udid,
@@ -80,7 +77,7 @@ export class IdbBackend implements DeviceBackend {
8077

8178
async snapshot(): Promise<SnapshotResult> {
8279
await this.ensureConnected();
83-
const raw = await execStrict('idb', [
80+
const raw = await execStrict(this.#idbPath, [
8481
'ui',
8582
'describe-all',
8683
'--udid',
@@ -120,7 +117,7 @@ export class IdbBackend implements DeviceBackend {
120117

121118
async tapCoordinates(x: number, y: number): Promise<void> {
122119
await this.ensureConnected();
123-
await execStrict('idb', [
120+
await execStrict(this.#idbPath, [
124121
'ui',
125122
'tap',
126123
String(x),
@@ -132,16 +129,16 @@ export class IdbBackend implements DeviceBackend {
132129

133130
async typeText(text: string): Promise<void> {
134131
await this.ensureConnected();
135-
await exec('idb', [
132+
await exec(this.#idbPath, [
136133
'ui',
137134
'key-sequence',
138135
'--',
139136
'cmd+a',
140137
'--udid',
141138
this.#udid,
142139
]);
143-
await exec('idb', ['ui', 'key', 'DELETE', '--udid', this.#udid]);
144-
await execStrict('idb', ['ui', 'text', text, '--udid', this.#udid]);
140+
await exec(this.#idbPath, ['ui', 'key', 'DELETE', '--udid', this.#udid]);
141+
await execStrict(this.#idbPath, ['ui', 'text', text, '--udid', this.#udid]);
145142
}
146143

147144
async swipe(
@@ -156,7 +153,7 @@ export class IdbBackend implements DeviceBackend {
156153
const sy = startY ?? 400;
157154
const [endX, endY] = computeSwipeEnd(sx, sy, direction, d);
158155

159-
await execStrict('idb', [
156+
await execStrict(this.#idbPath, [
160157
'ui',
161158
'swipe',
162159
String(sx),
@@ -189,38 +186,72 @@ export class IdbBackend implements DeviceBackend {
189186

190187
async getAppState(bundleId: string): Promise<AppStateResult> {
191188
await this.ensureConnected();
192-
const raw = await execStrict('idb', ['list-apps', '--udid', this.#udid]);
193-
194-
for (const line of raw.trim().split('\n')) {
195-
if (line.includes(bundleId)) {
196-
const parts = line.split('|').map((s) => s.trim());
197-
return {
198-
bundleId,
199-
state: parts[2] ?? 'Unknown',
200-
pid: parts[3] ? parseInt(parts[3], 10) : undefined,
201-
};
189+
try {
190+
const raw = await execStrict(this.#idbPath, [
191+
'list-apps',
192+
'--udid',
193+
this.#udid,
194+
]);
195+
196+
for (const line of raw.trim().split('\n')) {
197+
if (line.includes(bundleId)) {
198+
const parts = line.split('|').map((s) => s.trim());
199+
return {
200+
bundleId,
201+
state: parts[2] ?? 'Unknown',
202+
pid: parts[3] ? parseInt(parts[3], 10) : undefined,
203+
};
204+
}
202205
}
206+
207+
return { bundleId, state: 'Not Installed' };
208+
} catch {
209+
return this.#getAppStateViaSimctl(bundleId);
203210
}
211+
}
204212

213+
async #getAppStateViaSimctl(bundleId: string): Promise<AppStateResult> {
214+
const result = await exec('xcrun', ['simctl', 'listapps', this.#udid]);
215+
if (result.exitCode === 0 && result.stdout.includes(bundleId)) {
216+
return { bundleId, state: 'Running' };
217+
}
205218
return { bundleId, state: 'Not Installed' };
206219
}
207220

208221
async screenshot(outputPath?: string): Promise<ScreenshotResult> {
209222
await this.ensureConnected();
210223
const path = outputPath ?? `/tmp/device-mcp-screenshot-${Date.now()}.png`;
211-
await execStrict('idb', ['screenshot', path, '--udid', this.#udid]);
224+
await execStrict(this.#idbPath, ['screenshot', path, '--udid', this.#udid]);
212225
const data = await execStrict('base64', [path]);
213226
return { data: data.trim(), format: 'png', path };
214227
}
215228

216229
async openApp(bundleId: string): Promise<void> {
217230
await this.ensureConnected();
218-
await execStrict('idb', ['launch', bundleId, '--udid', this.#udid]);
231+
try {
232+
await execStrict(this.#idbPath, [
233+
'launch',
234+
bundleId,
235+
'--udid',
236+
this.#udid,
237+
]);
238+
} catch {
239+
await execStrict('xcrun', ['simctl', 'launch', this.#udid, bundleId]);
240+
}
219241
}
220242

221243
async closeApp(bundleId: string): Promise<void> {
222244
await this.ensureConnected();
223-
await execStrict('idb', ['terminate', bundleId, '--udid', this.#udid]);
245+
try {
246+
await execStrict(this.#idbPath, [
247+
'terminate',
248+
bundleId,
249+
'--udid',
250+
this.#udid,
251+
]);
252+
} catch {
253+
await execStrict('xcrun', ['simctl', 'terminate', this.#udid, bundleId]);
254+
}
224255
}
225256

226257
async pressButton(button: DeviceButton): Promise<void> {
@@ -231,7 +262,7 @@ export class IdbBackend implements DeviceBackend {
231262
back: 'HOME',
232263
enter: 'RETURN',
233264
};
234-
await execStrict('idb', [
265+
await execStrict(this.#idbPath, [
235266
'ui',
236267
'key',
237268
keyMap[button],
@@ -242,7 +273,13 @@ export class IdbBackend implements DeviceBackend {
242273

243274
async dismissKeyboard(): Promise<void> {
244275
await this.ensureConnected();
245-
await execStrict('idb', ['ui', 'key', 'RETURN', '--udid', this.#udid]);
276+
await execStrict(this.#idbPath, [
277+
'ui',
278+
'key',
279+
'RETURN',
280+
'--udid',
281+
this.#udid,
282+
]);
246283
}
247284

248285
async dismissAlert(accept: boolean): Promise<void> {
@@ -276,7 +313,7 @@ export class IdbBackend implements DeviceBackend {
276313
if (filter) {
277314
args.push('--predicate', `eventMessage CONTAINS "${filter}"`);
278315
}
279-
const raw = await execStrict('idb', args);
316+
const raw = await execStrict(this.#idbPath, args);
280317
const entries = raw
281318
.trim()
282319
.split('\n')
@@ -302,7 +339,7 @@ export class IdbBackend implements DeviceBackend {
302339

303340
const cx = Math.round(element.frame.x + element.frame.width / 2);
304341
const cy = Math.round(element.frame.y + element.frame.height / 2);
305-
await execStrict('idb', [
342+
await execStrict(this.#idbPath, [
306343
'ui',
307344
'tap',
308345
String(cx),
@@ -362,7 +399,7 @@ export class IdbBackend implements DeviceBackend {
362399

363400
async getWindowSize(): Promise<WindowSize> {
364401
await this.ensureConnected();
365-
const raw = await execStrict('idb', [
402+
const raw = await execStrict(this.#idbPath, [
366403
'describe',
367404
'--udid',
368405
this.#udid,
@@ -427,7 +464,7 @@ export class IdbBackend implements DeviceBackend {
427464
}
428465
const path = outputPath ?? `/tmp/device-mcp-recording-${Date.now()}.mp4`;
429466
this.#recordingPath = path;
430-
this.#recordingProcess = spawn('idb', [
467+
this.#recordingProcess = spawn(this.#idbPath, [
431468
'record-video',
432469
path,
433470
'--udid',

0 commit comments

Comments
 (0)