Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 62 additions & 18 deletions app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class iOSSimulatorProvider : DeviceProvider {
[string]$SimulatorUUID = $null
[string]$CurrentBundleId = $null
[bool]$DidBootSimulator = $false
[bool]$FreshlyBooted = $false

iOSSimulatorProvider() {
$this.Platform = 'iOSSimulator'
Expand Down Expand Up @@ -61,6 +62,7 @@ class iOSSimulatorProvider : DeviceProvider {
# Configure timeouts
$this.Timeouts = @{
'boot-timeout' = 60
'terminate-timeout' = 60
'run-timeout' = 300
}
}
Expand Down Expand Up @@ -161,6 +163,7 @@ class iOSSimulatorProvider : DeviceProvider {
$this.SimulatorUUID = $null
$this.CurrentBundleId = $null
$this.DidBootSimulator = $false
$this.FreshlyBooted = $false
}

[bool] TestConnection() {
Expand Down Expand Up @@ -258,21 +261,16 @@ class iOSSimulatorProvider : DeviceProvider {

$startTime = Get-Date

# Terminate any previous instance to ensure a clean launch.
# This prevents stale processes from interfering with console-pty output capture.
$launchOptions = $this.PrepareLaunch($bundleId)

# Build argument list for simctl launch
$simctlArgs = @("simctl", "launch", "--console-pty", $this.SimulatorUUID, $bundleId)
$simctlArgs = @("simctl", "launch", "--console-pty") + $launchOptions + @($this.SimulatorUUID, $bundleId)
if ($Arguments -and $Arguments.Count -gt 0) {
$simctlArgs += $Arguments
}

# Terminate any previous instance to ensure a clean launch.
# This prevents stale processes from interfering with console-pty output capture.
try {
& xcrun simctl terminate $this.SimulatorUUID $bundleId 2>&1 | Out-Null
}
catch {
# App may not be running - that's fine
}

Write-Host "Launching: $bundleId" -ForegroundColor Cyan
if ($Arguments -and $Arguments.Count -gt 0) {
Write-Host " Arguments: $($Arguments -join ' ')" -ForegroundColor Cyan
Expand Down Expand Up @@ -301,11 +299,8 @@ class iOSSimulatorProvider : DeviceProvider {
# Terminate the app on the simulator to ensure proper cleanup.
# This is important after crashes where the app process may have died
# but the console-pty connection keeps xcrun alive.
try {
& xcrun simctl terminate $this.SimulatorUUID $bundleId 2>&1 | Out-Null
}
catch {
Write-Debug "$($this.Platform): terminate after timeout failed (app may already be terminated): $_"
if (-not $this.TerminateApp($bundleId)) {
Write-Warning "$($this.Platform): terminate after timeout did not finish within $($this.Timeouts['terminate-timeout']) seconds"
}
Start-Sleep -Seconds 1

Expand Down Expand Up @@ -454,6 +449,50 @@ class iOSSimulatorProvider : DeviceProvider {
return $this.CreateSessionInfo()
}

# Helper: Return the extra `simctl launch` options that guarantee a fresh app process
hidden [string[]] PrepareLaunch([string]$bundleId) {
# Nothing of ours runs on a simulator we just booted, and terminating right after boot can stall for minutes.
if ($this.FreshlyBooted) {
$this.FreshlyBooted = $false
return @()
}

if ($this.TerminateApp($bundleId)) {
return @()
}

Write-Warning "$($this.Platform): simctl terminate did not finish within $($this.Timeouts['terminate-timeout']) seconds, letting launch terminate the app instead"
return @('--terminate-running-process')
}

# Helper: Terminate the app; returns $false if simctl did not finish in time (a not-running app is fine)
hidden [bool] TerminateApp([string]$bundleId) {
return $this.InvokeXcrun(@('simctl', 'terminate', $this.SimulatorUUID, $bundleId), $this.Timeouts['terminate-timeout'])
}

# Helper: Run xcrun, discarding its output; returns $false and kills it if it does not exit within $timeoutSeconds
hidden [bool] InvokeXcrun([string[]]$arguments, [int]$timeoutSeconds) {
$outFile = New-TemporaryFile
$errFile = New-TemporaryFile
try {
$process = Start-Process 'xcrun' `
-ArgumentList $arguments `
-NoNewWindow -PassThru `
-RedirectStandardOutput $outFile `
-RedirectStandardError $errFile

if ($process.WaitForExit($timeoutSeconds * 1000)) {
return $true
}
$process | Stop-Process -Force -ErrorAction SilentlyContinue
return $false
}
finally {
Remove-Item $outFile -ErrorAction SilentlyContinue
Remove-Item $errFile -ErrorAction SilentlyContinue
}
}

# Helper: Boot the simulator with graceful handling
hidden [void] BootSimulator() {
Write-Debug "$($this.Platform): Booting simulator: $($this.SimulatorUUID)"
Expand All @@ -462,13 +501,17 @@ class iOSSimulatorProvider : DeviceProvider {
$this.InvokeCommand('boot', @($this.SimulatorUUID))
}
catch {
# Check if already booted
if ("$_" -match 'Unable to boot device in current state: Booted') {
# Check if already booted (keeps whatever an earlier boot recorded in FreshlyBooted).
# The boot error only carries simctl's exit code, so ask for the state instead.
$bootError = $_
$state = try { $this.GetSimulatorState() } catch { 'Unknown' }
if ($state -eq 'Booted') {
Write-Debug "$($this.Platform): Simulator is already booted"
return
}
throw
throw $bootError
}
$this.FreshlyBooted = $false

# Wait for simulator to be ready
$maxWait = $this.Timeouts['boot-timeout']
Expand All @@ -478,6 +521,7 @@ class iOSSimulatorProvider : DeviceProvider {
if ($state -eq 'Booted') {
Write-Host "Simulator booted successfully" -ForegroundColor Green
$this.DidBootSimulator = $true
$this.FreshlyBooted = $true
return
}
Start-Sleep -Seconds 1
Expand Down
127 changes: 127 additions & 0 deletions app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
$ErrorActionPreference = 'Stop'

BeforeDiscovery {
$skip = -not $IsMacOS -or -not (Get-Command 'xcrun' -ErrorAction SilentlyContinue)
}

BeforeAll {
. "$PSScriptRoot\..\Private\DeviceProviders\DeviceProvider.ps1"
. "$PSScriptRoot\..\Private\DeviceProviders\iOSSimulatorProvider.ps1"

Invoke-Expression @'
class FakeiOSSimulatorProvider : iOSSimulatorProvider {
[System.Collections.Generic.List[string]]$Calls = [System.Collections.Generic.List[string]]::new()
[bool]$TerminateHangs = $false
[bool]$Booted = $false

FakeiOSSimulatorProvider() : base() {
$this.SimulatorUUID = 'E952A9B0-CC78-435C-BF08-06AD47FE1076'
}

[bool] InvokeXcrun([string[]]$arguments, [int]$timeoutSeconds) {
$this.Calls.Add($arguments[1])
return -not ($this.TerminateHangs -and $arguments[1] -eq 'terminate')
}

[object] InvokeCommand([string]$action, [object[]]$parameters) {
$this.Calls.Add($action)
switch ($action) {
'boot' {
if ($this.Booted) { throw 'Command (boot) failed with exit code 149' }
$this.Booted = $true
}
'shutdown' { $this.Booted = $false }
'list-devices' {
$state = if ($this.Booted) { 'Booted' } else { 'Shutdown' }
return @('-- iOS 26.0 --', " iPhone 17 Pro ($($this.SimulatorUUID)) ($state)")
}
}
return $null
}

[int] TerminateCount() {
return @($this.Calls | Where-Object { $_ -eq 'terminate' }).Count
}
}
'@
}

Describe 'iOSSimulatorProvider.PrepareLaunch' -Tag 'Unit' -Skip:$skip {
It 'Skips terminate on the first launch after a boot only' {
$provider = [FakeiOSSimulatorProvider]::new()
$provider.StartDevice() | Out-Null

$provider.PrepareLaunch('com.example.app') | Should -BeNullOrEmpty
$provider.TerminateCount() | Should -Be 0

$provider.PrepareLaunch('com.example.app') | Should -BeNullOrEmpty
$provider.TerminateCount() | Should -Be 1
}

It 'Skips terminate again after the simulator was restarted' {
$provider = [FakeiOSSimulatorProvider]::new()
$provider.StartDevice() | Out-Null
$provider.PrepareLaunch('com.example.app') | Out-Null
$provider.PrepareLaunch('com.example.app') | Out-Null

$provider.RestartDevice()
$provider.PrepareLaunch('com.example.app') | Out-Null

$provider.TerminateCount() | Should -Be 1
}

It 'Keeps the fresh boot when the simulator is started again' {
$provider = [FakeiOSSimulatorProvider]::new()
$provider.StartDevice() | Out-Null
$provider.StartDevice() | Out-Null

$provider.PrepareLaunch('com.example.app') | Out-Null

$provider.TerminateCount() | Should -Be 0
}

It 'Terminates on the first launch when the simulator was already booted' {
$provider = [FakeiOSSimulatorProvider]::new()
$provider.Booted = $true
$provider.Connect($provider.SimulatorUUID) | Out-Null

$provider.PrepareLaunch('com.example.app') | Out-Null

$provider.TerminateCount() | Should -Be 1
}

It 'Forgets the fresh boot on disconnect' {
$provider = [FakeiOSSimulatorProvider]::new()
$provider.StartDevice() | Out-Null

$provider.Disconnect()

$provider.FreshlyBooted | Should -BeFalse
}

It 'Lets launch terminate the app when terminate does not finish' {
$provider = [FakeiOSSimulatorProvider]::new()
$provider.TerminateHangs = $true

$options = $provider.PrepareLaunch('com.example.app') 3>$null

$options | Should -Be @('--terminate-running-process')
}
}

Describe 'iOSSimulatorProvider.InvokeXcrun' -Tag 'Unit' -Skip:$skip {
It 'Stops a command that does not exit in time' {
$provider = [iOSSimulatorProvider]::new()
$start = Get-Date

$provider.InvokeXcrun(@('sleep', '30'), 1) | Should -BeFalse

((Get-Date) - $start).TotalSeconds | Should -BeLessThan 10
}

It 'Returns true when the command exits, whatever its exit code' {
$provider = [iOSSimulatorProvider]::new()

$provider.InvokeXcrun(@('false'), 10) | Should -BeTrue
}
}
Loading