From 171319753709e45ad7a0e6d2caab4fd50b4589eb Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Sun, 25 Oct 2026 22:55:31 +0100 Subject: [PATCH 1/4] Skip the iOS simulator terminate right after boot and bound the others --- .../DeviceProviders/iOSSimulatorProvider.ps1 | 71 ++++++++--- .../iOSSimulatorRunApplication.Tests.ps1 | 110 ++++++++++++++++++ 2 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 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 + } +} From 8202533a41e7862f399637483a6e1db408c6673b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 16:24:00 +0100 Subject: [PATCH 2/4] Keep the iOS fresh-boot flag when booting an already-booted simulator BootSimulator cleared FreshlyBooted before simctl boot, so a second boot of a simulator this provider just booted (Connect, then Start-Device) hit the already-Booted early return with the flag lost, and the first launch ran the simctl terminate the fresh-boot skip exists to avoid. Clear it only once the boot command has run. --- .../DeviceProviders/iOSSimulatorProvider.ps1 | 4 +-- .../iOSSimulatorRunApplication.Tests.ps1 | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 index 4dca5f1..ea4eb38 100644 --- a/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 +++ b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 @@ -496,19 +496,19 @@ class iOSSimulatorProvider : DeviceProvider { # 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)) } catch { - # Check if already booted + # Check if already booted (keeps whatever an earlier boot recorded in FreshlyBooted) if ("$_" -match 'Unable to boot device in current state: Booted') { Write-Debug "$($this.Platform): Simulator is already booted" return } throw } + $this.FreshlyBooted = $false # Wait for simulator to be ready $maxWait = $this.Timeouts['boot-timeout'] diff --git a/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 index bba40e3..a1d5dff 100644 --- a/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 +++ b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 @@ -12,7 +12,7 @@ BeforeAll { class FakeiOSSimulatorProvider : iOSSimulatorProvider { [System.Collections.Generic.List[string]]$Calls = [System.Collections.Generic.List[string]]::new() [bool]$TerminateHangs = $false - [bool]$AlreadyBooted = $false + [bool]$Booted = $false FakeiOSSimulatorProvider() : base() { $this.SimulatorUUID = 'E952A9B0-CC78-435C-BF08-06AD47FE1076' @@ -26,8 +26,15 @@ class FakeiOSSimulatorProvider : iOSSimulatorProvider { [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)" } + 'boot' { + if ($this.Booted) { throw 'Unable to boot device in current state: Booted' } + $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 } @@ -63,9 +70,19 @@ Describe 'iOSSimulatorProvider.PrepareLaunch' -Tag 'Unit' -Skip:$skip { $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.AlreadyBooted = $true + $provider.Booted = $true $provider.StartDevice() | Out-Null $provider.PrepareLaunch('com.example.app') | Out-Null From fcb2e4fcc5cf0046475acf953eae894504d84fa4 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 16:24:17 +0100 Subject: [PATCH 3/4] Reach the already-booted iOS simulator through Connect in the test The test got there by having the fake boot throw 'Unable to boot device in current state: Booted', but the real InvokeBuiltCommand throws an exit-code message, so production reaches an already-booted simulator through SelectAndConnect's booted branch. Connect by UUID to a simulator the fake lists as Booted instead, so that branch is covered. --- app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 index a1d5dff..0dbef35 100644 --- a/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 +++ b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 @@ -83,7 +83,7 @@ Describe 'iOSSimulatorProvider.PrepareLaunch' -Tag 'Unit' -Skip:$skip { It 'Terminates on the first launch when the simulator was already booted' { $provider = [FakeiOSSimulatorProvider]::new() $provider.Booted = $true - $provider.StartDevice() | Out-Null + $provider.Connect($provider.SimulatorUUID) | Out-Null $provider.PrepareLaunch('com.example.app') | Out-Null From 07fa3b73615840778c93399ebc54b0940b3d4791 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 29 Oct 2026 11:30:15 +0100 Subject: [PATCH 4/4] Detect an already-booted iOS simulator by its state, not the boot error BootSimulator matched simctl's 'Unable to boot device in current state: Booted' text, but InvokeBuiltCommand throws only 'Command (boot) failed with exit code N', so a second boot of a simulator this provider booted rethrew instead of keeping FreshlyBooted. Ask for the simulator state when boot fails and rethrow the boot error unless it is Booted, and have the test fake throw the exit-code message the real command throws. --- .../Private/DeviceProviders/iOSSimulatorProvider.ps1 | 9 ++++++--- app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 index ea4eb38..718cad8 100644 --- a/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 +++ b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 @@ -501,12 +501,15 @@ class iOSSimulatorProvider : DeviceProvider { $this.InvokeCommand('boot', @($this.SimulatorUUID)) } catch { - # Check if already booted (keeps whatever an earlier boot recorded in FreshlyBooted) - 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 diff --git a/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 index 0dbef35..4f31fc8 100644 --- a/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 +++ b/app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1 @@ -27,7 +27,7 @@ class FakeiOSSimulatorProvider : iOSSimulatorProvider { $this.Calls.Add($action) switch ($action) { 'boot' { - if ($this.Booted) { throw 'Unable to boot device in current state: Booted' } + if ($this.Booted) { throw 'Command (boot) failed with exit code 149' } $this.Booted = $true } 'shutdown' { $this.Booted = $false }