Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion src/mcp/tools/device/__tests__/launch_app_device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('launch_app_device plugin (device-shared)', () => {
const schemaObj = z.strictObject(schema);
expect(schemaObj.safeParse({}).success).toBe(true);
expect(schemaObj.safeParse({ bundleId: 'com.example.app' }).success).toBe(false);
expect(Object.keys(schema)).toEqual([]);
expect(Object.keys(schema).sort()).toEqual(['env']);
});

it('should validate schema with invalid inputs', () => {
Expand Down Expand Up @@ -134,6 +134,67 @@ describe('launch_app_device plugin (device-shared)', () => {
'com.apple.mobilesafari',
]);
});

it('should append a JSON --environment-variables payload before bundleId when env is provided', async () => {
const calls: any[] = [];
const mockExecutor = createMockExecutor({
success: true,
output: 'App launched successfully',
process: { pid: 12345 },
});

const trackingExecutor = async (command: string[]) => {
calls.push({ command });
return mockExecutor(command);
};

await launch_app_deviceLogic(
{
deviceId: 'test-device-123',
bundleId: 'com.example.app',
env: { STAGING_ENABLED: '1', DEBUG: 'true' },
},
trackingExecutor,
createMockFileSystemExecutor(),
);

expect(calls).toHaveLength(1);
const cmd = calls[0].command;
// bundleId should be the last element
expect(cmd[cmd.length - 1]).toBe('com.example.app');
// --environment-variables should be provided exactly once as JSON
const envFlagIndices = cmd
.map((part: string, index: number) => (part === '--environment-variables' ? index : -1))
.filter((index: number) => index >= 0);
expect(envFlagIndices).toHaveLength(1);
const envIdx = envFlagIndices[0];
expect(JSON.parse(cmd[envIdx + 1])).toEqual({ STAGING_ENABLED: '1', DEBUG: 'true' });
});

it('should not include --environment-variables when env is not provided', async () => {
const calls: any[] = [];
const mockExecutor = createMockExecutor({
success: true,
output: 'App launched successfully',
process: { pid: 12345 },
});

const trackingExecutor = async (command: string[]) => {
calls.push({ command });
return mockExecutor(command);
};

await launch_app_deviceLogic(
{
deviceId: 'test-device-123',
bundleId: 'com.example.app',
},
trackingExecutor,
createMockFileSystemExecutor(),
);

expect(calls[0].command).not.toContain('--environment-variables');
});
});

