Skip to content

Commit 59ea274

Browse files
Merge pull request #6 from XcodeBazelMCP/maatheusgois-dd/test-simulator-lifecycle
Add simulator minimize and shutdown options for iOS tests
2 parents 55e4bb3 + 34df799 commit 59ea274

9 files changed

Lines changed: 406 additions & 21 deletions

File tree

src/cli/help.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ Build & Run:
1818
xcodebazelmcp install <path/to/App.app> [--simulator-id <UDID>]
1919
xcodebazelmcp launch <bundleId> [--simulator-id <UDID>] [--launch-arg ...]
2020
xcodebazelmcp stop <bundleId> [--simulator-name "..."]
21-
xcodebazelmcp test <target> [--filter XCTestFilter] [--stream]
22-
xcodebazelmcp coverage <target> [--filter XCTestFilter]
21+
xcodebazelmcp test <target> [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator] [--stream]
22+
xcodebazelmcp coverage <target> [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator]
2323
xcodebazelmcp clean [--expunge] [--stream]
2424
xcodebazelmcp app-path <target>
2525
xcodebazelmcp bundle-id <path/to/App.app | //target>

src/cli/parsers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ export function parseTest(args: string[]): TestArgs {
203203
else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]);
204204
else if (arg === '--startup-arg') parsed.startupArgs = append(parsed.startupArgs, args[++index]);
205205
else if (arg === '--stream') (parsed as JsonObject).streaming = true;
206+
else if (arg === '--minimize-simulator') parsed.minimizeSimulator = true;
207+
else if (arg === '--shutdown-simulator') parsed.shutdownSimulatorAfterTest = true;
206208
}
207209
return parsed;
208210
}

src/core/simulators.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
bootSimulator,
88
bootSimulatorIfNeeded,
99
clearStatusBar,
10+
deleteSimulator,
1011
eraseSimulator,
1112
findAppBundle,
1213
getSimulatorUiState,
@@ -379,6 +380,18 @@ describe('shutdownAllSimulators', () => {
379380
});
380381
});
381382

