diff --git a/src/cli/help.ts b/src/cli/help.ts index 79c1375..27490a3 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -18,8 +18,8 @@ Build & Run: xcodebazelmcp install [--simulator-id ] xcodebazelmcp launch [--simulator-id ] [--launch-arg ...] xcodebazelmcp stop [--simulator-name "..."] - xcodebazelmcp test [--filter XCTestFilter] [--stream] - xcodebazelmcp coverage [--filter XCTestFilter] + xcodebazelmcp test [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator] [--stream] + xcodebazelmcp coverage [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator] xcodebazelmcp clean [--expunge] [--stream] xcodebazelmcp app-path xcodebazelmcp bundle-id diff --git a/src/cli/parsers.ts b/src/cli/parsers.ts index 4262978..b7cc05a 100644 --- a/src/cli/parsers.ts +++ b/src/cli/parsers.ts @@ -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; } diff --git a/src/core/simulators.test.ts b/src/core/simulators.test.ts index 4360b02..7a6cb5c 100644 --- a/src/core/simulators.test.ts +++ b/src/core/simulators.test.ts @@ -7,6 +7,7 @@ import { bootSimulator, bootSimulatorIfNeeded, clearStatusBar, + deleteSimulator, eraseSimulator, findAppBundle, getSimulatorUiState, @@ -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); diff --git a/src/core/simulators.ts b/src/core/simulators.ts index 1503efb..55a670a 100644 --- a/src/core/simulators.ts +++ b/src/core/simulators.ts @@ -191,6 +191,14 @@ export async function shutdownAllSimulators(): Promise { }); } +export async function deleteSimulator(udid: string): Promise { + return runCommand('xcrun', ['simctl', 'delete', udid], { + cwd: process.cwd(), + timeoutSeconds: 30, + maxOutput: 50_000, + }); +} + export async function eraseSimulator(udid: string): Promise { return runCommand('xcrun', ['simctl', 'erase', udid], { cwd: process.cwd(), diff --git a/src/core/test-simulator.test.ts b/src/core/test-simulator.test.ts new file mode 100644 index 0000000..0490374 --- /dev/null +++ b/src/core/test-simulator.test.ts @@ -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((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 }); + }); +}); diff --git a/src/core/test-simulator.ts b/src/core/test-simulator.ts new file mode 100644 index 0000000..8965637 --- /dev/null +++ b/src/core/test-simulator.ts @@ -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> { + const { devices } = await listSimulators(true); + return new Set(devices.map((device) => device.udid)); +} + +export async function minimizeSimulatorWindows(): Promise { + 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, +): Promise { + 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( + testArgs: TestArgs, + run: () => Promise, +): Promise<{ result: T; cleanupSummary?: string }> { + const minimize = isTestSimulatorFlagEnabled(testArgs.minimizeSimulator); + const shutdownAfter = isTestSimulatorFlagEnabled(testArgs.shutdownSimulatorAfterTest); + + const bootedBefore = shutdownAfter ? await snapshotBootedSimulatorUdids() : new Set(); + 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?.(); + } +} diff --git a/src/tools/handlers/build.ts b/src/tools/handlers/build.ts index 045d771..c7eee10 100644 --- a/src/tools/handlers/build.ts +++ b/src/tools/handlers/build.ts @@ -11,6 +11,7 @@ import { simulatorArgs, testFilterArgs, } from '../../core/bazel.js'; +import { withTestSimulatorHooks } from '../../core/test-simulator.js'; import { bootSimulatorIfNeeded, findAppBundle, @@ -145,6 +146,14 @@ export const definitions: ToolDefinition[] = [ extraArgs: { type: 'array', items: { type: 'string' } }, timeoutSeconds: { type: 'number' }, streaming: { type: 'boolean', description: 'Stream test output incrementally via progress notifications.' }, + minimizeSimulator: { + type: 'boolean', + description: 'Minimize Simulator.app windows while the test runs.', + }, + shutdownSimulatorAfterTest: { + type: 'boolean', + description: 'Shutdown simulators opened for the test when it finishes (deletes BAZEL_TEST_* simulators).', + }, }, required: ['target'], }, @@ -259,6 +268,14 @@ export const definitions: ToolDefinition[] = [ startupArgs: { type: 'array', items: { type: 'string' } }, extraArgs: { type: 'array', items: { type: 'string' } }, timeoutSeconds: { type: 'number' }, + minimizeSimulator: { + type: 'boolean', + description: 'Minimize Simulator.app windows while the test runs.', + }, + shutdownSimulatorAfterTest: { + type: 'boolean', + description: 'Shutdown simulators opened for the test when it finishes (deletes BAZEL_TEST_* simulators).', + }, }, required: ['target'], }, @@ -369,13 +386,18 @@ export async function handle(name: string, args: JsonObject): Promise + runBazel( + bazelArgs, + numberOrUndefined(testArgs.timeoutSeconds) || 1_800, + asStringArray(testArgs.startupArgs, 'startupArgs'), + ), ); + const output = cleanupSummary + ? `${formatCommandResult(commandResult)}\n\n${cleanupSummary}` + : formatCommandResult(commandResult); return toolResult( - formatCommandResult(commandResult), + output, { ...structuredCommandResult(commandResult), target, testFilter: testArgs.testFilter }, commandResult.exitCode !== 0, ); @@ -462,10 +484,12 @@ export async function handle(name: string, args: JsonObject): Promise + runBazel( + bazelArgs, + numberOrUndefined(testArgs.timeoutSeconds) || 1_800, + asStringArray(testArgs.startupArgs, 'startupArgs'), + ), ); const coverageSummary: string[] = [formatCommandResult(commandResult)]; @@ -490,6 +514,9 @@ export async function handle(name: string, args: JsonObject): Promise => { + let finalResult: CommandResult | undefined; + for await (const chunk of runBazelStreaming(bazelArgs, timeout, startupArgs)) { + if ('stream' in chunk) { + onProgress(chunk.data); + } else { + finalResult = chunk; + } } - } + if (!finalResult) { + throw new Error('Command produced no result.'); + } + return finalResult; + }; - if (!finalResult) { - return toolText('Command produced no result.', true); + let finalResult: CommandResult; + let cleanupSummary: string | undefined; + if (name === 'bazel_ios_test') { + try { + const wrapped = await withTestSimulatorHooks(args as TestArgs, runStreaming); + finalResult = wrapped.result; + cleanupSummary = wrapped.cleanupSummary; + } catch (err) { + return toolText((err as Error).message, true); + } + } else { + try { + finalResult = await runStreaming(); + } catch (err) { + return toolText((err as Error).message, true); + } } if (name === 'bazel_ios_build_and_run' && finalResult.exitCode === 0) { @@ -100,7 +121,10 @@ export async function callBazelToolStreaming( return handleDeviceBuildAndRunPostBuild(args as BuildArgs, finalResult); } - return toolText(formatCommandResult(finalResult), finalResult.exitCode !== 0); + const output = cleanupSummary + ? `${formatCommandResult(finalResult)}\n\n${cleanupSummary}` + : formatCommandResult(finalResult); + return toolText(output, finalResult.exitCode !== 0); } async function streamSwiftPackageTool( diff --git a/src/types/index.ts b/src/types/index.ts index 33eb80b..d804a74 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,6 +97,8 @@ export interface TestArgs extends JsonObject { configs?: string[]; extraArgs?: string[]; timeoutSeconds?: number; + minimizeSimulator?: boolean; + shutdownSimulatorAfterTest?: boolean; } export interface QueryArgs extends JsonObject {