describe('Success Path Tests', () => {
Expand Down
42 changes: 26 additions & 16 deletions src/mcp/tools/device/launch_app_device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ type LaunchDataResponse = {
const launchAppDeviceSchema = z.object({
deviceId: z.string().describe('UDID of the device (obtained from list_devices)'),
bundleId: z.string(),
env: z
.record(z.string(), z.string())
.optional()
.describe('Environment variables to pass to the launched app (as key-value dictionary)'),
});

const publicSchemaObject = launchAppDeviceSchema.omit({
Expand All @@ -55,20 +59,27 @@ export async function launch_app_deviceLogic(
// Use JSON output to capture process ID
const tempJsonPath = join(fileSystem.tmpdir(), `launch-${Date.now()}.json`);

const command = [
'xcrun',
'devicectl',
'device',
'process',
'launch',
'--device',
deviceId,
'--json-output',
tempJsonPath,
'--terminate-existing',
];

if (params.env && Object.keys(params.env).length > 0) {
command.push('--environment-variables', JSON.stringify(params.env));
}

command.push(bundleId);

const result = await executor(
[
'xcrun',
'devicectl',
'device',
'process',
'launch',
'--device',
deviceId,
'--json-output',
tempJsonPath,
'--terminate-existing',
bundleId,
],
command,
'Launch app on device',
false, // useShell
undefined, // env
Expand Down Expand Up @@ -108,11 +119,10 @@ export async function launch_app_deviceLogic(
const launchData = parsedData as LaunchDataResponse;
processId = launchData.result?.process?.processIdentifier;
}

// Clean up temp file
await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {});
} catch (error) {
log('warn', `Failed to parse launch JSON output: ${error}`);
} finally {
await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {});
}

let responseText = `✅ App launched successfully\n\n${result.output}`;
Expand Down
75 changes: 68 additions & 7 deletions src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,19 @@ describe('launch_app_logs_sim tool', () => {

describe('Export Field Validation (Literal)', () => {
it('should expose only non-session fields in public schema', () => {
const schemaObj = z.object(schema);
const schemaObj = z.strictObject(schema);

expect(schemaObj.safeParse({}).success).toBe(true);
expect(schemaObj.safeParse({ args: ['--debug'] }).success).toBe(true);
expect(schemaObj.safeParse({ bundleId: 'com.example.app' }).success).toBe(true);
expect(schemaObj.safeParse({ bundleId: 42 }).success).toBe(true);
expect(schemaObj.safeParse({ bundleId: 'com.example.app' }).success).toBe(false);
expect(schemaObj.safeParse({ bundleId: 42 }).success).toBe(false);

expect(Object.keys(schema).sort()).toEqual(['args']);
expect(Object.keys(schema).sort()).toEqual(['args', 'env']);

const withSimId = schemaObj.safeParse({
simulatorId: 'abc123',
});
expect(withSimId.success).toBe(true);
expect('simulatorId' in (withSimId.data as Record<string, unknown>)).toBe(false);
expect(withSimId.success).toBe(false);
});
});

Expand Down Expand Up @@ -140,6 +139,68 @@ describe('launch_app_logs_sim tool', () => {
});
});

it('should pass env vars through to log capture function', async () => {
let capturedParams: unknown = null;
const logCaptureStub: LogCaptureFunction = async (params) => {
capturedParams = params;
return {
sessionId: 'test-session-789',
logFilePath: '/tmp/xcodemcp_sim_log_test-session-789.log',
processes: [],
error: undefined,
};
};

const mockExecutor = createMockExecutor({ success: true, output: '' });

await launch_app_logs_simLogic(
{
simulatorId: 'test-uuid-123',
bundleId: 'com.example.testapp',
env: { STAGING_ENABLED: '1' },
},
mockExecutor,
logCaptureStub,
);

expect(capturedParams).toEqual({
simulatorUuid: 'test-uuid-123',
bundleId: 'com.example.testapp',
captureConsole: true,
env: { STAGING_ENABLED: '1' },
});
});

it('should not include env in capture params when env is undefined', async () => {
let capturedParams: unknown = null;
const logCaptureStub: LogCaptureFunction = async (params) => {
capturedParams = params;
return {
sessionId: 'test-session-101',
logFilePath: '/tmp/xcodemcp_sim_log_test-session-101.log',
processes: [],
error: undefined,
};
};

const mockExecutor = createMockExecutor({ success: true, output: '' });

await launch_app_logs_simLogic(
{
simulatorId: 'test-uuid-123',
bundleId: 'com.example.testapp',
},
mockExecutor,
logCaptureStub,
);

expect(capturedParams).toEqual({
simulatorUuid: 'test-uuid-123',
bundleId: 'com.example.testapp',
captureConsole: true,
});
});

it('should surface log capture failure', async () => {
const logCaptureStub: LogCaptureFunction = async () => ({
sessionId: '',
Expand All @@ -163,7 +224,7 @@ describe('launch_app_logs_sim tool', () => {
content: [
{
type: 'text',
text: 'App was launched but log capture failed: Failed to start log capture',
text: 'Failed to launch app with log capture: Failed to start log capture',
},
],
isError: true,
Expand Down
88 changes: 87 additions & 1 deletion src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('launch_app_sim tool', () => {
expect(schemaObj.safeParse({ bundleId: 'com.example.testapp' }).success).toBe(false);
expect(schemaObj.safeParse({ bundleId: 123 }).success).toBe(false);

expect(Object.keys(schema).sort()).toEqual(['args']);
expect(Object.keys(schema).sort()).toEqual(['args', 'env']);

const withSimDefaults = schemaObj.safeParse({
simulatorId: 'sim-default',
Expand Down Expand Up @@ -346,5 +346,91 @@ describe('launch_app_sim tool', () => {
],
});
});

it('should pass env vars with SIMCTL_CHILD_ prefix to executor opts', async () => {
let callCount = 0;
const capturedOpts: (Record<string, unknown> | undefined)[] = [];

const sequencedExecutor = async (
command: string[],
_logPrefix?: string,
_useShell?: boolean,
opts?: { env?: Record<string, string> },
) => {
callCount++;
capturedOpts.push(opts);
if (callCount === 1) {
return {
success: true,
output: '/path/to/app/container',
error: '',
process: {} as any,
};
}
return {
success: true,
output: 'App launched successfully',
error: '',
process: {} as any,
};
};

await launch_app_simLogic(
{
simulatorId: 'test-uuid-123',
bundleId: 'com.example.testapp',
env: { STAGING_ENABLED: '1', DEBUG: 'true' },
},
sequencedExecutor,
);

// First call is get_app_container (no env), second is launch (with env)
expect(capturedOpts[1]).toEqual({
env: {
SIMCTL_CHILD_STAGING_ENABLED: '1',
SIMCTL_CHILD_DEBUG: 'true',
},
});
});

it('should not pass env opts when env is undefined', async () => {
let callCount = 0;
const capturedOpts: (Record<string, unknown> | undefined)[] = [];

const sequencedExecutor = async (
command: string[],
_logPrefix?: string,
_useShell?: boolean,
opts?: { env?: Record<string, string> },
) => {
callCount++;
capturedOpts.push(opts);
if (callCount === 1) {
return {
success: true,
output: '/path/to/app/container',
error: '',
process: {} as any,
};
}
return {
success: true,
output: 'App launched successfully',
error: '',
process: {} as any,
};
};

await launch_app_simLogic(
{
simulatorId: 'test-uuid-123',
bundleId: 'com.example.testapp',
},
sequencedExecutor,
);

// Launch call opts should be undefined when no env provided
expect(capturedOpts[1]).toBeUndefined();
});
});
});
16 changes: 15 additions & 1 deletion src/mcp/tools/simulator/launch_app_logs_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type LogCaptureFunction = (
bundleId: string;
captureConsole?: boolean;
args?: string[];
env?: Record<string, string>;
},
executor: CommandExecutor,
) => Promise<{ sessionId: string; logFilePath: string; processes: unknown[]; error?: string }>;
Expand All @@ -35,6 +36,12 @@ const baseSchemaObject = z.object({
),
bundleId: z.string().describe('Bundle identifier of the app to launch'),
args: z.array(z.string()).optional().describe('Optional arguments to pass to the app'),
env: z
.record(z.string(), z.string())
.optional()
.describe(
'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)',
),
});

// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId)
Expand All @@ -43,6 +50,12 @@ const internalSchemaObject = z.object({
simulatorName: z.string().optional(),
bundleId: z.string(),
args: z.array(z.string()).optional(),
env: z
.record(z.string(), z.string())
.optional()
.describe(
'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)',
),
});

type LaunchAppLogsSimParams = z.infer<typeof internalSchemaObject>;
Expand All @@ -67,12 +80,13 @@ export async function launch_app_logs_simLogic(
bundleId: params.bundleId,
captureConsole: true,
...(params.args && params.args.length > 0 ? { args: params.args } : {}),
...(params.env ? { env: params.env } : {}),
} as const;

const { sessionId, error } = await logCaptureFunction(captureParams, executor);
if (error) {
return {
content: [createTextContent(`App was launched but log capture failed: ${error}`)],
content: [createTextContent(`Failed to launch app with log capture: ${error}`)],
isError: true,
};
}
Expand Down
Loading