383+
describe('deleteSimulator', () => {
384+
it('calls xcrun simctl delete', async () => {
385+
mockRunCommand.mockResolvedValue(mockSuccess);
386+
await deleteSimulator('ABC-123');
387+
expect(mockRunCommand).toHaveBeenCalledWith('xcrun', ['simctl', 'delete', 'ABC-123'], {
388+
cwd: process.cwd(),
389+
timeoutSeconds: 30,
390+
maxOutput: 50_000,
391+
});
392+
});
393+
});
394+
382395
describe('eraseSimulator', () => {
383396
it('calls xcrun simctl erase', async () => {
384397
mockRunCommand.mockResolvedValue(mockSuccess);

src/core/simulators.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,14 @@ export async function shutdownAllSimulators(): Promise<CommandResult> {
191191
});
192192
}
193193

194+
export async function deleteSimulator(udid: string): Promise<CommandResult> {
195+
return runCommand('xcrun', ['simctl', 'delete', udid], {
196+
cwd: process.cwd(),
197+
timeoutSeconds: 30,
198+
maxOutput: 50_000,
199+
});
200+
}
201+
194202
export async function eraseSimulator(udid: string): Promise<CommandResult> {
195203
return runCommand('xcrun', ['simctl', 'erase', udid], {
196204
cwd: process.cwd(),

src/core/test-simulator.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { CommandResult } from '../types/index.js';
3+
import { runCommand } from '../utils/process.js';
4+
import * as simulators from './simulators.js';
5+
import {
6+
cleanupSimulatorsAfterTest,
7+
formatSimulatorTestCleanup,
8+
isTestSimulatorFlagEnabled,
9+
withTestSimulatorHooks,
10+
} from './test-simulator.js';
11+
12+
vi.mock('../utils/process.js', () => ({
13+
runCommand: vi.fn(),
14+
}));
15+
16+
vi.mock('./simulators.js', () => ({
17+
listSimulators: vi.fn(),
18+
shutdownSimulator: vi.fn(),
19+
deleteSimulator: vi.fn(),
20+
}));
21+
22+
const mockRunCommand = vi.mocked(runCommand);
23+
const mockListSimulators = vi.mocked(simulators.listSimulators);
24+
const mockShutdownSimulator = vi.mocked(simulators.shutdownSimulator);
25+
const mockDeleteSimulator = vi.mocked(simulators.deleteSimulator);
26+
27+
const mockSuccess: CommandResult = {
28+
command: 'xcrun',
29+
args: ['simctl'],
30+
exitCode: 0,
31+
output: '',
32+
durationMs: 10,
33+
truncated: false,
34+
};
35+
36+
describe('isTestSimulatorFlagEnabled', () => {
37+
it('accepts boolean and string truthy values', () => {
38+
expect(isTestSimulatorFlagEnabled(true)).toBe(true);
39+
expect(isTestSimulatorFlagEnabled('true')).toBe(true);
40+
expect(isTestSimulatorFlagEnabled(1)).toBe(true);
41+
});
42+
43+
it('rejects falsy values', () => {
44+
expect(isTestSimulatorFlagEnabled(false)).toBe(false);
45+
expect(isTestSimulatorFlagEnabled(undefined)).toBe(false);
46+
});
47+
});
48+
49+
describe('cleanupSimulatorsAfterTest', () => {
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
mockShutdownSimulator.mockResolvedValue(mockSuccess);
53+
mockDeleteSimulator.mockResolvedValue(mockSuccess);
54+
mockRunCommand.mockResolvedValue(mockSuccess);
55+
});
56+
57+
it('shuts down newly booted simulators and deletes BAZEL_TEST simulators', async () => {
58+
mockListSimulators
59+
.mockResolvedValueOnce({
60+
command: mockSuccess,
61+
devices: [
62+
{
63+
udid: 'NEW-1',
64+
name: 'BAZEL_TEST_iPhone 11_26.3_abc',
65+
state: 'Booted',
66+
runtime: 'iOS 26.3',
67+
isAvailable: true,
68+
},
69+
{
70+
udid: 'OLD-1',
71+
name: 'iPhone 15',
72+
state: 'Booted',
73+
runtime: 'iOS 18.0',
74+
isAvailable: true,
75+
},
76+
],
77+
})
78+
.mockResolvedValueOnce({ command: mockSuccess, devices: [] });
79+
80+
const result = await cleanupSimulatorsAfterTest(new Set(['OLD-1']));
81+
82+
expect(mockShutdownSimulator).toHaveBeenCalledWith('NEW-1');
83+
expect(mockDeleteSimulator).toHaveBeenCalledWith('NEW-1');
84+
expect(mockShutdownSimulator).not.toHaveBeenCalledWith('OLD-1');
85+
expect(result.shutDown).toHaveLength(1);
86+
expect(result.deleted).toHaveLength(1);
87+
expect(result.quitSimulatorApp).toBe(true);
88+
});
89+
90+
it('leaves pre-booted non-BAZEL simulators alone', async () => {
91+
mockListSimulators
92+
.mockResolvedValueOnce({
93+
command: mockSuccess,
94+
devices: [
95+
{
96+
udid: 'OLD-1',
97+
name: 'iPhone 15',
98+
state: 'Booted',
99+
runtime: 'iOS 18.0',
100+
isAvailable: true,
101+
},
102+
],
103+
})
104+
.mockResolvedValueOnce({
105+
command: mockSuccess,
106+
devices: [
107+
{
108+
udid: 'OLD-1',
109+
name: 'iPhone 15',
110+
state: 'Booted',
111+
runtime: 'iOS 18.0',
112+
isAvailable: true,
113+
},
114+
],
115+
});
116+
117+
const result = await cleanupSimulatorsAfterTest(new Set(['OLD-1']));
118+
119+
expect(mockShutdownSimulator).not.toHaveBeenCalled();
120+
expect(result.shutDown).toHaveLength(0);
121+
expect(result.quitSimulatorApp).toBe(false);
122+
});
123+
});
124+
125+
describe('formatSimulatorTestCleanup', () => {
126+
it('describes shutdown and delete actions', () => {
127+
const text = formatSimulatorTestCleanup({
128+
shutDown: ['BAZEL_TEST_iPhone 11 (NEW-1)'],
129+
deleted: ['BAZEL_TEST_iPhone 11 (NEW-1)'],
130+
quitSimulatorApp: true,
131+
});
132+
expect(text).toContain('Simulator shutdown');
133+
expect(text).toContain('Simulator deleted');
134+
expect(text).toContain('Simulator.app quit');
135+
});
136+
});
137+
138+
describe('withTestSimulatorHooks', () => {
139+
beforeEach(() => {
140+
vi.useFakeTimers();
141+
mockListSimulators.mockResolvedValue({ command: mockSuccess, devices: [] });
142+
mockRunCommand.mockResolvedValue(mockSuccess);
143+
mockShutdownSimulator.mockResolvedValue(mockSuccess);
144+
mockDeleteSimulator.mockResolvedValue(mockSuccess);
145+
});
146+
147+
afterEach(() => {
148+
vi.useRealTimers();
149+
});
150+
151+
it('runs cleanup after the wrapped command when shutdown is enabled', async () => {
152+
mockListSimulators
153+
.mockResolvedValueOnce({ command: mockSuccess, devices: [] })
154+
.mockResolvedValueOnce({
155+
command: mockSuccess,
156+
devices: [
157+
{
158+
udid: 'NEW-1',
159+
name: 'BAZEL_TEST_iPhone 11_26.3_abc',
160+
state: 'Booted',
161+
runtime: 'iOS 26.3',
162+
isAvailable: true,
163+
},
164+
],
165+
})
166+
.mockResolvedValueOnce({ command: mockSuccess, devices: [] });
167+
168+
const wrapped = withTestSimulatorHooks(
169+
{ shutdownSimulatorAfterTest: true },
170+
async () => 'done',
171+
);
172+
173+
await expect(wrapped).resolves.toEqual({
174+
result: 'done',
175+
cleanupSummary: expect.stringContaining('Simulator shutdown'),
176+
});
177+
expect(mockShutdownSimulator).toHaveBeenCalledWith('NEW-1');
178+
});
179+
180+
it('polls minimize while the wrapped command runs', async () => {
181+
let resolveRun: (value: string) => void = () => undefined;
182+
const runPromise = new Promise<string>((resolve) => {
183+
resolveRun = resolve;
184+
});
185+
186+
const wrapped = withTestSimulatorHooks({ minimizeSimulator: true }, () => runPromise);
187+
const resultPromise = wrapped;
188+
189+
await vi.advanceTimersByTimeAsync(2_000);
190+
expect(mockRunCommand).toHaveBeenCalled();
191+
192+
resolveRun('done');
193+
await expect(resultPromise).resolves.toEqual({ result: 'done', cleanupSummary: undefined });
194+
});
195+
});

src/core/test-simulator.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { CommandResult, TestArgs } from '../types/index.js';
2+
import { runCommand } from '../utils/process.js';
3+
import { deleteSimulator, listSimulators, shutdownSimulator } from './simulators.js';
4+
5+
const BAZEL_TEST_SIMULATOR_PREFIX = 'BAZEL_TEST_';
6+
7+
export function isTestSimulatorFlagEnabled(value: unknown): boolean {
8+
return value === true || value === 'true' || value === 1;
9+
}
10+
11+
export async function snapshotBootedSimulatorUdids(): Promise<Set<string>> {
12+
const { devices } = await listSimulators(true);
13+
return new Set(devices.map((device) => device.udid));
14+
}
15+
16+
export async function minimizeSimulatorWindows(): Promise<CommandResult> {
17+
return runCommand(
18+
'osascript',
19+
['-e', 'tell application "Simulator" to set miniaturized of every window to true'],
20+
{
21+
cwd: process.cwd(),
22+
timeoutSeconds: 5,
23+
maxOutput: 5_000,
24+
},
25+
);
26+
}
27+
28+
export function startMinimizeSimulatorPoller(intervalMs = 2_000): () => void {
29+
const timer = setInterval(() => {
30+
void minimizeSimulatorWindows();
31+
}, intervalMs);
32+
return () => clearInterval(timer);
33+
}
34+
35+
export interface SimulatorTestCleanupResult {
36+
shutDown: string[];
37+
deleted: string[];
38+
quitSimulatorApp: boolean;
39+
}
40+
41+
export async function cleanupSimulatorsAfterTest(
42+
bootedBefore: Set<string>,
43+
): Promise<SimulatorTestCleanupResult> {
44+
const { devices } = await listSimulators(true);
45+
const shutDown: string[] = [];
46+
const deleted: string[] = [];
47+
48+
for (const device of devices) {
49+
const openedForTest =
50+
!bootedBefore.has(device.udid) || device.name.startsWith(BAZEL_TEST_SIMULATOR_PREFIX);
51+
if (!openedForTest) continue;
52+
53+
await shutdownSimulator(device.udid);
54+
shutDown.push(`${device.name} (${device.udid})`);
55+
56+
if (device.name.startsWith(BAZEL_TEST_SIMULATOR_PREFIX)) {
57+
await deleteSimulator(device.udid);
58+
deleted.push(`${device.name} (${device.udid})`);
59+
}
60+
}
61+
62+
let quitSimulatorApp = false;
63+
const { devices: remaining } = await listSimulators(true);
64+
if (remaining.length === 0) {
65+
const quitResult = await runCommand('osascript', ['-e', 'tell application "Simulator" to quit'], {
66+
cwd: process.cwd(),
67+
timeoutSeconds: 5,
68+
maxOutput: 5_000,
69+
});
70+
quitSimulatorApp = quitResult.exitCode === 0;
71+
}
72+
73+
return { shutDown, deleted, quitSimulatorApp };
74+
}
75+
76+
export function formatSimulatorTestCleanup(result: SimulatorTestCleanupResult): string {
77+
const lines: string[] = [];
78+
if (result.shutDown.length > 0) {
79+
lines.push(`Simulator shutdown: ${result.shutDown.join(', ')}`);
80+
}
81+
if (result.deleted.length > 0) {
82+
lines.push(`Simulator deleted: ${result.deleted.join(', ')}`);
83+
}
84+
if (result.quitSimulatorApp) {
85+
lines.push('Simulator.app quit (no booted devices remaining).');
86+
}
87+
if (lines.length === 0) {
88+
lines.push('Simulator cleanup: no test simulators needed shutdown.');
89+
}
90+
return lines.join('\n');
91+
}
92+
93+
export async function withTestSimulatorHooks<T>(
94+
testArgs: TestArgs,
95+
run: () => Promise<T>,
96+
): Promise<{ result: T; cleanupSummary?: string }> {
97+
const minimize = isTestSimulatorFlagEnabled(testArgs.minimizeSimulator);
98+
const shutdownAfter = isTestSimulatorFlagEnabled(testArgs.shutdownSimulatorAfterTest);
99+
100+
const bootedBefore = shutdownAfter ? await snapshotBootedSimulatorUdids() : new Set<string>();
101+
const stopMinimize = minimize ? startMinimizeSimulatorPoller() : undefined;
102+
103+
try {
104+
const result = await run();
105+
let cleanupSummary: string | undefined;
106+
if (shutdownAfter) {
107+
const cleanup = await cleanupSimulatorsAfterTest(bootedBefore);
108+
cleanupSummary = formatSimulatorTestCleanup(cleanup);
109+
}
110+
return { result, cleanupSummary };
111+
} finally {
112+
stopMinimize?.();
113+
}
114+
}

0 commit comments

Comments
 (0)