Skip to content
Draft
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
71 changes: 56 additions & 15 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,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))
Expand All @@ -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
Expand Down
110 changes: 110 additions & 0 deletions app-runner/Tests/iOSSimulatorRunApplication.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading