Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 --environment-variables flags 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 flags should appear before bundleId
const envIdx1 = cmd.indexOf('--environment-variables');
expect(envIdx1).toBeGreaterThan(-1);
expect(cmd[envIdx1 + 1]).toBe('STAGING_ENABLED=1');
const envIdx2 = cmd.indexOf('--environment-variables', envIdx1 + 1);
expect(envIdx2).toBeGreaterThan(-1);
expect(cmd[envIdx2 + 1]).toBe('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
39 changes: 26 additions & 13 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 pairs)'),
});

const publicSchemaObject = launchAppDeviceSchema.omit({
Expand All @@ -55,20 +59,29 @@ 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) {
for (const [key, value] of Object.entries(params.env)) {
command.push('--environment-variables', `${key}=${value}`);
}
}

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
64 changes: 63 additions & 1 deletion src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('launch_app_logs_sim tool', () => {
expect(schemaObj.safeParse({ bundleId: 'com.example.app' }).success).toBe(true);
expect(schemaObj.safeParse({ bundleId: 42 }).success).toBe(true);

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

const withSimId = schemaObj.safeParse({
simulatorId: 'abc123',
Expand Down Expand Up @@ -140,6 +140,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 Down
89 changes: 88 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,92 @@ 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();
});

});
});
14 changes: 14 additions & 0 deletions 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,6 +80,7 @@ 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);
Expand Down
16 changes: 15 additions & 1 deletion src/mcp/tools/simulator/launch_app_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createSessionAwareTool,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
import { normalizeSimctlChildEnv } from '../../../utils/environment.ts';

const baseSchemaObject = z.object({
simulatorId: z
Expand All @@ -23,6 +24,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 @@ -31,6 +38,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)',
),
});

export type LaunchAppSimParams = z.infer<typeof internalSchemaObject>;
Expand Down Expand Up @@ -90,7 +103,8 @@ export async function launch_app_simLogic(
command.push(...params.args);
}

const result = await executor(command, 'Launch App in Simulator', false, undefined);
const execOpts = params.env ? { env: normalizeSimctlChildEnv(params.env) } : undefined;
const result = await executor(command, 'Launch App in Simulator', false, execOpts);

if (!result.success) {
return {
Expand Down
Loading