Skip to content

Commit 8f03a6c

Browse files
committed
fix(logging): detect device launch failures via json output
1 parent 3f7eaea commit 8f03a6c

File tree

2 files changed

+668
-6
lines changed

2 files changed

+668
-6
lines changed

src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
* Tests for start_device_log_cap plugin
33
* Following CLAUDE.md testing standards with pure dependency injection
44
*/
5-
import { describe, it, expect, beforeEach } from 'vitest';
5+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6+
import { EventEmitter } from 'events';
7+
import type { ChildProcess } from 'child_process';
68
import { z } from 'zod';
79
import {
810
createMockExecutor,
@@ -30,9 +32,20 @@ describe('start_device_log_cap plugin', () => {
3032
mkdirCalls = [];
3133
writeFileCalls = [];
3234

35+
const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS;
36+
3337
beforeEach(() => {
3438
sessionStore.clear();
3539
activeDeviceLogSessions.clear();
40+
process.env.XBMCP_LAUNCH_JSON_WAIT_MS = '25';
41+
});
42+
43+
afterEach(() => {
44+
if (originalJsonWaitEnv === undefined) {
45+
delete process.env.XBMCP_LAUNCH_JSON_WAIT_MS;
46+
} else {
47+
process.env.XBMCP_LAUNCH_JSON_WAIT_MS = originalJsonWaitEnv;
48+
}
3649
});
3750

3851
describe('Plugin Structure', () => {
@@ -136,6 +149,262 @@ describe('start_device_log_cap plugin', () => {
136149
expect(result.content[0].text).toContain('Use stop_device_log_cap');
137150
});
138151

152+
it('should surface early launch failures when process exits immediately', async () => {
153+
const failingProcess = new EventEmitter() as unknown as ChildProcess & {
154+
exitCode: number | null;
155+
killed: boolean;
156+
kill(signal?: string): boolean;
157+
stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void };
158+
stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void };
159+
};
160+
161+
const stubOutput = new EventEmitter() as NodeJS.ReadableStream & {
162+
setEncoding?: (encoding: string) => void;
163+
};
164+
stubOutput.setEncoding = () => {};
165+
const stubError = new EventEmitter() as NodeJS.ReadableStream & {
166+
setEncoding?: (encoding: string) => void;
167+
};
168+
stubError.setEncoding = () => {};
169+
170+
failingProcess.stdout = stubOutput;
171+
failingProcess.stderr = stubError;
172+
failingProcess.exitCode = null;
173+
failingProcess.killed = false;
174+
failingProcess.kill = () => {
175+
failingProcess.killed = true;
176+
failingProcess.exitCode = 0;
177+
failingProcess.emit('close', 0, null);
178+
return true;
179+
};
180+
181+
const mockExecutor = createMockExecutor({
182+
success: true,
183+
output: '',
184+
process: failingProcess,
185+
});
186+
187+
let createdLogPath = '';
188+
const mockFileSystemExecutor = createMockFileSystemExecutor({
189+
mkdir: async () => {},
190+
writeFile: async (path: string, content: string) => {
191+
createdLogPath = path;
192+
writeFileCalls.push({ path, content });
193+
},
194+
});
195+
196+
const resultPromise = start_device_log_capLogic(
197+
{
198+
deviceId: '00008110-001A2C3D4E5F',
199+
bundleId: 'com.invalid.App',
200+
},
201+
mockExecutor,
202+
mockFileSystemExecutor,
203+
);
204+
205+
setTimeout(() => {
206+
stubError.emit(
207+
'data',
208+
'ERROR: The application failed to launch. (com.apple.dt.CoreDeviceError error 10002)\nNSLocalizedRecoverySuggestion = Provide a valid bundle identifier.\n',
209+
);
210+
failingProcess.exitCode = 70;
211+
failingProcess.emit('close', 70, null);
212+
}, 10);
213+
214+
const result = await resultPromise;
215+
216+
expect(result.isError).toBe(true);
217+
expect(result.content[0].text).toContain('Provide a valid bundle identifier');
218+
expect(activeDeviceLogSessions.size).toBe(0);
219+
expect(createdLogPath).not.toBe('');
220+
});
221+
222+
it('should surface JSON-reported failures when launch cannot start', async () => {
223+
const jsonFailure = {
224+
error: {
225+
domain: 'com.apple.dt.CoreDeviceError',
226+
code: 10002,
227+
localizedDescription: 'The application failed to launch.',
228+
userInfo: {
229+
NSLocalizedRecoverySuggestion: 'Provide a valid bundle identifier.',
230+
NSLocalizedFailureReason: 'The requested application com.invalid.App is not installed.',
231+
BundleIdentifier: 'com.invalid.App',
232+
},
233+
},
234+
};
235+
236+
const failingProcess = new EventEmitter() as unknown as ChildProcess & {
237+
exitCode: number | null;
238+
killed: boolean;
239+
kill(signal?: string): boolean;
240+
stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void };
241+
stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void };
242+
};
243+
244+
const stubOutput = new EventEmitter() as NodeJS.ReadableStream & {
245+
setEncoding?: (encoding: string) => void;
246+
};
247+
stubOutput.setEncoding = () => {};
248+
const stubError = new EventEmitter() as NodeJS.ReadableStream & {
249+
setEncoding?: (encoding: string) => void;
250+
};
251+
stubError.setEncoding = () => {};
252+
253+
failingProcess.stdout = stubOutput;
254+
failingProcess.stderr = stubError;
255+
failingProcess.exitCode = null;
256+
failingProcess.killed = false;
257+
failingProcess.kill = () => {
258+
failingProcess.killed = true;
259+
return true;
260+
};
261+
262+
const mockExecutor = createMockExecutor({
263+
success: true,
264+
output: '',
265+
process: failingProcess,
266+
});
267+
268+
let jsonPathSeen = '';
269+
let removedJsonPath = '';
270+
271+
const mockFileSystemExecutor = createMockFileSystemExecutor({
272+
mkdir: async () => {},
273+
writeFile: async () => {},
274+
existsSync: (filePath: string): boolean => {
275+
if (filePath.includes('devicectl-launch-')) {
276+
jsonPathSeen = filePath;
277+
return true;
278+
}
279+
return false;
280+
},
281+
readFile: async (filePath: string): Promise<string> => {
282+
if (filePath.includes('devicectl-launch-')) {
283+
jsonPathSeen = filePath;
284+
return JSON.stringify(jsonFailure);
285+
}
286+
return '';
287+
},
288+
rm: async (filePath: string) => {
289+
if (filePath.includes('devicectl-launch-')) {
290+
removedJsonPath = filePath;
291+
}
292+
},
293+
});
294+
295+
setTimeout(() => {
296+
failingProcess.exitCode = 0;
297+
failingProcess.emit('close', 0, null);
298+
}, 5);
299+
300+
const result = await start_device_log_capLogic(
301+
{
302+
deviceId: '00008110-001A2C3D4E5F',
303+
bundleId: 'com.invalid.App',
304+
},
305+
mockExecutor,
306+
mockFileSystemExecutor,
307+
);
308+
309+
expect(result.isError).toBe(true);
310+
expect(result.content[0].text).toContain('Provide a valid bundle identifier');
311+
expect(jsonPathSeen).not.toBe('');
312+
expect(removedJsonPath).toBe(jsonPathSeen);
313+
expect(activeDeviceLogSessions.size).toBe(0);
314+
expect(failingProcess.killed).toBe(true);
315+
});
316+
317+
it('should treat JSON success payload as confirmation of launch', async () => {
318+
const jsonSuccess = {
319+
result: {
320+
process: {
321+
processIdentifier: 4321,
322+
},
323+
},
324+
};
325+
326+
const runningProcess = new EventEmitter() as unknown as ChildProcess & {
327+
exitCode: number | null;
328+
killed: boolean;
329+
kill(signal?: string): boolean;
330+
stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void };
331+
stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void };
332+
};
333+
334+
const stubOutput = new EventEmitter() as NodeJS.ReadableStream & {
335+
setEncoding?: (encoding: string) => void;
336+
};
337+
stubOutput.setEncoding = () => {};
338+
const stubError = new EventEmitter() as NodeJS.ReadableStream & {
339+
setEncoding?: (encoding: string) => void;
340+
};
341+
stubError.setEncoding = () => {};
342+
343+
runningProcess.stdout = stubOutput;
344+
runningProcess.stderr = stubError;
345+
runningProcess.exitCode = null;
346+
runningProcess.killed = false;
347+
runningProcess.kill = () => {
348+
runningProcess.killed = true;
349+
runningProcess.emit('close', 0, null);
350+
return true;
351+
};
352+
353+
const mockExecutor = createMockExecutor({
354+
success: true,
355+
output: '',
356+
process: runningProcess,
357+
});
358+
359+
let jsonPathSeen = '';
360+
let removedJsonPath = '';
361+
let jsonRemoved = false;
362+
363+
const mockFileSystemExecutor = createMockFileSystemExecutor({
364+
mkdir: async () => {},
365+
writeFile: async () => {},
366+
existsSync: (filePath: string): boolean => {
367+
if (filePath.includes('devicectl-launch-')) {
368+
jsonPathSeen = filePath;
369+
return !jsonRemoved;
370+
}
371+
return false;
372+
},
373+
readFile: async (filePath: string): Promise<string> => {
374+
if (filePath.includes('devicectl-launch-')) {
375+
jsonPathSeen = filePath;
376+
return JSON.stringify(jsonSuccess);
377+
}
378+
return '';
379+
},
380+
rm: async (filePath: string) => {
381+
if (filePath.includes('devicectl-launch-')) {
382+
jsonRemoved = true;
383+
removedJsonPath = filePath;
384+
}
385+
},
386+
});
387+
388+
setTimeout(() => {
389+
runningProcess.emit('close', 0, null);
390+
}, 5);
391+
392+
const result = await start_device_log_capLogic(
393+
{
394+
deviceId: '00008110-001A2C3D4E5F',
395+
bundleId: 'com.example.MyApp',
396+
},
397+
mockExecutor,
398+
mockFileSystemExecutor,
399+
);
400+
401+
expect(result.content[0].text).toContain('Device log capture started successfully');
402+
expect(result.isError ?? false).toBe(false);
403+
expect(jsonPathSeen).not.toBe('');
404+
expect(removedJsonPath).toBe(jsonPathSeen);
405+
expect(activeDeviceLogSessions.size).toBe(1);
406+
});
407+
139408
it('should handle directory creation failure', async () => {
140409
// Mock mkdir to fail
141410
const mockExecutor = createMockExecutor({

0 commit comments

Comments
 (0)