diff --git a/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts b/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts new file mode 100644 index 000000000..596de693b --- /dev/null +++ b/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts @@ -0,0 +1,103 @@ +import child_process from 'child_process'; +import fs from 'fs'; +import {IOSProjectInfo} from '@react-native-community/cli-types'; +import {Device} from '../../../types'; +import {runOnSimulator} from '../runOnSimulator'; +import {buildProject} from '../../buildCommand/buildProject'; +import installApp from '../installApp'; +import {FlagsT} from '../createRun'; + +jest.mock('child_process'); +jest.mock('fs', () => ({existsSync: jest.fn()})); +jest.mock('../../buildCommand/buildProject'); +jest.mock('../installApp'); + +const xcodeProject: IOSProjectInfo = { + name: 'TestApp.xcworkspace', + path: '/path/to/TestApp.xcworkspace', + isWorkspace: true, +}; + +const simulator: Device = { + name: 'iPhone 15', + udid: 'AAAA-BBBB-CCCC', + state: 'Booted', + type: 'simulator', +}; + +const args = {} as FlagsT; +const developerDir = '/Applications/Xcode.app/Contents/Developer'; + +beforeEach(() => { + jest.clearAllMocks(); + (buildProject as jest.Mock).mockResolvedValue(''); + (installApp as jest.Mock).mockResolvedValue(undefined); + (child_process.execFileSync as jest.Mock).mockReturnValue( + `${developerDir}\n`, + ); +}); + +test('opens Simulator.app with the device UDID when it exists', async () => { + (fs.existsSync as jest.Mock).mockImplementation((target) => + String(target).endsWith('Simulator.app'), + ); + + await runOnSimulator( + xcodeProject, + 'ios', + 'Debug', + 'TestApp', + args, + simulator, + ); + + expect(child_process.execFileSync).toHaveBeenCalledWith('open', [ + `${developerDir}/Applications/Simulator.app`, + '--args', + '-CurrentDeviceUDID', + simulator.udid, + ]); +}); + +test('falls back to DeviceHub via devices:// deep link when Simulator.app is absent', async () => { + (fs.existsSync as jest.Mock).mockImplementation((target) => + String(target).endsWith('DeviceHub.app'), + ); + + await runOnSimulator( + xcodeProject, + 'ios', + 'Debug', + 'TestApp', + args, + simulator, + ); + + // DeviceHub registers the `devices://` URL scheme, which focuses the device by UDID. + expect(child_process.execFileSync).toHaveBeenCalledWith('open', [ + `devices://device/open?id=${simulator.udid}`, + ]); + // The deep link replaces the Simulator.app `-CurrentDeviceUDID` argument. + expect(child_process.execFileSync).not.toHaveBeenCalledWith( + 'open', + expect.arrayContaining(['-CurrentDeviceUDID']), + ); +}); + +test('does not boot the simulator when it is already booted', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + await runOnSimulator( + xcodeProject, + 'ios', + 'Debug', + 'TestApp', + args, + simulator, + ); + + expect(child_process.spawnSync).not.toHaveBeenCalledWith( + 'xcrun', + expect.arrayContaining(['boot']), + ); +}); diff --git a/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts b/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts index fd0484108..093ed6a4c 100644 --- a/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts +++ b/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts @@ -1,4 +1,6 @@ import child_process from 'child_process'; +import fs from 'fs'; +import path from 'path'; import {IOSProjectInfo} from '@react-native-community/cli-types'; import {logger} from '@react-native-community/cli-tools'; import {ApplePlatform, Device} from '../../types'; @@ -32,12 +34,34 @@ export async function runOnSimulator( .execFileSync('xcode-select', ['-p'], {encoding: 'utf8'}) .trim(); - child_process.execFileSync('open', [ - `${activeDeveloperDir}/Applications/Simulator.app`, - '--args', - '-CurrentDeviceUDID', - simulator.udid, - ]); + // Xcode 27 replaces Simulator.app with DeviceHub.app and relocates it from + // /Contents/Developer/Applications to /Contents/Applications. + // Prefer Simulator.app while it exists (stable Xcode); fall back to DeviceHub + // for Xcode 27+. See https://developer.apple.com/documentation/xcode/device-hub + const simulatorApp = path.join( + activeDeveloperDir, + 'Applications', + 'Simulator.app', + ); + const deviceHubApp = path.join( + activeDeveloperDir, + '..', + 'Applications', + 'DeviceHub.app', + ); + + if (fs.existsSync(simulatorApp)) { + child_process.execFileSync('open', [ + simulatorApp, + '--args', + '-CurrentDeviceUDID', + simulator.udid, + ]); + } else if (fs.existsSync(deviceHubApp)) { + child_process.execFileSync('open', [ + `devices://device/open?id=${simulator.udid}`, + ]); + } if (simulator.state !== 'Booted') { bootSimulator(simulator);