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: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
### Fixed

- Fixed `suppressWarnings` being ignored in settled build, build-run, and test output. The flag was honored only while streaming, so warnings still reached the final MCP tool response ([#447](https://github.com/getsentry/XcodeBuildMCP/issues/447)).
- Fixed iOS scaffold orientation and device-family settings, LLDB command isolation and argument escaping, run-destination parsing without an active scheme, concurrent working-directory mutations, blocking physical-device name lookup, and unverified `xcodemake` downloads ([#459](https://github.com/getsentry/XcodeBuildMCP/issues/459)).

## [2.6.2]

Expand Down Expand Up @@ -693,4 +694,3 @@ Please note that the UI automation features are an early preview and currently i
- Initial release of XcodeBuildMCP
- Basic support for building iOS and macOS applications


Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { deviceFamiliesToNumeric, orientationToIOSConstant } from '../ios-scaffold-settings.ts';

describe('iOS scaffold settings', () => {
it.each([
['portrait', 'UIInterfaceOrientationPortrait'],
['portrait-upside-down', 'UIInterfaceOrientationPortraitUpsideDown'],
['landscape-left', 'UIInterfaceOrientationLandscapeLeft'],
['landscape-right', 'UIInterfaceOrientationLandscapeRight'],
] as const)('maps %s to its Info.plist constant', (orientation, expected) => {
expect(orientationToIOSConstant(orientation)).toBe(expected);
});

it.each([
[['iphone'], '1'],
[['ipad'], '2'],
[['iphone', 'ipad'], '1,2'],
[['universal'], '1,2'],
] as const)('maps device families %j to %s', (families, expected) => {
expect(deviceFamiliesToNumeric([...families])).toBe(expected);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,16 @@ describe('scaffold_ios_project plugin', () => {
await initConfigStoreForTest({ iosTemplatePath: '' });

let capturedCommands: string[][] = [];
let unzipOptions: unknown;
const trackingCommandExecutor = createMockExecutor({
success: true,
output: 'Command executed successfully',
});
const capturingExecutor = async (command: string[], ...args: any[]) => {
capturedCommands.push(command);
if (command[0] === 'unzip') {
unzipOptions = args[2];
}
return trackingCommandExecutor(command, ...args);
};

Expand Down Expand Up @@ -162,6 +166,9 @@ describe('scaffold_ios_project plugin', () => {
/https:\/\/github\.com\/getsentry\/XcodeBuildMCP-iOS-Template\/releases\/download\/v\d+\.\d+\.\d+\/XcodeBuildMCP-iOS-Template-\d+\.\d+\.\d+\.zip/,
),
]);
expect(unzipOptions).toEqual({
cwd: expect.stringMatching(/xcodebuild-mcp-template-/),
});

await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' });
});
Expand Down Expand Up @@ -242,6 +249,22 @@ describe('scaffold_ios_project plugin', () => {
});

it('should return success response with all optional parameters', async () => {
let writtenXCConfig: string | undefined;
const xcconfigFileSystem = createMockFileSystemExecutor({
existsSync: (path) => path.includes('/mock/template/path'),
readdir: async () => [
{ name: 'Project.xcconfig', isDirectory: () => false, isFile: () => true } as any,
],
readFile: async () =>
[
'TARGETED_DEVICE_FAMILY = old',
'INFOPLIST_KEY_UISupportedInterfaceOrientations = old',
'INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = old',
].join('\n'),
writeFile: async (_path, content) => {
writtenXCConfig = content;
},
});
const result = await runLogic(() =>
scaffold_ios_projectLogic(
{
Expand All @@ -258,13 +281,20 @@ describe('scaffold_ios_project plugin', () => {
supportedOrientationsIpad: ['portrait', 'landscape-left'],
},
mockCommandExecutor,
mockFileSystemExecutor,
xcconfigFileSystem,
),
);

expect(result.isError).toBeFalsy();
const text = allText(result);
expect(text).toContain('Project scaffolded successfully');
expect(writtenXCConfig).toContain('TARGETED_DEVICE_FAMILY = 1');
expect(writtenXCConfig).toContain(
'INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait',
);
expect(writtenXCConfig).toContain(
'INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft',
);
expect(result.nextStepParams).toEqual({
build_sim: {
workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace',
Expand Down
35 changes: 35 additions & 0 deletions src/mcp/tools/project-scaffolding/ios-scaffold-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export type IOSDeviceFamily = 'iphone' | 'ipad' | 'universal';

export type IOSOrientation =
| 'portrait'
| 'landscape-left'
| 'landscape-right'
| 'portrait-upside-down';

const ORIENTATION_CONSTANTS: Record<IOSOrientation, string> = {
portrait: 'UIInterfaceOrientationPortrait',
'portrait-upside-down': 'UIInterfaceOrientationPortraitUpsideDown',
'landscape-left': 'UIInterfaceOrientationLandscapeLeft',
'landscape-right': 'UIInterfaceOrientationLandscapeRight',
};

/** Converts a scaffold orientation token to its Info.plist build-setting constant. */
export function orientationToIOSConstant(orientation: IOSOrientation): string {
return ORIENTATION_CONSTANTS[orientation];
}

/** Converts scaffold device-family tokens to Xcode's numeric build-setting value. */
export function deviceFamiliesToNumeric(families: IOSDeviceFamily[]): string {
if (families.includes('universal')) {
return '1,2';
}

const numericFamilies = new Set<string>();
if (families.includes('iphone')) {
numericFamilies.add('1');
}
if (families.includes('ipad')) {
numericFamilies.add('2');
}
return [...numericFamilies].join(',');
}
52 changes: 13 additions & 39 deletions src/mcp/tools/project-scaffolding/scaffold_ios_project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import {
getHandlerContext,
} from '../../../utils/typed-tool-factory.ts';
import { createScaffoldDomainResult, setScaffoldStructuredOutput } from './domain-result.ts';
import {
deviceFamiliesToNumeric,
orientationToIOSConstant,
type IOSDeviceFamily,
type IOSOrientation,
} from './ios-scaffold-settings.ts';

const BaseScaffoldSchema = z.object({
projectName: z.string().min(1),
Expand All @@ -36,40 +42,6 @@ const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({
.optional(),
});

/**
* Convert orientation enum to iOS constant
*/
function orientationToIOSConstant(orientation: string): string {
switch (orientation) {
case 'Portrait':
return 'UIInterfaceOrientationPortrait';
case 'PortraitUpsideDown':
return 'UIInterfaceOrientationPortraitUpsideDown';
case 'LandscapeLeft':
return 'UIInterfaceOrientationLandscapeLeft';
case 'LandscapeRight':
return 'UIInterfaceOrientationLandscapeRight';
default:
return orientation;
}
}

/**
* Convert device family enum to numeric value
*/
function deviceFamilyToNumeric(family: string): string {
switch (family) {
case 'iPhone':
return '1';
case 'iPad':
return '2';
case 'iPhone+iPad':
return '1,2';
default:
return '1,2';
}
}

/**
* Update Package.swift file with deployment target
*/
Expand Down Expand Up @@ -114,9 +86,11 @@ function updateXCConfigFile(content: string, params: Record<string, unknown>): s
const currentProjectVersion = params.currentProjectVersion as string | undefined;
const platform = params.platform as string;
const deploymentTarget = params.deploymentTarget as string | undefined;
const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined;
const supportedOrientations = params.supportedOrientations as string[] | undefined;
const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined;
const targetedDeviceFamily = params.targetedDeviceFamily as IOSDeviceFamily[] | undefined;
const supportedOrientations = params.supportedOrientations as IOSOrientation[] | undefined;
const supportedOrientationsIpad = params.supportedOrientationsIpad as
| IOSOrientation[]
| undefined;

// Update project identity settings
result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`);
Expand Down Expand Up @@ -148,8 +122,8 @@ function updateXCConfigFile(content: string, params: Record<string, unknown>): s
}

// Device family
if (targetedDeviceFamily) {
const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily);
if (targetedDeviceFamily && targetedDeviceFamily.length > 0) {
const deviceFamilyValue = deviceFamiliesToNumeric(targetedDeviceFamily);
result = result.replace(
/TARGETED_DEVICE_FAMILY = .+/g,
`TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`,
Expand Down
95 changes: 95 additions & 0 deletions src/utils/__tests__/device-name-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetDeviceNameCacheForTests,
formatDeviceId,
resolveDeviceName,
type DeviceNameResolverDependencies,
} from '../device-name-resolver.ts';

const deviceId = 'device-identifier';
const udid = '00008110-0012345678901234';

function createDependencies(
overrides: Partial<DeviceNameResolverDependencies> = {},
): DeviceNameResolverDependencies {
return {
runDevicectl: vi.fn().mockResolvedValue(undefined),
readOutput: vi.fn().mockResolvedValue(
JSON.stringify({
result: {
devices: [
{
identifier: deviceId,
deviceProperties: { name: 'Cam’s iPhone' },
hardwareProperties: { udid },
},
],
},
}),
),
removeOutput: vi.fn().mockResolvedValue(undefined),
createOutputPath: () => '/tmp/devices.json',
now: () => 1_000,
...overrides,
};
}

describe('device name resolver', () => {
beforeEach(() => {
__resetDeviceNameCacheForTests();
});

it('loads device names asynchronously and resolves identifiers and UDIDs', async () => {
const deps = createDependencies();

await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone');
await expect(resolveDeviceName(udid, deps)).resolves.toBe('Cam’s iPhone');

expect(deps.runDevicectl).toHaveBeenCalledOnce();
expect(deps.runDevicectl).toHaveBeenCalledWith('/tmp/devices.json');
expect(deps.removeOutput).toHaveBeenCalledWith('/tmp/devices.json');
});

it('returns immediately while an asynchronous cache refresh is running', async () => {
let finishRefresh: (() => void) | undefined;
const runDevicectl = vi.fn(
() =>
new Promise<void>((resolve) => {
finishRefresh = resolve;
}),
);
const deps = createDependencies({ runDevicectl });

expect(formatDeviceId(deviceId, deps)).toBe(deviceId);
expect(runDevicectl).toHaveBeenCalledOnce();

finishRefresh?.();
await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone');
expect(formatDeviceId(deviceId, deps)).toBe(`Cam’s iPhone (${deviceId})`);
});

it('falls back to the device ID when devicectl fails', async () => {
const deps = createDependencies({
runDevicectl: vi.fn().mockRejectedValue(new Error('unavailable')),
});

await expect(resolveDeviceName(deviceId, deps)).resolves.toBeUndefined();
expect(formatDeviceId(deviceId, deps)).toBe(deviceId);
expect(deps.removeOutput).toHaveBeenCalledWith('/tmp/devices.json');
});

it('retains stale device names when a background refresh fails', async () => {
let now = 1_000;
const deps = createDependencies({ now: () => now });

await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone');
now = 31_001;
vi.mocked(deps.runDevicectl).mockRejectedValueOnce(new Error('unavailable'));

expect(formatDeviceId(deviceId, deps)).toBe(`Cam’s iPhone (${deviceId})`);
await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone');
await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone');
expect(formatDeviceId(deviceId, deps)).toBe(`Cam’s iPhone (${deviceId})`);
expect(deps.runDevicectl).toHaveBeenCalledTimes(3);
});
});
39 changes: 33 additions & 6 deletions src/utils/__tests__/nskeyedarchiver-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { describe, it, expect } from 'vitest';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { parseBuffer as bplistParseBuffer } from 'bplist-parser';

vi.mock('bplist-parser', () => ({
parseBuffer: vi.fn(),
}));
import {
parseXcuserstate,
parseXcuserstateBuffer,
Expand All @@ -8,6 +13,12 @@ import {
} from '../nskeyedarchiver-parser.ts';

describe('NSKeyedArchiver Parser', () => {
beforeEach(() => {
vi.mocked(bplistParseBuffer).mockImplementation(() => {
throw new Error('invalid plist');
});
});

describe('parseXcuserstate (file path)', () => {
it('returns empty result for non-existent file', () => {
const result = parseXcuserstate('/non/existent/file.xcuserstate');
Expand Down Expand Up @@ -97,11 +108,27 @@ describe('NSKeyedArchiver Parser', () => {
});

describe('edge cases', () => {
it('handles xcuserstate without ActiveScheme', () => {
// This would require a specially crafted test fixture
// For now, we just verify the function doesn't crash
const result = parseXcuserstateBuffer(Buffer.from('bplist00'));
expect(result).toEqual({});
it('extracts ActiveRunDestination without ActiveScheme', () => {
const simulatorId = '12345678-1234-1234-1234-123456789ABC';
vi.mocked(bplistParseBuffer).mockReturnValue([
{
$archiver: 'NSKeyedArchiver',
$objects: [
'$null',
'ActiveRunDestination',
'targetDeviceLocation',
{ 'NS.keys': [{ UID: 1 }], 'NS.objects': [{ UID: 4 }] },
{ 'NS.keys': [{ UID: 2 }], 'NS.objects': [{ UID: 5 }] },
`dvtdevice-iphonesimulator:${simulatorId}`,
],
},
]);

expect(parseXcuserstateBuffer(Buffer.from('plist'))).toEqual({
deviceLocation: `dvtdevice-iphonesimulator:${simulatorId}`,
simulatorId,
simulatorPlatform: 'iphonesimulator',
});
});

it('handles scheme object without IDENameString', () => {
Expand Down
Loading
Loading