Skip to content
17 changes: 17 additions & 0 deletions app-runner/Private/DeviceProviders/AdbProvider.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class AdbProvider : DeviceProvider {
# App execution
'launch' = @('adb', '-s {0} shell am start -n {1} {2} -W')
'pidof' = @('adb', '-s {0} shell pidof {1}')
'force-stop' = @('adb', '-s {0} shell am force-stop {1}')

# Logging
'logcat' = @('adb', '-s {0} logcat -d')
Expand Down Expand Up @@ -218,6 +219,9 @@ class AdbProvider : DeviceProvider {

$startTime = Get-Date

# Otherwise `am start` brings a still-running (possibly hung) instance to the front instead of starting a new one.
$this.TerminateApp($packageName)

# Clear logcat before launch
Write-Debug "$($this.Platform): Clearing logcat"
$this.InvokeCommand('logcat-clear', @($this.DeviceSerial))
Expand Down Expand Up @@ -289,6 +293,10 @@ class AdbProvider : DeviceProvider {
$logCache = @($this.InvokeCommand('logcat-pid', @($this.DeviceSerial, $appPID)))
Write-Host "Retrieved $($logCache.Count) log lines" -ForegroundColor Cyan

if (-not $processExited) {
$this.TerminateApp($packageName)
}

Write-Host "Android doesn't report exit codes via adb so exit code is always NULL"
$exitCode = $null
}
Expand All @@ -308,6 +316,15 @@ class AdbProvider : DeviceProvider {
}
}

[void] TerminateApp([string]$packageName) {
try {
$this.InvokeCommand('force-stop', @($this.DeviceSerial, $packageName)) | Out-Null
}
catch {
Write-Warning "$($this.Platform): Failed to force-stop ${packageName}: $_"
}
}

# Helper method to wait for process with retry
[string] WaitForProcess([string]$packageName, [int]$timeoutSeconds) {
Write-Debug "$($this.Platform): Waiting for process: $packageName"
Expand Down
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
69 changes: 69 additions & 0 deletions app-runner/Tests/AdbRunApplication.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
$ErrorActionPreference = 'Stop'

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

Invoke-Expression @'
class FakeAdbProvider : AdbProvider {
[System.Collections.Generic.List[string]]$Calls = [System.Collections.Generic.List[string]]::new()
[bool]$Hangs = $false
[bool]$ForceStopFails = $false
[int]$PidofCalls = 0

FakeAdbProvider() : base() {
$this.DeviceSerial = 'emulator-5554'
$this.Timeouts['run-timeout'] = 1
$this.Timeouts['pid-retry'] = 1
$this.Timeouts['process-check-interval'] = 0
}

[object] InvokeCommand([string]$action, [object[]]$parameters) {
$this.Calls.Add($action)
switch ($action) {
'force-stop' { if ($this.ForceStopFails) { throw 'adb: device offline' } }
'launch' { return 'Status: ok' }
'pidof' {
$this.PidofCalls++
if ($this.Hangs -or $this.PidofCalls -eq 1) { return '4056' }
return $null
}
'logcat-pid' { return @('10-20 08:14:04.782 4056 4056 I IL2CPP : JNI_OnLoad') }
}
return $null
}
}
'@
}

Describe 'AdbProvider.RunApplication' -Tag 'Unit' {
It 'Force-stops the package before launching it' {
$provider = [FakeAdbProvider]::new()

$provider.RunApplication('com.example.app/.MainActivity', @(), $null, $null) | Out-Null

$provider.Calls[0] | Should -Be 'force-stop'
$provider.Calls.IndexOf('force-stop') | Should -BeLessThan $provider.Calls.IndexOf('launch')
@($provider.Calls | Where-Object { $_ -eq 'force-stop' }).Count | Should -Be 1
}

It 'Force-stops the package after a run timeout, once its logs are read' {
$provider = [FakeAdbProvider]::new()
$provider.Hangs = $true

$result = $provider.RunApplication('com.example.app/.MainActivity', @(), $null, $null)

$provider.Calls[-1] | Should -Be 'force-stop'
$provider.Calls[-2] | Should -Be 'logcat-pid'
$result.Output | Should -Contain '10-20 08:14:04.782 4056 4056 I IL2CPP : JNI_OnLoad'
}

It 'Still launches when force-stop fails' {
$provider = [FakeAdbProvider]::new()
$provider.ForceStopFails = $true

$provider.RunApplication('com.example.app/.MainActivity', @(), $null, $null) 3>$null | Out-Null

$provider.Calls | Should -Contain 'launch'
}
}
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