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
101 changes: 23 additions & 78 deletions src/mcp/tools/simulator/__tests__/build_run_sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,109 +5,50 @@

import { describe, it, expect, beforeEach } from 'vitest';
import { z } from 'zod';
import {
createMockExecutor,
createMockFileSystemExecutor,
} from '../../../../test-utils/mock-executors.ts';
import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import buildRunSim, { build_run_simLogic } from '../build_run_sim.ts';

describe('build_run_sim tool', () => {
beforeEach(() => {
sessionStore.clear();
});

describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(buildRunSim.name).toBe('build_run_sim');
});

it('should have correct description', () => {
expect(buildRunSim.description).toBe(
"Builds and runs an app from a project or workspace on a specific simulator by UUID or name. Provide exactly one of projectPath or workspacePath, and exactly one of simulatorId or simulatorName. IMPORTANT: Requires either projectPath or workspacePath, plus scheme and either simulatorId or simulatorName. Example: build_run_sim({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16' })",
);
expect(buildRunSim.description).toBe('Builds and runs an app on an iOS simulator.');
});

it('should have handler function', () => {
expect(typeof buildRunSim.handler).toBe('function');
});

it('should have correct schema with required and optional fields', () => {
it('should expose only non-session fields in public schema', () => {
const schema = z.object(buildRunSim.schema);

// Valid inputs - workspace
expect(
schema.safeParse({
workspacePath: '/path/to/workspace',
scheme: 'MyScheme',
simulatorName: 'iPhone 16',
}).success,
).toBe(true);
expect(schema.safeParse({}).success).toBe(true);

// Valid inputs - project
expect(
schema.safeParse({
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyScheme',
simulatorName: 'iPhone 16',
}).success,
).toBe(true);

expect(
schema.safeParse({
workspacePath: '/path/to/workspace',
scheme: 'MyScheme',
simulatorName: 'iPhone 16',
configuration: 'Release',
derivedDataPath: '/path/to/derived',
extraArgs: ['--verbose'],
useLatestOS: true,
preferXcodebuild: false,
}).success,
).toBe(true);

// Invalid inputs - missing required fields
// Note: simulatorId/simulatorName are optional at schema level, XOR validation at runtime
expect(
schema.safeParse({
workspacePath: '/path/to/workspace',
scheme: 'MyScheme',
}).success,
).toBe(true); // Schema validation passes, runtime XOR validation would catch missing simulator fields

expect(
schema.safeParse({
projectPath: '/path/to/project.xcodeproj',
simulatorName: 'iPhone 16',
}).success,
).toBe(false);

expect(
schema.safeParse({
scheme: 'MyScheme',
simulatorName: 'iPhone 16',
}).success,
).toBe(true); // Base schema allows this, XOR validation happens in handler
expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false);
expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false);
expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false);

// Invalid types
expect(
schema.safeParse({
workspacePath: 123,
scheme: 'MyScheme',
simulatorName: 'iPhone 16',
}).success,
).toBe(false);

expect(
schema.safeParse({
workspacePath: '/path/to/workspace',
scheme: 123,
simulatorName: 'iPhone 16',
}).success,
).toBe(false);

expect(
schema.safeParse({
workspacePath: '/path/to/workspace',
scheme: 'MyScheme',
simulatorName: 123,
}).success,
).toBe(false);
const schemaKeys = Object.keys(buildRunSim.schema).sort();
expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort());
expect(schemaKeys).not.toContain('scheme');
expect(schemaKeys).not.toContain('simulatorName');
expect(schemaKeys).not.toContain('projectPath');
});
});

Expand Down Expand Up @@ -600,7 +541,8 @@ describe('build_run_sim tool', () => {
simulatorName: 'iPhone 16',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Either projectPath or workspacePath is required');
expect(result.content[0].text).toContain('Missing required session defaults');
expect(result.content[0].text).toContain('Provide a project or workspace');
});

it('should error when both projectPath and workspacePath provided', async () => {
Expand All @@ -611,7 +553,10 @@ describe('build_run_sim tool', () => {
simulatorName: 'iPhone 16',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('mutually exclusive');
expect(result.content[0].text).toContain('Parameter validation failed');
expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
expect(result.content[0].text).toContain('projectPath');
expect(result.content[0].text).toContain('workspacePath');
});

it('should succeed with only projectPath', async () => {
Expand Down
58 changes: 27 additions & 31 deletions src/mcp/tools/simulator/build_run_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { z } from 'zod';
import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts';
import { log } from '../../../utils/logging/index.ts';
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
import { createTextResponse } from '../../../utils/responses/index.ts';
import { executeXcodeBuildCommand } from '../../../utils/build/index.ts';
import type { CommandExecutor } from '../../../utils/execution/index.ts';
Expand Down Expand Up @@ -489,37 +490,32 @@ When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })
}
}

const publicSchemaObject = baseSchemaObject.omit({
projectPath: true,
workspacePath: true,
scheme: true,
configuration: true,
simulatorId: true,
simulatorName: true,
useLatestOS: true,
} as const);

export default {
name: 'build_run_sim',
description:
"Builds and runs an app from a project or workspace on a specific simulator by UUID or name. Provide exactly one of projectPath or workspacePath, and exactly one of simulatorId or simulatorName. IMPORTANT: Requires either projectPath or workspacePath, plus scheme and either simulatorId or simulatorName. Example: build_run_sim({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16' })",
schema: baseSchemaObject.shape, // MCP SDK compatibility
handler: async (args: Record<string, unknown>): Promise<ToolResponse> => {
try {
// Runtime validation with XOR constraints
const validatedParams = buildRunSimulatorSchema.parse(args);
return await build_run_simLogic(validatedParams, getDefaultCommandExecutor());
} catch (error) {
if (error instanceof z.ZodError) {
// Format validation errors in a user-friendly way
const errorMessages = error.errors.map((e) => {
const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root';
return `${path}: ${e.message}`;
});

return {
content: [
{
type: 'text',
text: `Parameter validation failed. Invalid parameters:\n${errorMessages.join('\n')}`,
},
],
isError: true,
};
}

// Re-throw unexpected errors
throw error;
}
},
description: 'Builds and runs an app on an iOS simulator.',
schema: publicSchemaObject.shape,
handler: createSessionAwareTool<BuildRunSimulatorParams>({
internalSchema: buildRunSimulatorSchema as unknown as z.ZodType<BuildRunSimulatorParams>,
logicFunction: build_run_simLogic,
getExecutor: getDefaultCommandExecutor,
requirements: [
{ allOf: ['scheme'], message: 'scheme is required' },
{ oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
{ oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
],
exclusivePairs: [
['projectPath', 'workspacePath'],
['simulatorId', 'simulatorName'],
],
}),
};
Loading