diff --git a/README.md b/README.md index ba815e3..f921c9c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Currently supported: - Linux - **Mobile Platforms:** - Android (via ADB or SauceLabs Real Device Cloud) + - iOS Simulator (via xcrun simctl on macOS) - iOS (via SauceLabs - coming soon) ## Requirements diff --git a/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 b/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 index b9bbcbe..d925dcf 100644 --- a/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 @@ -81,6 +81,10 @@ class DeviceProviderFactory { Write-Debug "DeviceProviderFactory: Creating AdbProvider" return [AdbProvider]::new() } + "iOSSimulator" { + Write-Debug "DeviceProviderFactory: Creating iOSSimulatorProvider" + return [iOSSimulatorProvider]::new() + } "AndroidSauceLabs" { Write-Debug "DeviceProviderFactory: Creating SauceLabsProvider (Android)" return [SauceLabsProvider]::new('Android') @@ -94,7 +98,7 @@ class DeviceProviderFactory { return [MockDeviceProvider]::new() } default { - $errorMessage = "Unsupported platform: $Platform. Supported platforms: Xbox, PlayStation5, Switch, Windows, MacOS, Linux, Adb, AndroidSauceLabs, iOSSauceLabs, Local, Mock" + $errorMessage = "Unsupported platform: $Platform. Supported platforms: Xbox, PlayStation5, Switch, Windows, MacOS, Linux, Adb, iOSSimulator, AndroidSauceLabs, iOSSauceLabs, Local, Mock" Write-Error "DeviceProviderFactory: $errorMessage" throw $errorMessage } @@ -112,7 +116,7 @@ class DeviceProviderFactory { An array of supported platform names. #> static [string[]] GetSupportedPlatforms() { - return @("Xbox", "PlayStation5", "Switch", "Windows", "MacOS", "Linux", "Adb", "AndroidSauceLabs", "iOSSauceLabs", "Local", "Mock") + return @("Xbox", "PlayStation5", "Switch", "Windows", "MacOS", "Linux", "Adb", "iOSSimulator", "AndroidSauceLabs", "iOSSauceLabs", "Local", "Mock") } <# diff --git a/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 new file mode 100644 index 0000000..16e5d18 --- /dev/null +++ b/app-runner/Private/DeviceProviders/iOSSimulatorProvider.ps1 @@ -0,0 +1,561 @@ +# iOS Simulator Provider Implementation +# Provides device management for iOS Simulators via xcrun simctl + +# Load the base provider +. "$PSScriptRoot\DeviceProvider.ps1" + +<# +.SYNOPSIS +Device provider for iOS Simulators via xcrun simctl. + +.DESCRIPTION +This provider implements iOS Simulator-specific device operations using xcrun simctl commands. +It supports booting, installing .app bundles, launching apps with console output capture, +and shutting down simulators. + +Key features: +- Auto-discovery of available simulators +- Runtime version filtering (e.g. "iOS 17.0") +- UUID and device name targeting +- .app bundle installation with bundle ID extraction +- App execution with console output capture via --console-pty +- Screenshot capture +- Tracks whether simulator was booted by this provider + +Requirements: +- macOS with Xcode and xcrun in PATH +- At least one iOS Simulator runtime installed +#> +class iOSSimulatorProvider : DeviceProvider { + [string]$SimulatorUUID = $null + [string]$CurrentBundleId = $null + [bool]$DidBootSimulator = $false + + iOSSimulatorProvider() { + $this.Platform = 'iOSSimulator' + $this.SdkPath = $null + + # Validate macOS platform + if (-not $global:IsMacOS) { + throw "iOSSimulator provider is only supported on macOS" + } + + # Validate xcrun is available + if (-not (Get-Command 'xcrun' -ErrorAction SilentlyContinue)) { + throw "xcrun not found in PATH. Please install Xcode Command Line Tools." + } + + # Configure simctl commands + $this.Commands = @{ + 'list-devices' = @('xcrun', 'simctl list devices') + 'list-runtimes' = @('xcrun', 'simctl list runtimes iOS') + 'boot' = @('xcrun', 'simctl boot {0}') + 'shutdown' = @('xcrun', 'simctl shutdown {0}') + 'install' = @('xcrun', 'simctl install {0} {1}') + 'uninstall' = @('xcrun', 'simctl uninstall {0} {1}') + 'screenshot' = @('xcrun', 'simctl io {0} screenshot {1}') + 'get-app-container' = @('xcrun', 'simctl get_app_container {0} {1}') + 'log-show' = @('xcrun', 'simctl spawn {0} log show --style compact --last 5m') + } + + # Configure timeouts + $this.Timeouts = @{ + 'boot-timeout' = 60 + 'run-timeout' = 300 + } + } + + [hashtable] Connect() { + Write-Debug "$($this.Platform): Auto-discovering available simulator" + + $simulators = $this.GetAvailableSimulators($null) + + if ($null -eq $simulators -or $simulators.Count -eq 0) { + throw "No iOS simulators found. Ensure at least one iOS Simulator runtime is installed via Xcode." + } + + return $this.SelectAndConnect($simulators, $null) + } + + [hashtable] Connect([string]$target) { + if ([string]::IsNullOrEmpty($target) -or $target -eq 'latest') { + if ($target -eq 'latest') { + $latestRuntime = $this.GetLatestRuntime() + Write-Debug "$($this.Platform): Resolved 'latest' to runtime: $latestRuntime" + return $this.ConnectWithRuntimeFilter($latestRuntime) + } + return $this.Connect() + } + + Write-Debug "$($this.Platform): Connecting with target: $target" + + # Check if target matches "iOS " pattern + if ($target -match '^iOS\s+\d+\.\d+') { + return $this.ConnectWithRuntimeFilter($target) + } + + # Check if target is a UUID + if ($target -match '^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$') { + return $this.ConnectWithUUID($target) + } + + # Otherwise, try to match by device name + return $this.ConnectWithDeviceName($target) + } + + hidden [hashtable] ConnectWithRuntimeFilter([string]$runtimeFilter) { + Write-Debug "$($this.Platform): Filtering simulators by runtime: $runtimeFilter" + + $simulators = $this.GetAvailableSimulators($runtimeFilter) + + if ($null -eq $simulators -or $simulators.Count -eq 0) { + throw "No simulators found for runtime '$runtimeFilter'. Check installed runtimes with: xcrun simctl list runtimes iOS" + } + + return $this.SelectAndConnect($simulators, $runtimeFilter) + } + + hidden [hashtable] ConnectWithUUID([string]$uuid) { + Write-Debug "$($this.Platform): Connecting to simulator by UUID: $uuid" + + $simulators = $this.GetAvailableSimulators($null) + $matched = @($simulators | Where-Object { $_.UUID -eq $uuid }) + + if ($matched.Count -eq 0) { + throw "No simulator found with UUID '$uuid'. Check available simulators with: xcrun simctl list devices" + } + + return $this.SelectAndConnect($matched, $null) + } + + hidden [hashtable] ConnectWithDeviceName([string]$deviceName) { + Write-Debug "$($this.Platform): Connecting to simulator by name: $deviceName" + + $simulators = $this.GetAvailableSimulators($null) + $matched = @($simulators | Where-Object { $_.Name -eq $deviceName }) + + if ($matched.Count -eq 0) { + $availableNames = ($simulators | Select-Object -ExpandProperty Name -Unique) -join ', ' + throw "No simulator found with name '$deviceName'. Available: $availableNames" + } + + return $this.SelectAndConnect($matched, $null) + } + + [void] Disconnect() { + Write-Debug "$($this.Platform): Disconnecting" + + if ($this.DidBootSimulator -and $this.SimulatorUUID) { + Write-Host "Shutting down simulator: $($this.SimulatorUUID)" -ForegroundColor Yellow + try { + $this.InvokeCommand('shutdown', @($this.SimulatorUUID)) + } + catch { + Write-Warning "Failed to shutdown simulator: $_" + } + } + else { + Write-Debug "$($this.Platform): Skipping shutdown (simulator was already booted before connect)" + } + + $this.SimulatorUUID = $null + $this.CurrentBundleId = $null + $this.DidBootSimulator = $false + } + + [bool] TestConnection() { + Write-Debug "$($this.Platform): Testing connection" + + if ([string]::IsNullOrEmpty($this.SimulatorUUID)) { + return $false + } + + try { + $status = $this.GetSimulatorState() + return $status -eq 'Booted' + } + catch { + return $false + } + } + + [hashtable] InstallApp([string]$PackagePath) { + Write-Debug "$($this.Platform): Installing app: $PackagePath" + + # Validate .app bundle + if (-not (Test-Path $PackagePath)) { + throw "App bundle not found: $PackagePath" + } + + if ($PackagePath -notlike '*.app') { + throw "iOS Simulator requires a .app bundle directory (not .ipa). Got: $PackagePath" + } + + if (-not (Test-Path "$PackagePath/Info.plist")) { + throw "Invalid .app bundle: Info.plist not found in $PackagePath" + } + + # Extract bundle ID from Info.plist + $bundleId = $null + try { + $bundleId = & /usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$PackagePath/Info.plist" 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "PlistBuddy failed with exit code $LASTEXITCODE" + } + $bundleId = "$bundleId".Trim() + } + catch { + throw "Failed to extract bundle ID from $PackagePath/Info.plist: $_" + } + + Write-Debug "$($this.Platform): Bundle ID: $bundleId" + + # Check for existing installation and uninstall + try { + $this.InvokeCommand('get-app-container', @($this.SimulatorUUID, $bundleId)) + # If we get here, app is installed - uninstall it + Write-Host "Uninstalling previous version: $bundleId" -ForegroundColor Yellow + try { + $this.InvokeCommand('uninstall', @($this.SimulatorUUID, $bundleId)) + } + catch { + Write-Warning "Failed to uninstall previous version: $_" + } + Start-Sleep -Seconds 1 + } + catch { + # App not installed - that's fine + Write-Debug "$($this.Platform): No previous installation found" + } + + # Install app + Write-Host "Installing app to simulator: $($this.SimulatorUUID)" -ForegroundColor Yellow + $this.InvokeCommand('install', @($this.SimulatorUUID, $PackagePath)) + $this.CurrentBundleId = $bundleId + + Write-Host "App installed successfully: $bundleId" -ForegroundColor Green + + return @{ + PackagePath = $PackagePath + BundleId = $bundleId + SimulatorUUID = $this.SimulatorUUID + } + } + + [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null, [string]$WorkingDirectory = $null) { + if (-not ([string]::IsNullOrEmpty($LogFilePath))) { + Write-Warning "LogFilePath parameter is not supported on this platform." + } + if (-not ([string]::IsNullOrEmpty($WorkingDirectory))) { + Write-Warning "WorkingDirectory parameter is not supported on this platform." + } + + Write-Debug "$($this.Platform): Running application: $ExecutablePath" + + $bundleId = $ExecutablePath + $this.CurrentBundleId = $bundleId + $timeoutSeconds = $this.Timeouts['run-timeout'] + + $startTime = Get-Date + + # Build argument list for simctl launch + $simctlArgs = @("simctl", "launch", "--console-pty", $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 + } + + # Use Start-Process with output redirection and timeout (pattern from smoke-test-ios.ps1) + $outFile = New-TemporaryFile + $errFile = New-TemporaryFile + $consoleOut = @() + $exitCode = $null + + try { + $process = Start-Process "xcrun" ` + -ArgumentList $simctlArgs ` + -NoNewWindow -PassThru ` + -RedirectStandardOutput $outFile ` + -RedirectStandardError $errFile + + $timedOut = $null + $process | Wait-Process -Timeout $timeoutSeconds -ErrorAction SilentlyContinue -ErrorVariable timedOut + + if ($timedOut) { + Write-Warning "App timed out after $timeoutSeconds seconds - stopping process" + $process | Stop-Process -Force -ErrorAction SilentlyContinue + + # 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): $_" + } + Start-Sleep -Seconds 1 + + $exitCode = -1 + } + else { + $exitCode = $process.ExitCode + } + + # Read captured output + $consoleOut = @(Get-Content $outFile -ErrorAction SilentlyContinue) + ` + @(Get-Content $errFile -ErrorAction SilentlyContinue) + } + finally { + Remove-Item $outFile -ErrorAction SilentlyContinue + Remove-Item $errFile -ErrorAction SilentlyContinue + } + + Write-Host "Retrieved $($consoleOut.Count) output lines" -ForegroundColor Cyan + + return @{ + Platform = $this.Platform + ExecutablePath = $ExecutablePath + Arguments = $Arguments + StartedAt = $startTime + FinishedAt = Get-Date + Output = $consoleOut + ExitCode = $exitCode + } + } + + [void] TakeScreenshot([string]$OutputPath) { + Write-Debug "$($this.Platform): Taking screenshot to: $OutputPath" + + # Ensure destination directory exists + $destDir = Split-Path $OutputPath -Parent + if ($destDir -and -not (Test-Path $destDir)) { + New-Item -Path $destDir -ItemType Directory -Force | Out-Null + } + + $this.InvokeCommand('screenshot', @($this.SimulatorUUID, $OutputPath)) + + if (Test-Path $OutputPath) { + $size = (Get-Item $OutputPath).Length + Write-Debug "$($this.Platform): Screenshot saved ($size bytes)" + } + } + + [hashtable] GetDeviceLogs([string]$LogType, [int]$MaxEntries) { + Write-Debug "$($this.Platform): Getting device logs (type: $LogType, max: $MaxEntries)" + + $logs = @() + try { + $logs = @($this.InvokeCommand('log-show', @($this.SimulatorUUID))) + } + catch { + Write-Warning "Failed to retrieve simulator logs: $_" + } + + if ($MaxEntries -gt 0 -and $logs.Count -gt $MaxEntries) { + $logs = $logs | Select-Object -Last $MaxEntries + } + + return @{ + Platform = $this.Platform + LogType = $LogType + Logs = $logs + Count = $logs.Count + Timestamp = Get-Date + } + } + + [hashtable] GetDeviceStatus() { + Write-Debug "$($this.Platform): Getting device status" + + $state = $this.GetSimulatorState() + + return @{ + Platform = $this.Platform + Status = if ($state -eq 'Booted') { 'Online' } else { $state } + StatusData = @{ + SimulatorUUID = $this.SimulatorUUID + State = $state + } + Timestamp = Get-Date + } + } + + [string] GetDeviceIdentifier() { + return $this.SimulatorUUID + } + + [void] StartDevice() { + Write-Debug "$($this.Platform): Starting simulator" + if ($this.SimulatorUUID) { + $this.BootSimulator() + } + else { + Write-Warning "$($this.Platform): No simulator selected. Call Connect() first." + } + } + + [void] StopDevice() { + Write-Debug "$($this.Platform): Stopping simulator" + if ($this.SimulatorUUID) { + try { + $this.InvokeCommand('shutdown', @($this.SimulatorUUID)) + } + catch { + Write-Warning "Failed to shutdown simulator: $_" + } + } + } + + [void] RestartDevice() { + Write-Debug "$($this.Platform): Restarting simulator" + $this.StopDevice() + Start-Sleep -Seconds 2 + $this.BootSimulator() + } + + # Helper: Select the best simulator from the list (prefer booted), boot if needed, and return session info + hidden [hashtable] SelectAndConnect([object[]]$simulators, [string]$context) { + $label = if ($context) { " [$context]" } else { '' } + + $booted = @($simulators | Where-Object { $_.State -eq 'Booted' }) + if ($booted.Count -gt 0) { + $selected = $booted[0] + Write-Host "Using already-booted simulator: $($selected.Name) ($($selected.UUID))$label" -ForegroundColor Green + $this.SimulatorUUID = $selected.UUID + $this.DidBootSimulator = $false + } + else { + $selected = $simulators[0] + Write-Host "Booting simulator: $($selected.Name) ($($selected.UUID))$label" -ForegroundColor Yellow + $this.SimulatorUUID = $selected.UUID + $this.BootSimulator() + } + + return $this.CreateSessionInfo() + } + + # Helper: Boot the simulator with graceful handling + hidden [void] BootSimulator() { + Write-Debug "$($this.Platform): Booting simulator: $($this.SimulatorUUID)" + + try { + $this.InvokeCommand('boot', @($this.SimulatorUUID)) + } + catch { + # Check if already booted + if ("$_" -match 'Unable to boot device in current state: Booted') { + Write-Debug "$($this.Platform): Simulator is already booted" + $this.DidBootSimulator = $false + return + } + throw + } + + # Wait for simulator to be ready + $maxWait = $this.Timeouts['boot-timeout'] + $waited = 0 + while ($waited -lt $maxWait) { + $state = $this.GetSimulatorState() + if ($state -eq 'Booted') { + Write-Host "Simulator booted successfully" -ForegroundColor Green + $this.DidBootSimulator = $true + return + } + Start-Sleep -Seconds 1 + $waited++ + Write-Debug "$($this.Platform): Waiting for simulator to boot ($waited/$maxWait seconds)" + } + + throw "Simulator did not boot within $maxWait seconds" + } + + # Helper: Get available simulators, optionally filtered by runtime + hidden [object[]] GetAvailableSimulators([string]$runtimeFilter) { + $output = $this.InvokeCommand('list-devices', @()) + + $simulators = @() + $currentRuntime = $null + + foreach ($line in $output) { + # Match runtime headers: "-- iOS 17.0 --" or "-- iOS 18.2 (18.2 - 22C150) --" + if ($line -match '^--\s+(.+?)\s+--') { + $currentRuntime = $matches[1] + # Normalize: extract just "iOS X.Y" from longer strings like "iOS 18.2 (18.2 - 22C150)" + if ($currentRuntime -match '^(iOS\s+\d+\.\d+)') { + $currentRuntime = $matches[1] + } + continue + } + + # Skip non-iOS runtimes + if ($null -eq $currentRuntime -or $currentRuntime -notmatch '^iOS') { + continue + } + + # Apply runtime filter if specified + if ($runtimeFilter -and $currentRuntime -ne $runtimeFilter) { + continue + } + + # Parse device lines: " iPhone 15 Pro (UUID) (State)" + if ($line -match '^\s+(?.+)\s+\((?[0-9A-Fa-f\-]{36})\)\s+\((?\w+)\)') { + $simulators += [PSCustomObject]@{ + Name = $matches['model'].Trim() + UUID = $matches['uuid'] + State = $matches['state'] + Runtime = $currentRuntime + } + } + } + + # Filter out unavailable devices + $simulators = @($simulators | Where-Object { $_.State -ne 'Unavailable' }) + + return $simulators + } + + # Helper: Get the latest available iOS runtime + hidden [string] GetLatestRuntime() { + $runtimes = $this.InvokeCommand('list-runtimes', @()) + $lastRuntime = $runtimes | Select-Object -Last 1 + $result = [regex]::Match($lastRuntime, '(?iOS\s+[0-9.]+)') + if (-not $result.Success) { + throw "Failed to determine latest iOS runtime. Output: $lastRuntime" + } + $latestRuntime = $result.Groups['runtime'].Value + Write-Debug "$($this.Platform): Latest runtime: $latestRuntime" + return $latestRuntime + } + + # Helper: Get current simulator state + hidden [string] GetSimulatorState() { + $output = $this.InvokeCommand('list-devices', @()) + foreach ($line in $output) { + if ($line -match $this.SimulatorUUID) { + if ($line -match '\((?Booted|Shutdown|Shutting Down)\)\s*$') { + return $matches['state'] + } + } + } + return 'Unknown' + } + + # Override DetectAndSetDefaultTarget - not needed for iOS Simulator + [void] DetectAndSetDefaultTarget() { + Write-Debug "$($this.Platform): Target detection not needed for iOS Simulator" + } +} diff --git a/app-runner/Public/Connect-Device.ps1 b/app-runner/Public/Connect-Device.ps1 index 9f52b57..9a4d6d2 100644 --- a/app-runner/Public/Connect-Device.ps1 +++ b/app-runner/Public/Connect-Device.ps1 @@ -8,7 +8,7 @@ function Connect-Device { automatically handles devkit selection and IP address resolution. .PARAMETER Platform - The platform to connect to. Valid values: Xbox, PlayStation5, Switch, Windows, MacOS, Linux, Local (auto-detects current OS) + The platform to connect to. Valid values: Xbox, PlayStation5, Switch, Windows, MacOS, Linux, Adb, iOSSimulator, AndroidSauceLabs, iOSSauceLabs, Local (auto-detects current OS) .PARAMETER Target For Xbox platform, specifies the target to connect to. Can be either a name or IP address. @@ -37,11 +37,23 @@ function Connect-Device { .EXAMPLE Connect-Device -Platform "Local" # Connects to the local computer (auto-detects Windows, MacOS, or Linux) + + .EXAMPLE + Connect-Device -Platform "iOSSimulator" + # Auto-discovers an available iOS Simulator + + .EXAMPLE + Connect-Device -Platform "iOSSimulator" -Target "iOS 17.0" + # Connects to a simulator running iOS 17.0 + + .EXAMPLE + Connect-Device -Platform "iOSSimulator" -Target "iPhone 15 Pro" + # Connects to a simulator by device name #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('Xbox', 'PlayStation5', 'Switch', 'Windows', 'MacOS', 'Linux', 'Adb', 'AndroidSauceLabs', 'iOSSauceLabs', 'Local', 'Mock')] + [ValidateSet('Xbox', 'PlayStation5', 'Switch', 'Windows', 'MacOS', 'Linux', 'Adb', 'iOSSimulator', 'AndroidSauceLabs', 'iOSSauceLabs', 'Local', 'Mock')] [string]$Platform, [Parameter(Mandatory = $false)] diff --git a/app-runner/Public/Invoke-DeviceApp.ps1 b/app-runner/Public/Invoke-DeviceApp.ps1 index 2a89ead..fde0d0b 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -39,6 +39,7 @@ function Invoke-DeviceApp { .EXAMPLE Invoke-DeviceApp -ExecutablePath "Game.self" -Arguments @("-unattended") -WorkingDirectory "C:\Build\StagedBuilds\PS5" + #> [CmdletBinding()] param( diff --git a/app-runner/README.md b/app-runner/README.md index 10148f8..a64f578 100644 --- a/app-runner/README.md +++ b/app-runner/README.md @@ -73,6 +73,31 @@ Get-DeviceLogs -LogType "All" -MaxEntries 1000 Disconnect-Device ``` +### iOS Simulator Example + +```powershell +# Connect to iOS Simulator (auto-discovers available simulators) +Connect-Device -Platform "iOSSimulator" + +# Or connect to a specific iOS runtime version +Connect-Device -Platform "iOSSimulator" -Target "iOS 17.0" + +# Or use latest available runtime +Connect-Device -Platform "iOSSimulator" -Target "latest" + +# Install .app bundle (built for simulator) +Install-DeviceApp -Path "MyApp.app" + +# Run app using bundle ID +Invoke-DeviceApp -ExecutablePath "com.example.app" -Arguments @("--test", "smoke") + +# Collect diagnostics +Get-DeviceScreenshot -OutputPath "screenshot.png" + +# Disconnect (shuts down simulator only if it was booted by this session) +Disconnect-Device +``` + ## Supported Platforms ### Gaming Consoles @@ -84,6 +109,7 @@ Disconnect-Device ### Mobile Platforms - **Adb** - Android devices and emulators via Android Debug Bridge +- **iOSSimulator** - iOS Simulators via xcrun simctl (macOS only) - **AndroidSauceLabs** - Android devices on SauceLabs Real Device Cloud - **iOSSauceLabs** - iOS devices on SauceLabs Real Device Cloud (coming soon) @@ -96,8 +122,8 @@ Disconnect-Device **Notes:** - Desktop platforms execute applications locally on the same machine running the module. Device lifecycle operations (power on/off, reboot) are not supported for desktop platforms. - Mobile platforms require separate installation and execution steps: - - Use `Install-DeviceApp "MyApp.apk"` to install APK files - - Use `Invoke-DeviceApp "package.name/.ActivityName"` to run installed apps + - Android: Use `Install-DeviceApp "MyApp.apk"` to install APK files, then `Invoke-DeviceApp "package.name/.ActivityName"` to run + - iOS Simulator: Use `Install-DeviceApp "MyApp.app"` to install .app bundles, then `Invoke-DeviceApp "com.example.app"` with bundle ID - Android Intent extras should be passed as Arguments in the format: `-e key value` or `-ez key true/false` ## Functions @@ -189,6 +215,11 @@ Connect-Device -Platform "Xbox" -TimeoutSeconds 300 # 5 minutes - USB debugging enabled on physical devices - Device connected via USB or emulator running locally +**iOS Simulator:** +- macOS with Xcode and `xcrun` in PATH +- At least one iOS Simulator runtime installed +- `.app` bundles built for simulator (not `.ipa` archives) + **Android/iOS (SauceLabs):** - SauceLabs account with Real Device Cloud access - Environment variables: `SAUCE_USERNAME`, `SAUCE_ACCESS_KEY`, `SAUCE_REGION` diff --git a/app-runner/SentryAppRunner.psm1 b/app-runner/SentryAppRunner.psm1 index a910624..088369b 100644 --- a/app-runner/SentryAppRunner.psm1 +++ b/app-runner/SentryAppRunner.psm1 @@ -15,6 +15,7 @@ $ProviderFiles = @( "$PSScriptRoot\Private\DeviceProviders\MacOSProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\LinuxProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\AdbProvider.ps1", + "$PSScriptRoot\Private\DeviceProviders\iOSSimulatorProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\SauceLabsProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\MockDeviceProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\DeviceProviderFactory.ps1" diff --git a/app-runner/Tests/Fixtures/iOS/.gitignore b/app-runner/Tests/Fixtures/iOS/.gitignore index 8f51471..1af988e 100644 --- a/app-runner/Tests/Fixtures/iOS/.gitignore +++ b/app-runner/Tests/Fixtures/iOS/.gitignore @@ -5,6 +5,9 @@ xcuserdata/ *.xcarchive export/ +# Build artifacts from Build-SimulatorApp.ps1 +TestApp.app/ + # Temporary build files build/ DerivedData/ diff --git a/app-runner/Tests/Fixtures/iOS/Build-SimulatorApp.ps1 b/app-runner/Tests/Fixtures/iOS/Build-SimulatorApp.ps1 new file mode 100644 index 0000000..9a812fe --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/Build-SimulatorApp.ps1 @@ -0,0 +1,73 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Builds the TestApp for iOS Simulator. + +.DESCRIPTION + This script builds the TestApp as a .app bundle for the iOS Simulator. + The .app is copied to the parent directory (Tests/Fixtures/iOS) after a successful build. + +.EXAMPLE + ./Build-SimulatorApp.ps1 +#> + +$ErrorActionPreference = 'Stop' + +$ProjectRoot = $PSScriptRoot + +Write-Information "Building SentryTestApp for iOS Simulator..." -InformationAction Continue + +try { + Push-Location $ProjectRoot + + # Clean previous builds + & xcodebuild -project TestApp.xcodeproj -scheme TestApp clean -quiet + + if ($LASTEXITCODE -ne 0) { + throw "Xcode clean failed with exit code $LASTEXITCODE" + } + + # Build for iOS Simulator + $derivedDataPath = Join-Path $ProjectRoot "DerivedData" + + & xcodebuild -project TestApp.xcodeproj ` + -scheme TestApp ` + -sdk iphonesimulator ` + -configuration Debug ` + -derivedDataPath $derivedDataPath ` + -quiet + + if ($LASTEXITCODE -ne 0) { + throw "Xcode build failed with exit code $LASTEXITCODE" + } + + # Find the built .app bundle + $builtApp = Get-ChildItem -Path "$derivedDataPath/Build/Products/Debug-iphonesimulator" -Filter "*.app" -Directory | Select-Object -First 1 + + if (-not $builtApp) { + throw "No .app bundle found in build output" + } + + # Copy to fixture directory + $targetApp = Join-Path $ProjectRoot "TestApp.app" + if (Test-Path $targetApp) { + Remove-Item $targetApp -Recurse -Force + } + Copy-Item -Path $builtApp.FullName -Destination $targetApp -Recurse + + Write-Information "App built successfully: $targetApp" -InformationAction Continue + $appSize = (Get-ChildItem $targetApp -Recurse | Measure-Object -Property Length -Sum).Sum + Write-Information " Size: $([math]::Round($appSize / 1MB, 2)) MB" -InformationAction Continue + + # Cleanup + if (Test-Path $derivedDataPath) { + Remove-Item $derivedDataPath -Recurse -Force + } +} +catch { + Write-Error "Failed to build simulator app: $_" + exit 1 +} +finally { + Pop-Location +} diff --git a/app-runner/Tests/iOSSimulator.Tests.ps1 b/app-runner/Tests/iOSSimulator.Tests.ps1 new file mode 100644 index 0000000..076e541 --- /dev/null +++ b/app-runner/Tests/iOSSimulator.Tests.ps1 @@ -0,0 +1,164 @@ +$ErrorActionPreference = 'Stop' + +BeforeDiscovery { + # Define test targets + function Get-TestTarget { + param( + [string]$Platform, + [string]$Target + ) + + $TargetName = if ($Target) { + "$Platform-$Target" + } + else { + $Platform + } + + return @{ + Platform = $Platform + Target = $Target + TargetName = $TargetName + } + } + + $TestTargets = @() + + # Detect if running in CI environment + $isCI = $env:CI -eq 'true' + + # Check for macOS platform and xcrun availability + if (-not $IsMacOS) { + Write-Warning "iOSSimulator tests require macOS. iOSSimulator tests will be skipped." + } + elseif (-not (Get-Command 'xcrun' -ErrorAction SilentlyContinue)) { + $message = "xcrun not found in PATH" + if ($isCI) { + throw "$message. This is required in CI." + } + else { + Write-Warning "$message. iOSSimulator tests will be skipped." + } + } + elseif (-not ((xcrun simctl list devices) -match '\(([0-9A-Fa-f\-]{36})\)')) { + $message = "No iOS simulators available" + if ($isCI) { + throw "$message. This is required in CI." + } + else { + Write-Warning "$message. iOSSimulator tests will be skipped." + } + } + else { + $TestTargets += Get-TestTarget -Platform 'iOSSimulator' + } +} + +BeforeAll { + # Import the module + Import-Module "$PSScriptRoot\..\SentryAppRunner.psm1" -Force + + # Helper function for cleanup + function Invoke-TestCleanup { + try { + if (Get-DeviceSession) { + Disconnect-Device + } + } + catch { + # Ignore cleanup errors + Write-Debug "Cleanup failed: $_" + } + } +} + +Describe '' -Tag 'iOSSimulator' -ForEach $TestTargets { + Context 'Device Connection Management' -Tag $TargetName { + AfterEach { + Invoke-TestCleanup + } + + It 'Connect-Device establishes valid session' { + { Connect-Device -Platform $Platform -Target $Target } | Should -Not -Throw + + $session = Get-DeviceSession + $session | Should -Not -BeNullOrEmpty + $session.Platform | Should -Be $Platform + $session.IsConnected | Should -BeTrue + } + + It 'Get-DeviceStatus returns status information' { + Connect-Device -Platform $Platform -Target $Target + + $status = Get-DeviceStatus + $status | Should -Not -BeNullOrEmpty + $status.Status | Should -Be 'Online' + } + + It 'Connect-Device with "latest" target selects a simulator' { + { Connect-Device -Platform $Platform -Target 'latest' } | Should -Not -Throw + + $session = Get-DeviceSession + $session | Should -Not -BeNullOrEmpty + $session.IsConnected | Should -BeTrue + } + } + + Context 'Application Management' -Tag $TargetName { + BeforeDiscovery { + $testApp = "$PSScriptRoot/Fixtures/iOS/TestApp.app" + $shouldSkip = -not (Test-Path $testApp) + if ($shouldSkip) { + Write-Warning "Test .app bundle not found at: $testApp. Run Build-SimulatorApp.ps1 first." + } + } + + BeforeAll { + Connect-Device -Platform $Platform -Target $Target + $script:appPath = Join-Path $PSScriptRoot 'Fixtures' 'iOS' 'TestApp.app' + } + + AfterAll { + Invoke-TestCleanup + } + + It 'Install-DeviceApp installs .app bundle' -Skip:$shouldSkip { + { Install-DeviceApp -Path $script:appPath } | Should -Not -Throw + } + + It 'Invoke-DeviceApp executes application' -Skip:$shouldSkip { + $bundleId = 'io.sentry.apprunner.TestApp' + + $result = Invoke-DeviceApp -ExecutablePath $bundleId -Arguments @('--test-mode', 'simulator') + $result | Should -Not -BeNullOrEmpty + $result.Output | Should -Not -BeNullOrEmpty + } + } + + Context 'Screenshot Capture' -Tag $TargetName { + BeforeAll { + Connect-Device -Platform $Platform -Target $Target + } + + AfterAll { + Invoke-TestCleanup + } + + It 'Get-DeviceScreenshot captures screenshot' { + $outputPath = Join-Path $TestDrive "test_screenshot_$Platform.png" + + try { + { Get-DeviceScreenshot -OutputPath $outputPath } | Should -Not -Throw + + Test-Path $outputPath | Should -Be $true + $fileInfo = Get-Item $outputPath + $fileInfo.Length | Should -BeGreaterThan 0 + } + finally { + if (Test-Path $outputPath) { + Remove-Item $outputPath -Force + } + } + } + } +}