Skip to content
  •  
  •  
  •  
13 changes: 13 additions & 0 deletions example_projects/iOS_Calculator/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"mcpServers": {
"XcodeBuildMCP": {
"type": "stdio",
"command": "node",
"args": [
"../../build/cli.js",
"mcp"
],
"env": {}
}
}
}
57 changes: 57 additions & 0 deletions src/mcp/resources/__tests__/session-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,45 @@
import { mkdtempSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import type { ChildProcess } from 'node:child_process';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { clearDaemonActivityRegistry } from '../../../daemon/activity-registry.ts';
import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts';
import { activeLogSessions } from '../../../utils/log_capture.ts';
import { activeDeviceLogSessions } from '../../../utils/log-capture/device-log-sessions.ts';
import {
clearAllSimulatorLaunchOsLogSessions,
registerSimulatorLaunchOsLogSession,
setSimulatorLaunchOsLogRegistryDirOverrideForTests,
} from '../../../utils/log-capture/simulator-launch-oslog-sessions.ts';
import { setSimulatorLaunchOsLogRecordActiveOverrideForTests } from '../../../utils/log-capture/simulator-launch-oslog-registry.ts';
import { setRuntimeInstanceForTests } from '../../../utils/runtime-instance.ts';
import { clearAllProcesses } from '../../tools/swift-package/active-processes.ts';
import { sessionStatusResourceLogic } from '../session-status.ts';

let registryDir: string;

function createTrackedChild(pid = 777): ChildProcess {
const emitter = new EventEmitter();
const child = emitter as ChildProcess;
Object.defineProperty(child, 'pid', { value: pid, configurable: true });
Object.defineProperty(child, 'exitCode', { value: null, writable: true, configurable: true });
child.kill = (() => true) as ChildProcess['kill'];
return child;
}

describe('session-status resource', () => {
beforeEach(async () => {
registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-session-status-'));
setSimulatorLaunchOsLogRegistryDirOverrideForTests(registryDir);
setRuntimeInstanceForTests({ instanceId: 'session-status-test', pid: process.pid });
setSimulatorLaunchOsLogRecordActiveOverrideForTests(async () => true);
activeLogSessions.clear();
activeDeviceLogSessions.clear();
clearAllProcesses();
await clearAllSimulatorLaunchOsLogSessions();
clearDaemonActivityRegistry();
await getDefaultDebuggerManager().disposeAll();
});
Expand All @@ -19,8 +48,13 @@ describe('session-status resource', () => {
activeLogSessions.clear();
activeDeviceLogSessions.clear();
clearAllProcesses();
await clearAllSimulatorLaunchOsLogSessions();
clearDaemonActivityRegistry();
await getDefaultDebuggerManager().disposeAll();
setSimulatorLaunchOsLogRecordActiveOverrideForTests(null);
setRuntimeInstanceForTests(null);
setSimulatorLaunchOsLogRegistryDirOverrideForTests(null);
await rm(registryDir, { recursive: true, force: true });
});

describe('Handler Functionality', () => {
Expand All @@ -31,6 +65,7 @@ describe('session-status resource', () => {
const parsed = JSON.parse(result.contents[0].text);

expect(parsed.logging.simulator.activeSessionIds).toEqual([]);
expect(parsed.logging.simulator.activeLaunchOsLogSessions).toEqual([]);
expect(parsed.logging.device.activeSessionIds).toEqual([]);
expect(parsed.debug.currentSessionId).toBe(null);
expect(parsed.debug.sessionIds).toEqual([]);
Expand All @@ -43,5 +78,27 @@ describe('session-status resource', () => {
expect(parsed.process.rssBytes).toBeTypeOf('number');
expect(parsed.process.heapUsedBytes).toBeTypeOf('number');
});

it('should include tracked launch OSLog sessions', async () => {
await registerSimulatorLaunchOsLogSession({
process: createTrackedChild(888),
simulatorUuid: 'sim-1',
bundleId: 'io.sentry.app',
logFilePath: '/tmp/app.log',
});

const result = await sessionStatusResourceLogic();
const parsed = JSON.parse(result.contents[0].text);

expect(parsed.logging.simulator.activeLaunchOsLogSessions).toEqual([
expect.objectContaining({
simulatorUuid: 'sim-1',
bundleId: 'io.sentry.app',
pid: 888,
logFilePath: '/tmp/app.log',
ownedByCurrentProcess: true,
}),
]);
});
});
});
2 changes: 1 addition & 1 deletion src/mcp/resources/session-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getSessionRuntimeStatusSnapshot } from '../../utils/session-status.ts';
export async function sessionStatusResourceLogic(): Promise<{ contents: Array<{ text: string }> }> {
try {
log('info', 'Processing session status resource request');
const status = getSessionRuntimeStatusSnapshot();
const status = await getSessionRuntimeStatusSnapshot();

return {
contents: [
Expand Down
138 changes: 136 additions & 2 deletions src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,89 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as z from 'zod';
import {
createMockExecutor,
createMockCommandResponse,
} from '../../../../test-utils/mock-executors.ts';
import type { CommandExecutor } from '../../../../utils/execution/index.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import {
clearAllSimulatorLaunchOsLogSessions,
registerSimulatorLaunchOsLogSession,
setSimulatorLaunchOsLogRegistryDirOverrideForTests,
} from '../../../../utils/log-capture/simulator-launch-oslog-sessions.ts';
import { setSimulatorLaunchOsLogRecordActiveOverrideForTests } from '../../../../utils/log-capture/simulator-launch-oslog-registry.ts';
import { schema, handler, stop_app_simLogic } from '../stop_app_sim.ts';
import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
import { EventEmitter } from 'node:events';
import { mkdtempSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import type { ChildProcess } from 'node:child_process';
import { setRuntimeInstanceForTests } from '../../../../utils/runtime-instance.ts';

function createTrackedChild(options?: {
pid?: number;
killImplementation?: (signal?: NodeJS.Signals | number) => boolean;
}): ChildProcess {
const emitter = new EventEmitter();
const child = emitter as ChildProcess;
let exitCode: number | null = null;
const pid = options?.pid ?? nextPid++;

Object.defineProperty(child, 'pid', { value: pid, configurable: true });
Object.defineProperty(child, 'exitCode', {
configurable: true,
get: () => exitCode,
set: (value: number | null) => {
exitCode = value;
},
});

trackedChildren.set(pid, child);

child.kill = vi.fn((signal?: NodeJS.Signals | number) => {
if (options?.killImplementation) {
return options.killImplementation(signal);
}
exitCode = 0;
queueMicrotask(() => {
emitter.emit('exit', 0, signal);
emitter.emit('close', 0, signal);
});
return true;
}) as ChildProcess['kill'];

return child;
}
Comment thread
cursor[bot] marked this conversation as resolved.

let registryDir: string;
let nextPid = 1234;
const trackedChildren = new Map<number, ChildProcess>();

describe('stop_app_sim tool', () => {
beforeEach(() => {
beforeEach(async () => {
nextPid = 1234;
trackedChildren.clear();
registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-stop-app-sim-'));
setSimulatorLaunchOsLogRegistryDirOverrideForTests(registryDir);
setRuntimeInstanceForTests({ instanceId: 'stop-app-sim-test', pid: process.pid });
setSimulatorLaunchOsLogRecordActiveOverrideForTests(async (record) => {
const child = trackedChildren.get(record.helperPid);
return child ? child.exitCode == null : true;
});
sessionStore.clear();
await clearAllSimulatorLaunchOsLogSessions();
});

afterEach(async () => {
sessionStore.clear();
await clearAllSimulatorLaunchOsLogSessions();
setSimulatorLaunchOsLogRecordActiveOverrideForTests(null);
setRuntimeInstanceForTests(null);
setSimulatorLaunchOsLogRegistryDirOverrideForTests(null);
trackedChildren.clear();
await rm(registryDir, { recursive: true, force: true });
});

describe('Export Field Validation (Literal)', () => {
Expand Down Expand Up @@ -89,6 +161,32 @@ describe('stop_app_sim tool', () => {
expect(text).toContain('test-uuid');
});

it('stops tracked OSLog sessions alongside the app', async () => {
const mockExecutor = createMockExecutor({ success: true, output: '' });
const child = createTrackedChild();
await registerSimulatorLaunchOsLogSession({
process: child,
simulatorUuid: 'test-uuid',
bundleId: 'io.sentry.App',
logFilePath: '/tmp/app.log',
});

const result = await runLogic(() =>
stop_app_simLogic(
{
simulatorId: 'test-uuid',
bundleId: 'io.sentry.App',
},
mockExecutor,
),
);

const text = allText(result);
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(text).toContain('stopped successfully');
expect(text).not.toContain('Tracked OSLog sessions cleaned up');
});

it('should display friendly name when simulatorName is provided alongside resolved simulatorId', async () => {
const mockExecutor = createMockExecutor({ success: true, output: '' });

Expand Down Expand Up @@ -117,6 +215,13 @@ describe('stop_app_sim tool', () => {
error: 'Simulator not found',
});

await registerSimulatorLaunchOsLogSession({
process: createTrackedChild(),
simulatorUuid: 'invalid-uuid',
bundleId: 'io.sentry.App',
logFilePath: '/tmp/app.log',
});

const result = await runLogic(() =>
stop_app_simLogic(
{
Expand All @@ -133,6 +238,35 @@ describe('stop_app_sim tool', () => {
expect(result.isError).toBe(true);
});

it('should report cleanup failures even when terminate succeeds', async () => {
const mockExecutor = createMockExecutor({ success: true, output: '' });
await registerSimulatorLaunchOsLogSession({
process: createTrackedChild({
killImplementation: () => {
throw new Error('cleanup boom');
},
}),
simulatorUuid: 'test-uuid',
bundleId: 'io.sentry.App',
logFilePath: '/tmp/app.log',
});

const result = await runLogic(() =>
stop_app_simLogic(
{
simulatorId: 'test-uuid',
bundleId: 'io.sentry.App',
},
mockExecutor,
),
);

const text = allText(result);
expect(text).toContain('OSLog cleanup failed');
expect(text).toContain('cleanup boom');
expect(result.isError).toBe(true);
});

it('should handle unexpected exceptions', async () => {
const throwingExecutor = async () => {
throw new Error('Unexpected error');
Expand Down
20 changes: 18 additions & 2 deletions src/mcp/tools/simulator/stop_app_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../../../utils/typed-tool-factory.ts';
import { withErrorHandling } from '../../../utils/tool-error-handling.ts';
import { header, statusLine } from '../../../utils/tool-event-builders.ts';
import { stopSimulatorLaunchOsLogSessionsForApp } from '../../../utils/log-capture/index.ts';

const baseSchemaObject = z.object({
simulatorId: z
Expand Down Expand Up @@ -57,10 +58,25 @@ export async function stop_app_simLogic(
async () => {
const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId];
const result = await executor(command, 'Stop App in Simulator', false);
const cleanupResult = await stopSimulatorLaunchOsLogSessionsForApp(
simulatorId,
params.bundleId,
1000,
);

if (!result.success || cleanupResult.errorCount > 0) {
const details: string[] = [];
if (!result.success) {
details.push(result.error ?? 'Unknown simulator terminate error');
}
if (cleanupResult.errorCount > 0) {
details.push(`OSLog cleanup failed: ${cleanupResult.errors.join('; ')}`);
}

if (!result.success) {
ctx.emit(headerEvent);
ctx.emit(statusLine('error', `Stop app in simulator operation failed: ${result.error}`));
ctx.emit(
statusLine('error', `Stop app in simulator operation failed: ${details.join(' | ')}`),
);
return;
}

Expand Down
Loading
Loading