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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ ESM TypeScript project (`type: module`). Key layers:
- Final settled output MUST render from the final structured/domain result and next-step metadata. If final output needs data, add it to the final result type instead of reading it from fragments.
- Streaming-capable renderers may observe fragment callbacks only for live progress. Fragment handling must not affect final structured output or final settled text.

## Error Handling
- Structured errors (domain results with `didError`) are for domain errors only: failures in the user's build/test/device/simulator workflow (compile errors, test failures, missing destinations, etc.).
- System errors with the MCP server or CLI itself (invalid internal state, unresolvable configuration, infrastructure failures) must NOT be wrapped in structured domain results — let them surface as runtime tool errors so they are clearly distinguishable from workflow outcomes.

## Test Conventions
- Vitest with colocated `__tests__/` directories using `*.test.ts`
- Snapshot tests (`*.snapshot.test.ts`) must only assert generated tool output against fixtures. Move helper, parser, schema, setup, or behavior assertions to non-snapshot unit/integration tests.
Expand Down Expand Up @@ -139,6 +143,8 @@ Use these sections under `## [Unreleased]`:
- **External contributions**: `Added feature X ([#456](https://github.com/getsentry/XcodeBuildMCP/pull/456) by [@username](https://github.com/username))`

## Test Execution Rules
- **NEVER run the snapshot or smoke test suites without explicit user permission.** They are expensive (~7 min baseline, spawn real `xcodebuild`/`simctl`/`devicectl` processes and can wedge). This covers `npm run test:snapshot`, `npm run test:smoke`, and any direct `vitest run --config vitest.snapshot.config.ts` / `vitest.smoke.config.ts` invocation. Ask first, then run only if the user agrees.
- The default unit suite (`npm test` / `vitest run`), `npm run typecheck`, `npm run lint`, and `npm run build` are cheap and may be run freely without asking.
- When running long test suites (snapshot tests, smoke tests), ALWAYS write full output to a log file and read it afterwards. NEVER pipe through `tail` or `grep` directly — that loses output you may need to debug failures.
- Pattern: `DEVICE_ID=... npm run test:snapshot 2>&1 | tee /tmp/snapshot-results.txt` then read `/tmp/snapshot-results.txt` with the native read tool.
- If you need a summary, read the log file and grep/filter it — the full output is always preserved.
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ Use these sections under `## [Unreleased]`:
- Final settled output MUST render from the final structured/domain result and next-step metadata. If final output needs data, add it to the final result type instead of reading it from fragments.
- Streaming-capable renderers may observe fragment callbacks only for live progress. Fragment handling must not affect final structured output or final settled text.

## Error Handling
- Structured errors (domain results with `didError`) are for domain errors only: failures in the user's build/test/device/simulator workflow (compile errors, test failures, missing destinations, etc.).
- System errors with the MCP server or CLI itself (invalid internal state, unresolvable configuration, infrastructure failures) must NOT be wrapped in structured domain results — let them surface as runtime tool errors so they are clearly distinguishable from workflow outcomes.

## Test Execution Rules
- **NEVER run the snapshot or smoke test suites without explicit user permission.** They are expensive (~7 min baseline, spawn real `xcodebuild`/`simctl`/`devicectl` processes and can wedge). This covers `npm run test:snapshot`, `npm run test:smoke`, and any direct `vitest run --config vitest.snapshot.config.ts` / `vitest.smoke.config.ts` invocation. Ask first, then run only if the user agrees.
- The default unit suite (`npm test` / `vitest run`), `npm run typecheck`, `npm run lint`, and `npm run build` are cheap and may be run freely without asking.
- When running long test suites (snapshot tests, smoke tests), ALWAYS write full output to a log file and read it afterwards. NEVER pipe through `tail` or `grep` directly — that loses output you may need to debug failures.
- Pattern: `DEVICE_ID=... npm run test:snapshot 2>&1 | tee /tmp/snapshot-results.txt` then read `/tmp/snapshot-results.txt` with the native read tool.
- If you need a summary, read the log file and grep/filter it — the full output is always preserved.
Expand Down
71 changes: 71 additions & 0 deletions src/cli/commands/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,77 @@ describe('setup command', () => {
expect(parsed.sessionDefaults?.scheme).toBe('App');
expect(parsed.sessionDefaults?.deviceId).toBe('DEVICE-1');
expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1');
expect(parsed.sessionDefaults?.simulatorPlatform).toBe('iOS Simulator');
expect(result.changedFields).toContainEqual(
expect.stringContaining('sessionDefaults.simulatorPlatform'),
);
});

