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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

- Added `xcodebuildmcp upgrade` command to check for updates and upgrade in place. Supports `--check` (report-only) and `--yes`/`-y` (skip confirmation). Detects install method (Homebrew, npm-global, npx) and queries the appropriate channel source (`brew info`, `npm view`, or GitHub Releases) for the latest version. Non-interactive environments exit 1 when an auto-upgrade is possible but `--yes` was not supplied.
- Added platform selection step to the `xcodebuildmcp setup` wizard. You now choose which platforms you are developing for (macOS, iOS, tvOS, watchOS, visionOS) before selecting workflows. Based on the selection, the wizard automatically recommends the appropriate workflow set ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)).
- Added `XCODEBUILDMCP_CWD` environment variable to override the working directory used for project-config discovery and path resolution. Useful when the MCP server is launched by a host that can configure environment variables but not the spawn directory (e.g. MCP Inspector). Supports `~/` expansion. Programmatic `cwd` (passed via the bootstrap API) takes precedence; falls back to the original directory with a warning if the path can't be entered.
- Added per-test timing output for test runs ([#339](https://github.com/getsentry/XcodeBuildMCP/pull/339) by [@codeman9](https://github.com/codeman9)). Per-test results are always included in JSON/structured output as a new `testCases` field on `xcodebuildmcp.output.test-result` (suite, test, status, durationMs). Text rendering is opt-in: set the `showTestTiming` config option (or `XCODEBUILDMCP_SHOW_TEST_TIMING=1`) to render a `Test Results:` block of passed/failed/skipped cases with durations before the test summary. Works uniformly across CLI, JSON, and MCP output. Parameterized Swift Testing groups still surface as a single aggregate entry because xcodebuild does not emit per-case names or durations for them.

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
},
"currentProfile": {
"type": "string"
},
"persisted": {
"type": "boolean"
}
},
"required": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,36 @@
}
},
"required": []
},
"testCases": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"suite": {
"type": "string"
},
"test": {
"type": "string"
},
"status": {
"enum": [
"passed",
"failed",
"skipped"
]
},
"durationMs": {
"type": "integer",
"minimum": 0
}
},
"required": [
"test",
"status"
]
}
}
},
"required": [
Expand Down
1 change: 1 addition & 0 deletions src/cli/__tests__/register-tool-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const baseRuntimeConfig: ResolvedRuntimeConfig = {
experimentalWorkflowDiscovery: false,
disableSessionDefaults: true,
disableXcodeAutoSync: false,
showTestTiming: false,
uiDebuggerGuardMode: 'error',
incrementalBuildsEnabled: false,
dapRequestTimeoutMs: 30_000,
Expand Down
1 change: 1 addition & 0 deletions src/cli/__tests__/session-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('CLI session defaults', () => {
experimentalWorkflowDiscovery: false,
disableSessionDefaults: true,
disableXcodeAutoSync: false,
showTestTiming: false,
uiDebuggerGuardMode: 'error',
incrementalBuildsEnabled: false,
dapRequestTimeoutMs: 30_000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
schema,
sessionUseDefaultsProfileLogic,
} from '../session_use_defaults_profile.ts';
import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
import {
allText,
createMockToolHandlerContext,
runLogic,
} from '../../../../test-utils/test-helpers.ts';

describe('session-use-defaults-profile tool', () => {
beforeEach(() => {
Expand Down Expand Up @@ -83,11 +87,11 @@ describe('session-use-defaults-profile tool', () => {
sessionStore.setActiveProfile('ios');
sessionStore.setActiveProfile(null);

const result = await runLogic(() =>
sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }),
);
expect(result.isError).toBeFalsy();
expect(allText(result)).toContain('Persisted active profile selection');
const { ctx, result, run } = createMockToolHandlerContext();
await run(() => sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }));
expect(result.isError()).toBe(false);
expect(result.text()).toContain('Persisted active profile selection');
expect(ctx.structuredOutput?.result).toMatchObject({ persisted: true });
expect(writes).toHaveLength(1);
const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string };
expect(parsed.activeSessionDefaultsProfile).toBe('ios');
Expand Down
10 changes: 10 additions & 0 deletions src/rendering/render-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ export interface TestFailureRenderItem {
durationMs?: number;
}

export interface TestCaseResultRenderItem {
type: 'test-case-result';
operation: 'TEST';
suite?: string;
test: string;
status: 'passed' | 'failed' | 'skipped';
durationMs?: number;
}

export interface SummaryRenderItem {
type: 'summary';
operation?: XcodebuildOperation;
Expand Down Expand Up @@ -134,4 +143,5 @@ export type RenderItem =
| TestDiscoveryRenderItem
| TestProgressRenderItem
| TestFailureRenderItem
| TestCaseResultRenderItem
| SummaryRenderItem;
13 changes: 12 additions & 1 deletion src/rendering/render.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AnyFragment } from '../types/domain-fragments.ts';
import type { NextStep } from '../types/common.ts';
import { sessionStore } from '../utils/session-store.ts';
import { getConfig } from '../utils/config-store.ts';
import {
createCliTextRenderer,
renderCliTextTranscript,
Expand Down Expand Up @@ -103,18 +104,21 @@ function createBaseRenderSession(hooks: RenderSessionHooks): RenderSession {

function createTextRenderSession(): RenderSession {
const suppressWarnings = sessionStore.get('suppressWarnings');
const showTestTiming = getConfig().showTestTiming;

return createBaseRenderSession({
finalize: (input) =>
renderCliTextTranscript({
...input,
suppressWarnings: suppressWarnings ?? false,
showTestTiming,
}),
});
}

function createRawRenderSession(): RenderSession {
const suppressWarnings = sessionStore.get('suppressWarnings');
const showTestTiming = getConfig().showTestTiming;

return createBaseRenderSession({
onEmit: (fragment) => {
Expand All @@ -136,6 +140,7 @@ function createRawRenderSession(): RenderSession {
nextSteps: input.nextSteps,
nextStepsRuntime: input.nextStepsRuntime,
suppressWarnings: suppressWarnings ?? false,
showTestTiming,
});
if (text) {
process.stdout.write(text);
Expand All @@ -146,7 +151,13 @@ function createRawRenderSession(): RenderSession {
}

function createCliTextRenderSession(options: { interactive: boolean }): RenderSession {
const renderer = createCliTextRenderer(options);
const suppressWarnings = sessionStore.get('suppressWarnings');
const showTestTiming = getConfig().showTestTiming;
const renderer = createCliTextRenderer({
...options,
suppressWarnings: suppressWarnings ?? false,
showTestTiming,
});

return createBaseRenderSession({
onEmit: (fragment) => renderer.onFragment(fragment),
Expand Down
65 changes: 64 additions & 1 deletion src/runtime/__tests__/bootstrap-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import os from 'node:os';

const { scheduleSimulatorDefaultsRefreshMock } = vi.hoisted(() => ({
scheduleSimulatorDefaultsRefreshMock: vi.fn(),
Expand Down Expand Up @@ -143,4 +144,66 @@ describe('bootstrapRuntime', () => {
expect(sessionStore.getAll().scheme).toBe('IOSScheme');
expect(sessionStore.getAll().simulatorName).toBe('iPhone 17');
});

describe('XCODEBUILDMCP_CWD env override', () => {
let chdirSpy: ReturnType<typeof vi.spyOn> | null = null;
let originalEnvValue: string | undefined;

beforeEach(() => {
originalEnvValue = process.env.XCODEBUILDMCP_CWD;
chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => undefined);
});

afterEach(() => {
chdirSpy?.mockRestore();
chdirSpy = null;
if (originalEnvValue === undefined) {
delete process.env.XCODEBUILDMCP_CWD;
} else {
process.env.XCODEBUILDMCP_CWD = originalEnvValue;
}
});

it('chdirs to env-var value when opts.cwd is undefined', async () => {
process.env.XCODEBUILDMCP_CWD = '/explicit/project/dir';
await bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() });
expect(chdirSpy).toHaveBeenCalledWith('/explicit/project/dir');
});

it('does not chdir when opts.cwd is provided (caller wins)', async () => {
process.env.XCODEBUILDMCP_CWD = '/should/be/ignored';
await bootstrapRuntime({ runtime: 'cli', cwd, fs: createFsWithSessionDefaults() });
expect(chdirSpy).not.toHaveBeenCalled();
});

it('expands a leading ~/ to the home directory', async () => {
process.env.XCODEBUILDMCP_CWD = '~/Developer/project';
await bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() });
const calledWith = chdirSpy?.mock.calls[0]?.[0] as string;
expect(calledWith.endsWith('/Developer/project')).toBe(true);
expect(calledWith.startsWith('~')).toBe(false);
});

it('expands a bare ~ to the home directory', async () => {
process.env.XCODEBUILDMCP_CWD = '~';
await bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() });
expect(chdirSpy).toHaveBeenCalledWith(os.homedir());
});

it('falls back gracefully when chdir throws', async () => {
process.env.XCODEBUILDMCP_CWD = '/nonexistent';
chdirSpy?.mockImplementation(() => {
throw new Error('ENOENT');
});
await expect(
bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() }),
).resolves.toBeDefined();
});

it('is a no-op when env var is unset', async () => {
delete process.env.XCODEBUILDMCP_CWD;
await bootstrapRuntime({ runtime: 'cli', cwd, fs: createFsWithSessionDefaults() });
expect(chdirSpy).not.toHaveBeenCalled();
});
});
});
22 changes: 22 additions & 0 deletions src/runtime/bootstrap-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getDefaultFileSystemExecutor } from '../utils/command.ts';
import { log } from '../utils/logger.ts';
import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts';
import { scheduleSimulatorDefaultsRefresh } from '../utils/simulator-defaults-refresh.ts';
import { expandHomePrefix } from '../utils/path.ts';

export type RuntimeKind = 'cli' | 'daemon' | 'mcp';

Expand Down Expand Up @@ -94,10 +95,31 @@ function logHydrationResult(hydration: MCPSessionHydrationResult): void {
);
}

function resolveCwdOverride(): string | undefined {
const raw = process.env.XCODEBUILDMCP_CWD;
if (!raw) {
return undefined;
}
return expandHomePrefix(raw);
}
Comment thread
cursor[bot] marked this conversation as resolved.

export async function bootstrapRuntime(
opts: BootstrapRuntimeOptions,
): Promise<BootstrapRuntimeResult> {
process.env.XCODEBUILDMCP_RUNTIME = opts.runtime;
const cwdOverride = opts.cwd === undefined ? resolveCwdOverride() : undefined;
if (cwdOverride !== undefined) {
try {
process.chdir(cwdOverride);
} catch (error) {
log(
'warn',
`XCODEBUILDMCP_CWD points at "${cwdOverride}" but chdir failed: ${
error instanceof Error ? error.message : String(error)
}. Falling back to existing cwd.`,
);
}
}
const cwd = opts.cwd ?? process.cwd();
const fs = opts.fs ?? getDefaultFileSystemExecutor();

Expand Down
Loading
Loading