Skip to content

Commit e6c80c5

Browse files
committed
fix(setup): Tolerate optional discovery failures
Allow setup to continue when optional simulator or device discovery fails instead of aborting the wizard. Catch rejecting simulator and xctrace lookups, and treat injected filesystem temp-path failures the same way so optional defaults can still be skipped during setup.
1 parent 23e34fd commit e6c80c5

File tree

2 files changed

+192
-25
lines changed

2 files changed

+192
-25
lines changed

src/cli/commands/__tests__/setup.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,156 @@ sessionDefaults:
741741
expect(parsed.sessionDefaults?.simulatorName).toBeUndefined();
742742
});
743743

744+
it('continues setup with no default device when xctrace spawn fails', async () => {
745+
const { fs, getStoredConfig } = createSetupFs();
746+
747+
const executor: CommandExecutor = async (command) => {
748+
if (command[0] === 'xcrun' && command[1] === 'devicectl') {
749+
throw new Error('devicectl unavailable');
750+
}
751+
752+
if (command[0] === 'xcrun' && command[1] === 'xctrace') {
753+
throw new Error('xctrace spawn failed');
754+
}
755+
756+
if (command.includes('--json')) {
757+
return createMockCommandResponse({
758+
success: true,
759+
output: JSON.stringify({
760+
devices: {
761+
'iOS 17.0': [
762+
{
763+
name: 'iPhone 15',
764+
udid: 'SIM-1',
765+
state: 'Shutdown',
766+
isAvailable: true,
767+
},
768+
],
769+
},
770+
}),
771+
});
772+
}
773+
774+
return createMockCommandResponse({
775+
success: true,
776+
output: `Information about workspace "App":\n Schemes:\n App`,
777+
});
778+
};
779+
780+
const prompter: Prompter = {
781+
selectOne: async <T>(opts: { options: Array<{ value: T }> }) => opts.options[0].value,
782+
selectMany: async <T>(opts: { options: Array<{ value: T }> }) => {
783+
const loggingOption = opts.options.find((option) => option.value === ('logging' as T));
784+
return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value);
785+
},
786+
confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue,
787+
};
788+
789+
await runSetupWizard({
790+
cwd,
791+
fs,
792+
executor,
793+
prompter,
794+
quietOutput: true,
795+
});
796+
797+
const parsed = parseYaml(getStoredConfig()) as {
798+
sessionDefaults?: Record<string, unknown>;
799+
};
800+
801+
expect(parsed.sessionDefaults?.deviceId).toBeUndefined();
802+
expect(parsed.sessionDefaults?.simulatorId).toBeUndefined();
803+
expect(parsed.sessionDefaults?.simulatorName).toBeUndefined();
804+
});
805+
806+
it('continues setup with no default device when temp path creation fails', async () => {
807+
const { fs, getStoredConfig } = createSetupFs();
808+
fs.tmpdir = () => {
809+
throw new Error('tmpdir unavailable');
810+
};
811+
812+
const executor: CommandExecutor = async (command) => {
813+
if (command[0] === 'xcrun' && command[1] === 'xctrace') {
814+
throw new Error('xctrace spawn failed');
815+
}
816+
817+
return createMockCommandResponse({
818+
success: true,
819+
output: `Information about workspace "App":\n Schemes:\n App`,
820+
});
821+
};
822+
823+
const prompter: Prompter = {
824+
selectOne: async <T>(opts: { options: Array<{ value: T }> }) => opts.options[0].value,
825+
selectMany: async <T>(opts: { options: Array<{ value: T }> }) => {
826+
const loggingOption = opts.options.find((option) => option.value === ('logging' as T));
827+
return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value);
828+
},
829+
confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue,
830+
};
831+
832+
await runSetupWizard({
833+
cwd,
834+
fs,
835+
executor,
836+
prompter,
837+
quietOutput: true,
838+
});
839+
840+
const parsed = parseYaml(getStoredConfig()) as {
841+
sessionDefaults?: Record<string, unknown>;
842+
};
843+
844+
expect(parsed.sessionDefaults?.deviceId).toBeUndefined();
845+
expect(parsed.sessionDefaults?.simulatorId).toBeUndefined();
846+
expect(parsed.sessionDefaults?.simulatorName).toBeUndefined();
847+
});
848+
849+
it('continues setup with no default simulator when simulator discovery fails', async () => {
850+
const { fs, getStoredConfig } = createSetupFs();
851+
852+
const executor: CommandExecutor = async (command) => {
853+
if (command[0] === 'xcrun' && command[1] === 'devicectl') {
854+
throw new Error('device lookup should not run for simulator-only workflows');
855+
}
856+
857+
if (command[0] === 'xcrun' && command[1] === 'simctl') {
858+
throw new Error('simctl unavailable');
859+
}
860+
861+
return createMockCommandResponse({
862+
success: true,
863+
output: `Information about workspace "App":\n Schemes:\n App`,
864+
});
865+
};
866+
867+
const prompter: Prompter = {
868+
selectOne: async <T>(opts: { options: Array<{ value: T }> }) => opts.options[0].value,
869+
selectMany: async <T>(opts: { options: Array<{ value: T }> }) => {
870+
const simulatorOption = opts.options.find((option) => option.value === ('simulator' as T));
871+
return simulatorOption
872+
? [simulatorOption.value]
873+
: opts.options.map((option) => option.value);
874+
},
875+
confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue,
876+
};
877+
878+
await runSetupWizard({
879+
cwd,
880+
fs,
881+
executor,
882+
prompter,
883+
quietOutput: true,
884+
});
885+
886+
const parsed = parseYaml(getStoredConfig()) as {
887+
sessionDefaults?: Record<string, unknown>;
888+
};
889+
890+
expect(parsed.sessionDefaults?.simulatorId).toBeUndefined();
891+
expect(parsed.sessionDefaults?.simulatorName).toBeUndefined();
892+
});
893+
744894
it('continues setup with no default simulator when no simulators are available', async () => {
745895
const { fs, getStoredConfig } = createSetupFs();
746896

src/cli/commands/setup.ts

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,13 @@ async function selectSimulator(opts: {
379379
quietOutput: opts.quietOutput,
380380
startMessage: 'Loading simulators...',
381381
stopMessage: 'Simulators loaded.',
382-
task: () => listSimulators(opts.executor),
382+
task: async () => {
383+
try {
384+
return await listSimulators(opts.executor);
385+
} catch {
386+
return [];
387+
}
388+
},
383389
});
384390

385391
const defaultIndex =
@@ -543,41 +549,52 @@ async function listAvailableDevices(
543549
fileSystem: FileSystemExecutor,
544550
executor: CommandExecutor,
545551
): Promise<SetupDevice[]> {
546-
const jsonPath = path.join(fileSystem.tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`);
547-
548552
try {
549-
const result = await executor(
550-
['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath],
551-
'List Devices (setup)',
552-
false,
553-
undefined,
553+
const jsonPath = path.join(
554+
fileSystem.tmpdir(),
555+
`xcodebuildmcp-setup-devices-${Date.now()}.json`,
554556
);
555557

556-
if (result.success) {
557-
const jsonContent = await fileSystem.readFile(jsonPath, 'utf8');
558-
const devices = parseDeviceListResponse(JSON.parse(jsonContent));
559-
if (devices.length > 0) {
560-
return devices;
558+
try {
559+
const result = await executor(
560+
['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath],
561+
'List Devices (setup)',
562+
false,
563+
undefined,
564+
);
565+
566+
if (result.success) {
567+
const jsonContent = await fileSystem.readFile(jsonPath, 'utf8');
568+
const devices = parseDeviceListResponse(JSON.parse(jsonContent));
569+
if (devices.length > 0) {
570+
return devices;
571+
}
561572
}
573+
} catch {
574+
// Fall back to xctrace below.
575+
} finally {
576+
await fileSystem.rm(jsonPath, { force: true }).catch(() => {});
562577
}
563578
} catch {
564-
// Fall back to xctrace below.
565-
} finally {
566-
await fileSystem.rm(jsonPath, { force: true }).catch(() => {});
579+
return [];
567580
}
568581

569-
const fallbackResult = await executor(
570-
['xcrun', 'xctrace', 'list', 'devices'],
571-
'List Devices (setup fallback)',
572-
false,
573-
undefined,
574-
);
582+
try {
583+
const fallbackResult = await executor(
584+
['xcrun', 'xctrace', 'list', 'devices'],
585+
'List Devices (setup fallback)',
586+
false,
587+
undefined,
588+
);
575589

576-
if (!fallbackResult.success) {
590+
if (!fallbackResult.success) {
591+
return [];
592+
}
593+
594+
return parseXctraceDevices(fallbackResult.output);
595+
} catch {
577596
return [];
578597
}
579-
580-
return parseXctraceDevices(fallbackResult.output);
581598
}
582599

583600
function getDefaultDeviceIndex(devices: SetupDevice[], existingDeviceId?: string): number {

0 commit comments

Comments
 (0)