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
4 changes: 2 additions & 2 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Build & Run:
xcodebazelmcp install <path/to/App.app> [--simulator-id <UDID>]
xcodebazelmcp launch <bundleId> [--simulator-id <UDID>] [--launch-arg ...]
xcodebazelmcp stop <bundleId> [--simulator-name "..."]
xcodebazelmcp test <target> [--filter XCTestFilter] [--stream]
xcodebazelmcp coverage <target> [--filter XCTestFilter]
xcodebazelmcp test <target> [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator] [--stream]
xcodebazelmcp coverage <target> [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator]
xcodebazelmcp clean [--expunge] [--stream]
xcodebazelmcp app-path <target>
xcodebazelmcp bundle-id <path/to/App.app | //target>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ export function parseTest(args: string[]): TestArgs {
else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]);
else if (arg === '--startup-arg') parsed.startupArgs = append(parsed.startupArgs, args[++index]);
else if (arg === '--stream') (parsed as JsonObject).streaming = true;
else if (arg === '--minimize-simulator') parsed.minimizeSimulator = true;
else if (arg === '--shutdown-simulator') parsed.shutdownSimulatorAfterTest = true;
}
return parsed;
}
Expand Down
13 changes: 13 additions & 0 deletions src/core/simulators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
bootSimulator,
bootSimulatorIfNeeded,
clearStatusBar,
deleteSimulator,
eraseSimulator,
findAppBundle,
getSimulatorUiState,
Expand Down Expand Up @@ -379,6 +380,18 @@ describe('shutdownAllSimulators', () => {
});
});

describe('deleteSimulator', () => {
it('calls xcrun simctl delete', async () => {
mockRunCommand.mockResolvedValue(mockSuccess);
await deleteSimulator('ABC-123');
expect(mockRunCommand).toHaveBeenCalledWith('xcrun', ['simctl', 'delete', 'ABC-123'], {
cwd: process.cwd(),
timeoutSeconds: 30,
maxOutput: 50_000,
});
});
});

describe('eraseSimulator', () => {
it('calls xcrun simctl erase', async () => {
mockRunCommand.mockResolvedValue(mockSuccess);
Expand Down
8 changes: 8 additions & 0 deletions src/core/simulators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ export async function shutdownAllSimulators(): Promise<CommandResult> {
});
}

export async function deleteSimulator(udid: string): Promise<CommandResult> {
return runCommand('xcrun', ['simctl', 'delete', udid], {
cwd: process.cwd(),
timeoutSeconds: 30,
maxOutput: 50_000,
});
}

export async function eraseSimulator(udid: string): Promise<CommandResult> {
return runCommand('xcrun', ['simctl', 'erase', udid], {
cwd: process.cwd(),
Expand Down
195 changes: 195 additions & 0 deletions src/core/test-simulator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CommandResult } from '../types/index.js';
import { runCommand } from '../utils/process.js';
import * as simulators from './simulators.js';
import {
cleanupSimulatorsAfterTest,
formatSimulatorTestCleanup,
isTestSimulatorFlagEnabled,
withTestSimulatorHooks,
} from './test-simulator.js';

vi.mock('../utils/process.js', () => ({
runCommand: vi.fn(),
}));

vi.mock('./simulators.js', () => ({
listSimulators: vi.fn(),
shutdownSimulator: vi.fn(),
deleteSimulator: vi.fn(),
}));

const mockRunCommand = vi.mocked(runCommand);
const mockListSimulators = vi.mocked(simulators.listSimulators);
const mockShutdownSimulator = vi.mocked(simulators.shutdownSimulator);
const mockDeleteSimulator = vi.mocked(simulators.deleteSimulator);

const mockSuccess: CommandResult = {
command: 'xcrun',
args: ['simctl'],
exitCode: 0,
output: '',
durationMs: 10,
truncated: false,
};

describe('isTestSimulatorFlagEnabled', () => {
it('accepts boolean and string truthy values', () => {
expect(isTestSimulatorFlagEnabled(true)).toBe(true);
expect(isTestSimulatorFlagEnabled('true')).toBe(true);
expect(isTestSimulatorFlagEnabled(1)).toBe(true);
});

it('rejects falsy values', () => {
expect(isTestSimulatorFlagEnabled(false)).toBe(false);
expect(isTestSimulatorFlagEnabled(undefined)).toBe(false);
});
});

describe('cleanupSimulatorsAfterTest', () => {
beforeEach(() => {
vi.clearAllMocks();
mockShutdownSimulator.mockResolvedValue(mockSuccess);
mockDeleteSimulator.mockResolvedValue(mockSuccess);
mockRunCommand.mockResolvedValue(mockSuccess);
});

it('shuts down newly booted simulators and deletes BAZEL_TEST simulators', async () => {
mockListSimulators
.mockResolvedValueOnce({
command: mockSuccess,
devices: [
{
udid: 'NEW-1',
name: 'BAZEL_TEST_iPhone 11_26.3_abc',
state: 'Booted',
runtime: 'iOS 26.3',
isAvailable: true,
},
{
udid: 'OLD-1',
name: 'iPhone 15',
state: 'Booted',
runtime: 'iOS 18.0',
isAvailable: true,
},
],
})
.mockResolvedValueOnce({ command: mockSuccess, devices: [] });

const result = await cleanupSimulatorsAfterTest(new Set(['OLD-1']));

expect(mockShutdownSimulator).toHaveBeenCalledWith('NEW-1');
expect(mockDeleteSimulator).toHaveBeenCalledWith('NEW-1');
expect(mockShutdownSimulator).not.toHaveBeenCalledWith('OLD-1');
expect(result.shutDown).toHaveLength(1);
expect(result.deleted).toHaveLength(1);
expect(result.quitSimulatorApp).toBe(true);
});

it('leaves pre-booted non-BAZEL simulators alone', async () => {
mockListSimulators
.mockResolvedValueOnce({
command: mockSuccess,
devices: [
{
udid: 'OLD-1',
name: 'iPhone 15',
state: 'Booted',
runtime: 'iOS 18.0',
isAvailable: true,
},
],
})
.mockResolvedValueOnce({
command: mockSuccess,
devices: [
{
udid: 'OLD-1',
name: 'iPhone 15',
state: 'Booted',
runtime: 'iOS 18.0',
isAvailable: true,
},
],
});

const result = await cleanupSimulatorsAfterTest(new Set(['OLD-1']));

expect(mockShutdownSimulator).not.toHaveBeenCalled();
expect(result.shutDown).toHaveLength(0);
expect(result.quitSimulatorApp).toBe(false);
});
});

describe('formatSimulatorTestCleanup', () => {
it('describes shutdown and delete actions', () => {
const text = formatSimulatorTestCleanup({
shutDown: ['BAZEL_TEST_iPhone 11 (NEW-1)'],
deleted: ['BAZEL_TEST_iPhone 11 (NEW-1)'],
quitSimulatorApp: true,
});
expect(text).toContain('Simulator shutdown');
expect(text).toContain('Simulator deleted');
expect(text).toContain('Simulator.app quit');
});
});

describe('withTestSimulatorHooks', () => {
beforeEach(() => {
vi.useFakeTimers();
mockListSimulators.mockResolvedValue({ command: mockSuccess, devices: [] });
mockRunCommand.mockResolvedValue(mockSuccess);
mockShutdownSimulator.mockResolvedValue(mockSuccess);
mockDeleteSimulator.mockResolvedValue(mockSuccess);
});

afterEach(() => {
vi.useRealTimers();
});

it('runs cleanup after the wrapped command when shutdown is enabled', async () => {
mockListSimulators
.mockResolvedValueOnce({ command: mockSuccess, devices: [] })
.mockResolvedValueOnce({
command: mockSuccess,
devices: [
{
udid: 'NEW-1',
name: 'BAZEL_TEST_iPhone 11_26.3_abc',
state: 'Booted',
runtime: 'iOS 26.3',
isAvailable: true,
},
],
})
.mockResolvedValueOnce({ command: mockSuccess, devices: [] });

const wrapped = withTestSimulatorHooks(
{ shutdownSimulatorAfterTest: true },
async () => 'done',
);

await expect(wrapped).resolves.toEqual({
result: 'done',
cleanupSummary: expect.stringContaining('Simulator shutdown'),
});
expect(mockShutdownSimulator).toHaveBeenCalledWith('NEW-1');
});

it('polls minimize while the wrapped command runs', async () => {
let resolveRun: (value: string) => void = () => undefined;
const runPromise = new Promise<string>((resolve) => {
resolveRun = resolve;
});

const wrapped = withTestSimulatorHooks({ minimizeSimulator: true }, () => runPromise);
const resultPromise = wrapped;

await vi.advanceTimersByTimeAsync(2_000);
expect(mockRunCommand).toHaveBeenCalled();

resolveRun('done');
await expect(resultPromise).resolves.toEqual({ result: 'done', cleanupSummary: undefined });
});
});
114 changes: 114 additions & 0 deletions src/core/test-simulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { CommandResult, TestArgs } from '../types/index.js';
import { runCommand } from '../utils/process.js';
import { deleteSimulator, listSimulators, shutdownSimulator } from './simulators.js';

const BAZEL_TEST_SIMULATOR_PREFIX = 'BAZEL_TEST_';

export function isTestSimulatorFlagEnabled(value: unknown): boolean {
return value === true || value === 'true' || value === 1;
}

export async function snapshotBootedSimulatorUdids(): Promise<Set<string>> {
const { devices } = await listSimulators(true);
return new Set(devices.map((device) => device.udid));
}

export async function minimizeSimulatorWindows(): Promise<CommandResult> {
return runCommand(
'osascript',
['-e', 'tell application "Simulator" to set miniaturized of every window to true'],
{
cwd: process.cwd(),
timeoutSeconds: 5,
maxOutput: 5_000,
},
);
}

export function startMinimizeSimulatorPoller(intervalMs = 2_000): () => void {
const timer = setInterval(() => {
void minimizeSimulatorWindows();
}, intervalMs);
return () => clearInterval(timer);
}

export interface SimulatorTestCleanupResult {
shutDown: string[];
deleted: string[];
quitSimulatorApp: boolean;
}

export async function cleanupSimulatorsAfterTest(
bootedBefore: Set<string>,
): Promise<SimulatorTestCleanupResult> {
const { devices } = await listSimulators(true);
const shutDown: string[] = [];
const deleted: string[] = [];

for (const device of devices) {
const openedForTest =
!bootedBefore.has(device.udid) || device.name.startsWith(BAZEL_TEST_SIMULATOR_PREFIX);
if (!openedForTest) continue;

await shutdownSimulator(device.udid);
shutDown.push(`${device.name} (${device.udid})`);

if (device.name.startsWith(BAZEL_TEST_SIMULATOR_PREFIX)) {
await deleteSimulator(device.udid);
deleted.push(`${device.name} (${device.udid})`);
}
}

let quitSimulatorApp = false;
const { devices: remaining } = await listSimulators(true);
if (remaining.length === 0) {
const quitResult = await runCommand('osascript', ['-e', 'tell application "Simulator" to quit'], {
cwd: process.cwd(),
timeoutSeconds: 5,
maxOutput: 5_000,
});
quitSimulatorApp = quitResult.exitCode === 0;
}

return { shutDown, deleted, quitSimulatorApp };
}

export function formatSimulatorTestCleanup(result: SimulatorTestCleanupResult): string {
const lines: string[] = [];
if (result.shutDown.length > 0) {
lines.push(`Simulator shutdown: ${result.shutDown.join(', ')}`);
}
if (result.deleted.length > 0) {
lines.push(`Simulator deleted: ${result.deleted.join(', ')}`);
}
if (result.quitSimulatorApp) {
lines.push('Simulator.app quit (no booted devices remaining).');
}
if (lines.length === 0) {
lines.push('Simulator cleanup: no test simulators needed shutdown.');
}
return lines.join('\n');
}

export async function withTestSimulatorHooks<T>(
testArgs: TestArgs,
run: () => Promise<T>,
): Promise<{ result: T; cleanupSummary?: string }> {
const minimize = isTestSimulatorFlagEnabled(testArgs.minimizeSimulator);
const shutdownAfter = isTestSimulatorFlagEnabled(testArgs.shutdownSimulatorAfterTest);

const bootedBefore = shutdownAfter ? await snapshotBootedSimulatorUdids() : new Set<string>();
const stopMinimize = minimize ? startMinimizeSimulatorPoller() : undefined;

try {
const result = await run();
let cleanupSummary: string | undefined;
if (shutdownAfter) {
const cleanup = await cleanupSimulatorsAfterTest(bootedBefore);
cleanupSummary = formatSimulatorTestCleanup(cleanup);
}
return { result, cleanupSummary };
} finally {
stopMinimize?.();
}
}
Loading
Loading