it('does not offer simulators whose runtime is unavailable', async () => {
const { fs, getStoredConfig, setTempFile } = createSetupFs();

const executor: CommandExecutor = async (command) => {
if (command[0] === 'xcrun' && command[1] === 'devicectl') {
setTempFile(command[5], mockDeviceListJson());
return createMockCommandResponse({ success: true, output: '' });
}

if (command.includes('--json')) {
// Unavailable device is listed first: without filtering the picker would
// select it. The available device must be chosen instead.
return createMockCommandResponse({
success: true,
output: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-26-5': [
{
name: 'iPhone 17 Pro',
udid: 'SIM-UNAVAILABLE',
state: 'Shutdown',
isAvailable: false,
},
],
'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [
{
name: 'iPhone 15',
udid: 'SIM-AVAILABLE',
state: 'Shutdown',
isAvailable: true,
},
],
},
}),
});
}

if (command[0] === 'xcrun') {
return createMockCommandResponse({
success: true,
output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-AVAILABLE) (Shutdown)`,
});
}

return createMockCommandResponse({
success: true,
output: `Information about workspace "App":\n Schemes:\n App`,
});
};

await runSetupWizard({
cwd,
fs,
executor,
prompter: createTestPrompter(),
quietOutput: true,
});

const parsed = parseYaml(getStoredConfig()) as {
sessionDefaults?: Record<string, unknown>;
};

expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-AVAILABLE');
expect(parsed.sessionDefaults?.simulatorName).toBe('iPhone 15');
expect(parsed.sessionDefaults?.simulatorPlatform).toBe('iOS Simulator');
});

it('shows debug-gated workflows when existing config enables debug', async () => {
Expand Down
19 changes: 17 additions & 2 deletions src/cli/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type { FileSystemExecutor } from '../../utils/FileSystemExecutor.ts';
import type { CommandExecutor } from '../../utils/CommandExecutor.ts';
import { createDoctorDependencies } from '../../mcp/tools/doctor/lib/doctor.deps.ts';
import { XcodePlatform } from '../../types/common.ts';
import { inferPlatformFromRuntime } from '../../utils/infer-platform.ts';
import type { SimulatorPlatform } from '../../utils/platform-detection.ts';

type SetupPlatform = WorkflowTargetPlatform;

Expand Down Expand Up @@ -54,6 +56,7 @@ interface SetupSelection {
deviceId?: string;
simulatorId?: string;
simulatorName?: string;
simulatorPlatform?: SimulatorPlatform;
clearDeviceDefault: boolean;
clearSimulatorDefault: boolean;
}
Expand Down Expand Up @@ -329,6 +332,11 @@ function getChangedFields(
beforeValue: beforeDefaults.simulatorName,
afterValue: afterDefaults.simulatorName,
},
{
label: 'sessionDefaults.simulatorPlatform',
beforeValue: beforeDefaults.simulatorPlatform,
afterValue: afterDefaults.simulatorPlatform,
},
{
label: 'setupPreferences.platforms',
beforeValue: beforeConfig?.setupPreferences?.platforms,
Expand Down Expand Up @@ -572,7 +580,7 @@ async function selectSimulator(opts: {
stopMessage: 'Simulators loaded.',
task: async () => {
try {
return await listSimulators(opts.executor);
return await listSimulators(opts.executor, { enabled: true });
} catch {
return [];
}
Expand Down Expand Up @@ -958,6 +966,9 @@ async function collectSetupSelection(
deviceId: device?.udid,
simulatorId: simulator?.udid,
simulatorName: simulator?.name,
simulatorPlatform: simulator
? (inferPlatformFromRuntime(simulator.runtime) ?? undefined)
: undefined,
clearDeviceDefault: isMacOsOnly || (requiresDeviceDefault(enabledWorkflows) && device == null),
clearSimulatorDefault:
isMacOsOnly || (requiresSimulatorDefault(enabledWorkflows) && simulator == null),
Expand Down Expand Up @@ -1001,6 +1012,9 @@ function selectionToMcpConfigJson(selection: SetupSelection): string {
if (selection.simulatorName) {
env.XCODEBUILDMCP_SIMULATOR_NAME = selection.simulatorName;
}
if (selection.simulatorPlatform) {
env.XCODEBUILDMCP_SIMULATOR_PLATFORM = selection.simulatorPlatform;
}

const mcpConfig = {
mcpServers: {
Expand Down Expand Up @@ -1093,7 +1107,7 @@ export async function runSetupWizard(deps?: Partial<SetupDependencies>): Promise
deleteSessionDefaultKeys.push('deviceId');
}
if (selection.clearSimulatorDefault) {
deleteSessionDefaultKeys.push('simulatorId', 'simulatorName');
deleteSessionDefaultKeys.push('simulatorId', 'simulatorName', 'simulatorPlatform');
}

const persistedProjectPath =
Expand All @@ -1119,6 +1133,7 @@ export async function runSetupWizard(deps?: Partial<SetupDependencies>): Promise
deviceId: selection.deviceId,
simulatorId: selection.simulatorId,
simulatorName: selection.simulatorName,
simulatorPlatform: selection.simulatorPlatform,
},
setupPreferences:
selection.platforms.length > 0 ? { platforms: [...selection.platforms] } : null,
Comment thread
cameroncooke marked this conversation as resolved.
Expand Down
11 changes: 11 additions & 0 deletions src/cli/register-tool-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { toCliJsonlEvent } from './jsonl-event.ts';
import { resolveSimulatorNameToId } from '../utils/simulator-resolver.ts';
import { getDefaultCommandExecutor } from '../utils/execution/index.ts';
import { sessionStore } from '../utils/session-store.ts';

export interface RegisterToolCommandsOptions {
workspaceRoot: string;
Expand Down Expand Up @@ -357,6 +358,16 @@ function registerToolSubcommand(
runtimeConfig: opts.runtimeConfig,
profileOverride,
});
// Mirror the MCP bootstrap: hydrate the in-process session store with the
// full resolved defaults so session-aware logic (e.g. platform inference's
// cached simulatorPlatform) sees fields that are not tool schema parameters.
// Unlike the MCP server, the CLI deliberately does NOT schedule the
// background simulator defaults refresh: CLI runs must stay deterministic
// for CI workflows/scripts, so a stale or foreign simulator id fails fast
// instead of being silently re-resolved to a different device.
if (Object.keys(rawDefaults).length > 0) {
sessionStore.setDefaults(rawDefaults);
}
Comment thread
cameroncooke marked this conversation as resolved.
const args = mergeCliSessionDefaults({
defaults: getCliSessionDefaultsForTool({
tool,
Expand Down
23 changes: 11 additions & 12 deletions src/mcp/tools/simulator/__tests__/build_sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,24 +142,23 @@ describe('build_sim tool', () => {
expect(result.content[0].text).toContain('simulatorName');
});

it('should handle empty simulatorName parameter', async () => {
it('should error when no resolvable simulator is provided (empty simulatorName)', async () => {
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'For iOS Simulator platform, either simulatorId or simulatorName must be provided',
});

const { result } = await runBuildSimLogic(
{
workspacePath: '/path/to/workspace',
scheme: 'MyScheme',
simulatorName: '',
},
mockExecutor,
);

expect(result.isError()).toBe(true);
expectPendingBuildResponse(result);
await expect(
runBuildSimLogic(
{
workspacePath: '/path/to/workspace',
scheme: 'MyScheme',
simulatorName: '',
},
mockExecutor,
),
).rejects.toThrow(/Unable to determine the simulator platform/);
});
});

Expand Down
7 changes: 3 additions & 4 deletions src/mcp/tools/simulator/build_run_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,9 @@ async function prepareBuildRunSimExecution(
);
const detectedPlatform = inferred.platform;
const configuration = params.configuration;
const displayPlatform =
params.simulatorId && inferred.source !== 'simulator-runtime'
? 'Simulator'
: String(detectedPlatform);
// Every successful inference source (cache, runtime, name heuristic) is
// authoritative, so the detected platform is always safe to display.
const displayPlatform = String(detectedPlatform);
const platformName = detectedPlatform.replace(' Simulator', '');

return {
Expand Down
10 changes: 10 additions & 0 deletions src/server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ export async function bootstrapServer(
}

if (Object.keys(syncedDefaults).length > 0) {
const currentDefaults = sessionStore.getAll();
const selectorChanged =
(syncedDefaults.simulatorId != null &&
syncedDefaults.simulatorId !== currentDefaults.simulatorId) ||
(syncedDefaults.simulatorName != null &&
syncedDefaults.simulatorName !== currentDefaults.simulatorName);
if (selectorChanged) {
// The cached platform belongs to the previous simulator selection.
sessionStore.clear(['simulatorPlatform']);
}
sessionStore.setDefaults(syncedDefaults);
log(
'info',
Expand Down
Loading
Loading