diff --git a/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 index 06bb1d4..4dca5f1 100644 --- a/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 +++ b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 @@ -30,6 +30,7 @@ class iOSSimulatorProvider : DeviceProvider { [string]$SimulatorUUID = $null [string]$CurrentBundleId = $null [bool]$DidBootSimulator = $false + [bool]$FreshlyBooted = $false iOSSimulatorProvider() { $this.Platform = 'iOSSimulator' @@ -61,6 +62,7 @@ class iOSSimulatorProvider : DeviceProvider { # Configure timeouts $this.Timeouts = @{ 'boot-timeout' = 60 + 'terminate-timeout' = 60 'run-timeout' = 300 } } @@ -161,6 +163,7 @@ class iOSSimulatorProvider : DeviceProvider { $this.SimulatorUUID = $null $this.CurrentBundleId = $null $this.DidBootSimulator = $false + $this.FreshlyBooted = $false } [bool] TestConnection() { @@ -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 @@ -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 @@ -454,9 +449,54 @@ 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)" + $this.FreshlyBooted = $false try { $this.InvokeCommand('boot', @($this.SimulatorUUID)) @@ -478,6 +518,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 diff --git a/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 new file mode 100644 index 0000000..bba40e3 --- /dev/null +++ b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 @@ -0,0 +1,110 @@ +$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]$AlreadyBooted = $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.AlreadyBooted) { throw 'Unable to boot device in current state: Booted' } } + 'list-devices' { return " iPhone 17 Pro ($($this.SimulatorUUID)) (Booted)" } + } + 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 'Terminates on the first launch when the simulator was already booted' { + $provider = [FakeiOSSimulatorProvider]::new() + $provider.AlreadyBooted = $true + $provider.StartDevice() | 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 + } +}