Skip to content

Commit 7317fb4

Browse files
authored
refactor(7/12): migrate device and macOS tools to event-based handlers (#325)
## Summary This is **PR 7 of 12** in a stacked PR series that decouples the rendering pipeline from MCP transport. Depends on PR 6 (simulator migrations). Migrates all device and macOS tool handlers to the new event-based handler contract. Same mechanical transformation pattern as PR 6 but for physical device and macOS desktop targets. ### Tools migrated (31 files) **Device tools**: `build_device`, `build_run_device`, `get_device_app_path`, `install_app_device`, `launch_app_device`, `list_devices`, `stop_app_device`, `test_device`, `build-settings` **macOS tools**: `build_macos`, `build_run_macos`, `get_mac_app_path`, `launch_mac_app`, `stop_mac_app`, `test_macos` ### Notable changes - `test_device.ts` and `test_macos.ts` were the most complex handlers (~250-280 lines each). They've been significantly simplified by delegating to `test-preflight.ts`, `device-steps.ts`/`macos-steps.ts`, and `xcodebuild-pipeline.ts` from PR 4. The handlers are now thin orchestrators that emit events. - Device and macOS build tools pass `ctx.emit` through to the xcodebuild pipeline for real-time progress streaming. - `stop_app_device.ts` and `stop_mac_app.ts` updated to emit structured events for process termination results. ## Stack navigation - PR 1-5/12: Foundation, utilities, runtime contract - PR 6/12: Simulator tool migrations - **PR 7/12** (this PR): Device + macOS tool migrations - PR 8/12: UI automation tool migrations - PR 9/12: Remaining tool migrations - PR 10-12/12: Boundaries, config, tests ## Test plan - [ ] `npx vitest run` passes -- all device and macOS tool tests updated - [ ] Build tools stream progress events through the xcodebuild pipeline - [ ] Test tools correctly delegate to test-preflight and platform step modules
1 parent 6e51e5b commit 7317fb4

31 files changed

+2506
-4109
lines changed

src/mcp/tools/device/__tests__/build_device.test.ts

Lines changed: 143 additions & 213 deletions
Large diffs are not rendered by default.

src/mcp/tools/device/__tests__/build_run_device.test.ts

Lines changed: 316 additions & 221 deletions
Large diffs are not rendered by default.

src/mcp/tools/device/__tests__/get_device_app_path.test.ts

Lines changed: 88 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1-
/**
2-
* Tests for get_device_app_path plugin (unified)
3-
* Following CLAUDE.md testing standards with literal validation
4-
* Using dependency injection for deterministic testing
5-
*/
6-
71
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts';
83
import * as z from 'zod';
94
import {
105
createMockCommandResponse,
116
createMockExecutor,
127
} from '../../../../test-utils/mock-executors.ts';
138
import { schema, handler, get_device_app_pathLogic } from '../get_device_app_path.ts';
149
import { sessionStore } from '../../../../utils/session-store.ts';
10+
import { runLogic } from '../../../../test-utils/test-helpers.ts';
11+
1512

1613
describe('get_device_app_path plugin', () => {
1714
beforeEach(() => {
@@ -107,12 +104,14 @@ describe('get_device_app_path plugin', () => {
107104
);
108105
};
109106

110-
await get_device_app_pathLogic(
111-
{
112-
projectPath: '/path/to/project.xcodeproj',
113-
scheme: 'MyScheme',
114-
},
115-
mockExecutor,
107+
await runLogic(() =>
108+
get_device_app_pathLogic(
109+
{
110+
projectPath: '/path/to/project.xcodeproj',
111+
scheme: 'MyScheme',
112+
},
113+
mockExecutor,
114+
),
116115
);
117116

118117
expect(calls).toHaveLength(1);
@@ -128,6 +127,8 @@ describe('get_device_app_path plugin', () => {
128127
'Debug',
129128
'-destination',
130129
'generic/platform=iOS',
130+
'-derivedDataPath',
131+
DERIVED_DATA_DIR,
131132
],
132133
logPrefix: 'Get App Path',
133134
useShell: false,
@@ -161,13 +162,15 @@ describe('get_device_app_path plugin', () => {
161162
);
162163
};
163164

164-
await get_device_app_pathLogic(
165-
{
166-
projectPath: '/path/to/project.xcodeproj',
167-
scheme: 'MyScheme',
168-
platform: 'watchOS',
169-
},
170-
mockExecutor,
165+
await runLogic(() =>
166+
get_device_app_pathLogic(
167+
{
168+
projectPath: '/path/to/project.xcodeproj',
169+
scheme: 'MyScheme',
170+
platform: 'watchOS',
171+
},
172+
mockExecutor,
173+
),
171174
);
172175

173176
expect(calls).toHaveLength(1);
@@ -183,6 +186,8 @@ describe('get_device_app_path plugin', () => {
183186
'Debug',
184187
'-destination',
185188
'generic/platform=watchOS',
189+
'-derivedDataPath',
190+
DERIVED_DATA_DIR,
186191
],
187192
logPrefix: 'Get App Path',
188193
useShell: false,
@@ -216,12 +221,14 @@ describe('get_device_app_path plugin', () => {
216221
);
217222
};
218223

219-
await get_device_app_pathLogic(
220-
{
221-
workspacePath: '/path/to/workspace.xcworkspace',
222-
scheme: 'MyScheme',
223-
},
224-
mockExecutor,
224+
await runLogic(() =>
225+
get_device_app_pathLogic(
226+
{
227+
workspacePath: '/path/to/workspace.xcworkspace',
228+
scheme: 'MyScheme',
229+
},
230+
mockExecutor,
231+
),
225232
);
226233

227234
expect(calls).toHaveLength(1);
@@ -237,6 +244,8 @@ describe('get_device_app_path plugin', () => {
237244
'Debug',
238245
'-destination',
239246
'generic/platform=iOS',
247+
'-derivedDataPath',
248+
DERIVED_DATA_DIR,
240249
],
241250
logPrefix: 'Get App Path',
242251
useShell: false,
@@ -251,29 +260,24 @@ describe('get_device_app_path plugin', () => {
251260
'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n',
252261
});
253262

254-
const result = await get_device_app_pathLogic(
255-
{
256-
projectPath: '/path/to/project.xcodeproj',
257-
scheme: 'MyScheme',
258-
},
259-
mockExecutor,
260-
);
261-
262-
expect(result).toEqual({
263-
content: [
263+
const result = await runLogic(() =>
264+
get_device_app_pathLogic(
264265
{
265-
type: 'text',
266-
text: '✅ App path retrieved successfully: /path/to/build/Debug-iphoneos/MyApp.app',
267-
},
268-
],
269-
nextStepParams: {
270-
get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' },
271-
install_app_device: {
272-
deviceId: 'DEVICE_UDID',
273-
appPath: '/path/to/build/Debug-iphoneos/MyApp.app',
266+
projectPath: '/path/to/project.xcodeproj',
267+
scheme: 'MyScheme',
274268
},
275-
launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' },
269+
mockExecutor,
270+
),
271+
);
272+
273+
expect(result.isError).toBeFalsy();
274+
expect(result.nextStepParams).toEqual({
275+
get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' },
276+
install_app_device: {
277+
deviceId: 'DEVICE_UDID',
278+
appPath: '/path/to/build/Debug-iphoneos/MyApp.app',
276279
},
280+
launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' },
277281
});
278282
});
279283

@@ -283,23 +287,18 @@ describe('get_device_app_path plugin', () => {
283287
error: 'xcodebuild: error: The project does not exist.',
284288
});
285289

286-
const result = await get_device_app_pathLogic(
287-
{
288-
projectPath: '/path/to/nonexistent.xcodeproj',
289-
scheme: 'MyScheme',
290-
},
291-
mockExecutor,
292-
);
293-
294-
expect(result).toEqual({
295-
content: [
290+
const result = await runLogic(() =>
291+
get_device_app_pathLogic(
296292
{
297-
type: 'text',
298-
text: 'Failed to get app path: xcodebuild: error: The project does not exist.',
293+
projectPath: '/path/to/nonexistent.xcodeproj',
294+
scheme: 'MyScheme',
299295
},
300-
],
301-
isError: true,
302-
});
296+
mockExecutor,
297+
),
298+
);
299+
300+
expect(result.isError).toBe(true);
301+
expect(result.nextStepParams).toBeUndefined();
303302
});
304303

305304
it('should return exact parse failure response', async () => {
@@ -308,23 +307,18 @@ describe('get_device_app_path plugin', () => {
308307
output: 'Build settings without required fields',
309308
});
310309

311-
const result = await get_device_app_pathLogic(
312-
{
313-
projectPath: '/path/to/project.xcodeproj',
314-
scheme: 'MyScheme',
315-
},
316-
mockExecutor,
317-
);
318-
319-
expect(result).toEqual({
320-
content: [
310+
const result = await runLogic(() =>
311+
get_device_app_pathLogic(
321312
{
322-
type: 'text',
323-
text: 'Failed to extract app path from build settings. Make sure the app has been built first.',
313+
projectPath: '/path/to/project.xcodeproj',
314+
scheme: 'MyScheme',
324315
},
325-
],
326-
isError: true,
327-
});
316+
mockExecutor,
317+
),
318+
);
319+
320+
expect(result.isError).toBe(true);
321+
expect(result.nextStepParams).toBeUndefined();
328322
});
329323

330324
it('should include optional configuration parameter in command', async () => {
@@ -353,13 +347,15 @@ describe('get_device_app_path plugin', () => {
353347
);
354348
};
355349

356-
await get_device_app_pathLogic(
357-
{
358-
projectPath: '/path/to/project.xcodeproj',
359-
scheme: 'MyScheme',
360-
configuration: 'Release',
361-
},
362-
mockExecutor,
350+
await runLogic(() =>
351+
get_device_app_pathLogic(
352+
{
353+
projectPath: '/path/to/project.xcodeproj',
354+
scheme: 'MyScheme',
355+
configuration: 'Release',
356+
},
357+
mockExecutor,
358+
),
363359
);
364360

365361
expect(calls).toHaveLength(1);
@@ -375,6 +371,8 @@ describe('get_device_app_path plugin', () => {
375371
'Release',
376372
'-destination',
377373
'generic/platform=iOS',
374+
'-derivedDataPath',
375+
DERIVED_DATA_DIR,
378376
],
379377
logPrefix: 'Get App Path',
380378
useShell: false,
@@ -393,47 +391,18 @@ describe('get_device_app_path plugin', () => {
393391
return Promise.reject(new Error('Network error'));
394392
};
395393

396-
const result = await get_device_app_pathLogic(
397-
{
398-
projectPath: '/path/to/project.xcodeproj',
399-
scheme: 'MyScheme',
400-
},
401-
mockExecutor,
402-
);
403-
404-
expect(result).toEqual({
405-
content: [
394+
const result = await runLogic(() =>
395+
get_device_app_pathLogic(
406396
{
407-
type: 'text',
408-
text: 'Error retrieving app path: Network error',
397+
projectPath: '/path/to/project.xcodeproj',
398+
scheme: 'MyScheme',
409399
},
410-
],
411-
isError: true,
412-
});
413-
});
414-
415-
it('should return exact string error handling response', async () => {
416-
const mockExecutor = () => {
417-
return Promise.reject('String error');
418-
};
419-
420-
const result = await get_device_app_pathLogic(
421-
{
422-
projectPath: '/path/to/project.xcodeproj',
423-
scheme: 'MyScheme',
424-
},
425-
mockExecutor,
400+
mockExecutor,
401+
),
426402
);
427403

428-
expect(result).toEqual({
429-
content: [
430-
{
431-
type: 'text',
432-
text: 'Error retrieving app path: String error',
433-
},
434-
],
435-
isError: true,
436-
});
404+
expect(result.isError).toBe(true);
405+
expect(result.nextStepParams).toBeUndefined();
437406
});
438407
});
439408
});

0 commit comments

Comments
 (0)