Skip to content

Commit 2996e8d

Browse files
committed
fix(setup): Preserve device fallback discovery
Keep optional device setup resilient without skipping viable fallback behavior. When temp-path creation fails, still fall through to xctrace instead of returning an empty device list immediately. Add regression coverage for tmpdir failures, malformed device JSON, and simulator text-fallback failures so optional defaults keep degrading to skip instead of aborting setup.
1 parent e6c80c5 commit 2996e8d

File tree

2 files changed

+129
-23
lines changed

2 files changed

+129
-23
lines changed

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

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -803,13 +803,61 @@ sessionDefaults:
803803
expect(parsed.sessionDefaults?.simulatorName).toBeUndefined();
804804
});
805805

806-
it('continues setup with no default device when temp path creation fails', async () => {
806+
it('uses xctrace fallback when temp path creation fails', async () => {
807807
const { fs, getStoredConfig } = createSetupFs();
808808
fs.tmpdir = () => {
809809
throw new Error('tmpdir unavailable');
810810
};
811811

812812
const executor: CommandExecutor = async (command) => {
813+
if (command[0] === 'xcrun' && command[1] === 'xctrace') {
814+
return createMockCommandResponse({
815+
success: true,
816+
output: 'Cam iPhone (12345678-1234-1234-1234-123456789ABC)',
817+
});
818+
}
819+
820+
return createMockCommandResponse({
821+
success: true,
822+
output: `Information about workspace "App":\n Schemes:\n App`,
823+
});
824+
};
825+
826+
const prompter: Prompter = {
827+
selectOne: async <T>(opts: { options: Array<{ value: T }> }) =>
828+
opts.options.find((option) => option.value != null)?.value ?? opts.options[0].value,
829+
selectMany: async <T>(opts: { options: Array<{ value: T }> }) => {
830+
const loggingOption = opts.options.find((option) => option.value === ('logging' as T));
831+
return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value);
832+
},
833+
confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue,
834+
};
835+
836+
await runSetupWizard({
837+
cwd,
838+
fs,
839+
executor,
840+
prompter,
841+
quietOutput: true,
842+
});
843+
844+
const parsed = parseYaml(getStoredConfig()) as {
845+
sessionDefaults?: Record<string, unknown>;
846+
};
847+
848+
expect(parsed.sessionDefaults?.deviceId).toBe('12345678-1234-1234-1234-123456789ABC');
849+
});
850+
851+
it('continues setup with no default device when device json parsing fails', async () => {
852+
const { fs, getStoredConfig, setTempFile } = createSetupFs();
853+
854+
const executor: CommandExecutor = async (command) => {
855+
if (command[0] === 'xcrun' && command[1] === 'devicectl') {
856+
const jsonPath = command[command.length - 1];
857+
setTempFile(jsonPath, 'not json');
858+
return createMockCommandResponse({ success: true, output: '' });
859+
}
860+
813861
if (command[0] === 'xcrun' && command[1] === 'xctrace') {
814862
throw new Error('xctrace spawn failed');
815863
}
@@ -842,6 +890,67 @@ sessionDefaults:
842890
};
843891

844892
expect(parsed.sessionDefaults?.deviceId).toBeUndefined();
893+
});
894+
895+
it('continues setup with no default simulator when simctl text fallback fails', async () => {
896+
const { fs, getStoredConfig } = createSetupFs();
897+
898+
const executor: CommandExecutor = async (command) => {
899+
if (command[0] === 'xcrun' && command[1] === 'devicectl') {
900+
throw new Error('device lookup should not run for simulator-only workflows');
901+
}
902+
903+
if (command[0] === 'xcrun' && command[1] === 'simctl' && command.includes('--json')) {
904+
return createMockCommandResponse({
905+
success: true,
906+
output: JSON.stringify({
907+
devices: {
908+
'iOS 17.0': [
909+
{
910+
name: 'iPhone 15',
911+
udid: 'SIM-1',
912+
state: 'Shutdown',
913+
isAvailable: true,
914+
},
915+
],
916+
},
917+
}),
918+
});
919+
}
920+
921+
if (command[0] === 'xcrun' && command[1] === 'simctl') {
922+
throw new Error('simctl text fallback unavailable');
923+
}
924+
925+
return createMockCommandResponse({
926+
success: true,
927+
output: `Information about workspace "App":\n Schemes:\n App`,
928+
});
929+
};
930+
931+
const prompter: Prompter = {
932+
selectOne: async <T>(opts: { options: Array<{ value: T }> }) => opts.options[0].value,
933+
selectMany: async <T>(opts: { options: Array<{ value: T }> }) => {
934+
const simulatorOption = opts.options.find((option) => option.value === ('simulator' as T));
935+
return simulatorOption
936+
? [simulatorOption.value]
937+
: opts.options.map((option) => option.value);
938+
},
939+
confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue,
940+
};
941+
942+
await runSetupWizard({
943+
cwd,
944+
fs,
945+
executor,
946+
prompter,
947+
quietOutput: true,
948+
});
949+
950+
const parsed = parseYaml(getStoredConfig()) as {
951+
sessionDefaults?: Record<string, unknown>;
952+
};
953+
845954
expect(parsed.sessionDefaults?.simulatorId).toBeUndefined();
846955
expect(parsed.sessionDefaults?.simulatorName).toBeUndefined();
847956
});

src/cli/commands/setup.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -549,34 +549,31 @@ async function listAvailableDevices(
549549
fileSystem: FileSystemExecutor,
550550
executor: CommandExecutor,
551551
): Promise<SetupDevice[]> {
552+
let jsonPath: string | undefined;
553+
552554
try {
553-
const jsonPath = path.join(
554-
fileSystem.tmpdir(),
555-
`xcodebuildmcp-setup-devices-${Date.now()}.json`,
556-
);
555+
jsonPath = path.join(fileSystem.tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`);
557556

558-
try {
559-
const result = await executor(
560-
['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath],
561-
'List Devices (setup)',
562-
false,
563-
undefined,
564-
);
557+
const result = await executor(
558+
['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath],
559+
'List Devices (setup)',
560+
false,
561+
undefined,
562+
);
565563

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-
}
564+
if (result.success) {
565+
const jsonContent = await fileSystem.readFile(jsonPath, 'utf8');
566+
const devices = parseDeviceListResponse(JSON.parse(jsonContent));
567+
if (devices.length > 0) {
568+
return devices;
572569
}
573-
} catch {
574-
// Fall back to xctrace below.
575-
} finally {
576-
await fileSystem.rm(jsonPath, { force: true }).catch(() => {});
577570
}
578571
} catch {
579-
return [];
572+
// Fall back to xctrace below.
573+
} finally {
574+
if (jsonPath != null) {
575+
await fileSystem.rm(jsonPath, { force: true }).catch(() => {});
576+
}
580577
}
581578

582579
try {

0 commit comments

Comments
 (0)