diff --git a/app-runner/Private/AndroidHelpers.ps1 b/app-runner/Private/AndroidHelpers.ps1 index c67c67b..1eab77a 100644 --- a/app-runner/Private/AndroidHelpers.ps1 +++ b/app-runner/Private/AndroidHelpers.ps1 @@ -39,45 +39,65 @@ function ConvertFrom-AndroidActivityPath { <# .SYNOPSIS -Validates that Android Intent extras are in the correct format. +Validates that an array of arguments can be safely converted to Intent extras format. .DESCRIPTION -Android Intent extras should be passed in the format understood by `am start`. -This function validates and optionally formats the arguments string. - -Common Intent extra formats: - -e key value String extra - -es key value String extra (explicit) - -ez key true|false Boolean extra - -ei key value Integer extra - -el key value Long extra +Validates each element of an argument array to ensure they form valid Intent extras +when combined. This prevents issues where individual elements are valid but the +combined string breaks Intent extras format. .PARAMETER Arguments -The arguments string to validate/format +Array of string arguments to validate .EXAMPLE -Test-IntentExtrasFormat "-e cmdline -crash-capture" +Test-IntentExtrasArray @('-e', 'key', 'value') Returns: $true .EXAMPLE -Test-IntentExtrasFormat "-e test true -ez debug false" -Returns: $true +Test-IntentExtrasArray @('-e', 'key with spaces', 'value') +Returns: $true (will be quoted properly) + +.EXAMPLE +Test-IntentExtrasArray @('invalid', 'format') +Throws error for invalid format #> -function Test-IntentExtrasFormat { +function Test-IntentExtrasArray { [CmdletBinding()] param( [Parameter(Mandatory = $false)] - [string]$Arguments + [string[]]$Arguments ) - if ([string]::IsNullOrWhiteSpace($Arguments)) { + if (-not $Arguments -or $Arguments.Count -eq 0) { return $true } - # Intent extras must start with flags: -e, -es, -ez, -ei, -el, -ef, -eu, etc. - # Followed by at least one whitespace and additional content - if ($Arguments -notmatch '^--?[a-z]{1,2}\s+') { - throw "Invalid Intent extras format: '$Arguments'. Must start with flags like -e, -es, -ez, -ei, -el, etc. followed by key-value pairs." + # Only validate specific patterns we understand and can verify + # Don't throw errors on unknown patterns - just validate what we know + $knownKeyValueFlags = @('-e', '-es', '--es', '-ez', '--ez', '-ei', '--ei', '-el', '--el') + + for ($i = 0; $i -lt $Arguments.Count; $i++) { + $currentArg = $Arguments[$i] + + if ($knownKeyValueFlags -contains $currentArg) { + # For known key-value flags, ensure proper structure + if ($i + 2 -ge $Arguments.Count) { + throw "Invalid Intent extras format: Flag '$currentArg' must be followed by key and value. Missing arguments." + } + + $key = $Arguments[$i + 1] + $value = $Arguments[$i + 2] + + # For boolean flags, validate the value + if ($currentArg -in @('-ez', '--ez') -and $value -notin @('true', 'false')) { + throw "Invalid Intent extras format: Boolean flag '$currentArg' requires 'true' or 'false' value, got: '$value'" + } + + # Skip the key and value we just validated + $i += 2 + } + # For all other arguments (including single tokens like --grant-read-uri-permission), + # just continue - don't validate what we don't understand } return $true @@ -132,7 +152,7 @@ function Get-ApkPackageName { } Write-Debug "Using $($aaptCmd.Name) to extract package name from APK" - + try { $PSNativeCommandUseErrorActionPreference = $false $output = & $aaptCmd.Name dump badging $ApkPath 2>&1 diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 index 5b53c82..0f1bee1 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -195,9 +195,14 @@ class AdbProvider : DeviceProvider { } } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { + # LogFilePath parameter ignored in this implementation Write-Debug "$($this.Platform): Running application: $ExecutablePath" + if (-not ([string]::IsNullOrEmpty($LogFilePath))) { + Write-Warning "LogFilePath parameter is not supported on this platform." + } + # Parse ExecutablePath: "package.name/activity.name" $parsed = ConvertFrom-AndroidActivityPath -ExecutablePath $ExecutablePath $packageName = $parsed.PackageName @@ -205,8 +210,8 @@ class AdbProvider : DeviceProvider { $this.CurrentPackageName = $packageName # Validate Intent extras format - if ($Arguments) { - Test-IntentExtrasFormat -Arguments $Arguments | Out-Null + if ($Arguments -and $Arguments.Count -gt 0) { + Test-IntentExtrasArray -Arguments $Arguments | Out-Null } $timeoutSeconds = $this.Timeouts['run-timeout'] @@ -221,11 +226,13 @@ class AdbProvider : DeviceProvider { # Launch activity Write-Host "Launching: $ExecutablePath" -ForegroundColor Cyan - if ($Arguments) { - Write-Host " Arguments: $Arguments" -ForegroundColor Cyan + + $argumentsString = $Arguments -join ' ' + if ($argumentsString) { + Write-Host " Arguments: $argumentsString" -ForegroundColor Cyan } - $launchOutput = $this.InvokeCommand('launch', @($this.DeviceSerial, $ExecutablePath, $Arguments)) + $launchOutput = $this.InvokeCommand('launch', @($this.DeviceSerial, $ExecutablePath, $argumentsString)) # Join output to string first since -match on arrays returns matching elements, not boolean if (($launchOutput -join "`n") -match 'Error') { diff --git a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 index 037c3be..a1bc8df 100644 --- a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 @@ -372,14 +372,19 @@ class DeviceProvider { return @{} } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { Write-Debug "$($this.Platform): Running application: $ExecutablePath with arguments: $Arguments" - $command = $this.BuildCommand('launch', @($ExecutablePath, $Arguments)) + if (-not ([string]::IsNullOrEmpty($LogFilePath))) { + Write-Warning "LogFilePath parameter is not supported on this platform." + } + + $argumentsString = $Arguments -join ' ' + $command = $this.BuildCommand('launch', @($ExecutablePath, $argumentsString)) return $this.InvokeApplicationCommand($command, $ExecutablePath, $Arguments) } - [hashtable] InvokeApplicationCommand([BuiltCommand]$builtCommand, [string]$ExecutablePath, [string]$Arguments) { + [hashtable] InvokeApplicationCommand([BuiltCommand]$builtCommand, [string]$ExecutablePath, [string[]]$Arguments) { Write-Debug "$($this.Platform): Invoking $($builtCommand.Command)" $result = $null diff --git a/app-runner/Private/DeviceProviders/MockDeviceProvider.ps1 b/app-runner/Private/DeviceProviders/MockDeviceProvider.ps1 index 90597a8..4185907 100644 --- a/app-runner/Private/DeviceProviders/MockDeviceProvider.ps1 +++ b/app-runner/Private/DeviceProviders/MockDeviceProvider.ps1 @@ -181,7 +181,7 @@ class MockDeviceProvider : DeviceProvider { } } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { Write-Debug "Mock: Running application $ExecutablePath with args: $Arguments" $this.MockConfig.AppRunning = $true diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 029dc6c..20bb87b 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -20,6 +20,7 @@ Key features: - Appium session management (create, reuse, delete) - App execution with state monitoring - Logcat/Syslog retrieval via Appium +- On-device log file retrieval (optional override with fallback to Logcat/Syslog) - Screenshot capture Requirements: @@ -303,7 +304,7 @@ class SauceLabsProvider : DeviceProvider { } } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { Write-Debug "$($this.Platform): Running application: $ExecutablePath" if (-not $this.SessionId) { @@ -323,14 +324,16 @@ class SauceLabsProvider : DeviceProvider { $this.CurrentPackageName = $packageName # Validate Intent extras format - if ($Arguments) { - Test-IntentExtrasFormat -Arguments $Arguments | Out-Null + if ($Arguments -and $Arguments.Count -gt 0) { + Test-IntentExtrasArray -Arguments $Arguments | Out-Null } # Launch activity with Intent extras Write-Host "Launching: $packageName/$activityName" -ForegroundColor Cyan - if ($Arguments) { - Write-Host " Arguments: $Arguments" -ForegroundColor Cyan + + $argumentsString = $Arguments -join ' ' + if ($argumentsString) { + Write-Host " Arguments: $argumentsString" -ForegroundColor Cyan } $launchBody = @{ @@ -341,11 +344,12 @@ class SauceLabsProvider : DeviceProvider { intentCategory = 'android.intent.category.LAUNCHER' } - if ($Arguments) { - $launchBody['optionalIntentArguments'] = $Arguments + if ($argumentsString) { + $launchBody['optionalIntentArguments'] = $argumentsString } try { + Write-Debug "Launching activity with arguments: $argumentsString" $launchResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/appium/device/start_activity", $launchBody, $false, $null) Write-Debug "Launch response: $($launchResponse | ConvertTo-Json)" } @@ -361,27 +365,17 @@ class SauceLabsProvider : DeviceProvider { Write-Host "Launching: $bundleId" -ForegroundColor Cyan if ($Arguments) { Write-Host " Arguments: $Arguments" -ForegroundColor Cyan - Write-Warning "Passing arguments to iOS apps via SauceLabs/Appium might require specific app capability configuration." - } - - $launchBody = @{ - bundleId = $bundleId - } - - if ($Arguments) { - # Appium 'mobile: launchApp' supports arguments? - # Or use 'appium:processArguments' capability during session creation? - # For now, we'll try to pass them if supported by the endpoint or warn. - $launchBody['arguments'] = $Arguments -split ' ' # Simple split, might need better parsing } try { - # Use mobile: launchApp for iOS - $scriptBody = @{ + $body = @{ script = "mobile: launchApp" - args = $launchBody + args = @{ + bundleId = $bundleId + arguments = $Arguments + } } - $launchResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $scriptBody, $false, $null) + $launchResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $body, $false, $null) Write-Debug "Launch response: $($launchResponse | ConvertTo-Json)" } catch { @@ -398,15 +392,17 @@ class SauceLabsProvider : DeviceProvider { while ((Get-Date) - $startTime -lt [TimeSpan]::FromSeconds($timeoutSeconds)) { # Query app state using Appium's mobile: queryAppState - $stateBody = @{ + # Use correct parameter name based on platform: appId for Android, bundleId for iOS + $appParameter = if ($this.MobilePlatform -eq 'Android') { 'appId' } else { 'bundleId' } + $body = @{ script = 'mobile: queryAppState' - args = @( - @{ appId = $this.CurrentPackageName } # Use stored package/bundle ID - ) + args = @{ + $appParameter = $this.CurrentPackageName + } } try { - $stateResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $stateBody, $false, $null) + $stateResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $body, $false, $null) $appState = $stateResponse.value Write-Debug "App state: $appState (elapsed: $([int]((Get-Date) - $startTime).TotalSeconds)s)" @@ -431,38 +427,64 @@ class SauceLabsProvider : DeviceProvider { Write-Host "Warning: App did not exit within timeout" -ForegroundColor Yellow } - # Retrieving logs after app completion + # Retrieve logs - try log file first if provided, otherwise use system logs Write-Host "Retrieving logs..." -ForegroundColor Yellow - $logType = if ($this.MobilePlatform -eq 'iOS') { 'syslog' } else { 'logcat' } - $logBody = @{ type = $logType } - $logResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/log", $logBody, $false, $null) - - [array]$allLogs = @() - if ($logResponse.value -and $logResponse.value.Count -gt 0) { - $allLogs = @($logResponse.value) - Write-Host "Retrieved $($allLogs.Count) log lines" -ForegroundColor Cyan - } - - # Convert SauceLabs log format to text (matching ADB output format) - $logCache = @() - if ($allLogs -and $allLogs.Count -gt 0) { - $logCache = $allLogs | ForEach-Object { - if ($_) { - $timestamp = if ($_.timestamp) { $_.timestamp } else { '' } - $level = if ($_.level) { $_.level } else { '' } - $message = if ($_.message) { $_.message } else { '' } - "$timestamp $level $message" + + $formattedLogs = @() + + # Try log file if path provided + if (-not [string]::IsNullOrWhiteSpace($LogFilePath)) { + try { + Write-Host "Attempting to retrieve log file: $LogFilePath" -ForegroundColor Cyan + $tempLogFile = [System.IO.Path]::GetTempFileName() + + try { + $this.CopyDeviceItem($LogFilePath, $tempLogFile) + $logFileContent = Get-Content -Path $tempLogFile -Raw + + if ($logFileContent) { + $formattedLogs = $logFileContent -split "`n" | Where-Object { $_.Trim() -ne "" } + Write-Host "Retrieved log file with $($formattedLogs.Count) lines" -ForegroundColor Green + } + } finally { + Remove-Item $tempLogFile -Force -ErrorAction SilentlyContinue } - } | Where-Object { $_ } # Filter out any nulls + } + catch { + Write-Warning "Failed to retrieve log file: $($_.Exception.Message)" + Write-Host "Falling back to system logs..." -ForegroundColor Yellow + } } - # Format logs consistently (Android only for now) - $formattedLogs = $logCache - if ($this.MobilePlatform -eq 'Android') { - $formattedLogs = Format-LogcatOutput -LogcatOutput $logCache + # Fallback to system logs if log file not retrieved + if (-not $formattedLogs) { + $logType = if ($this.MobilePlatform -eq 'iOS') { 'syslog' } else { 'logcat' } + $logResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/log", @{ type = $logType }, $false, $null) + + if ($logResponse.value) { + Write-Host "Retrieved $($logResponse.value.Count) system log lines" -ForegroundColor Cyan + $logCache = $logResponse.value | ForEach-Object { + "$($_.timestamp) $($_.level) $($_.message)" + } | Where-Object { $_ } + + $formattedLogs = if ($this.MobilePlatform -eq 'Android') { + Format-LogcatOutput -LogcatOutput $logCache + } else { + $logCache + } + } + } + + # Output logs. + # NOTE: System logs are very noisy, so only enabled on GitHub and in a folded group. + if ($formattedLogs -and $env:GITHUB_ACTIONS -eq 'true') { + Write-GitHub "::group::Logs" + $formattedLogs | ForEach-Object { + Write-Host "$_" + } + Write-GitHub "::endgroup::" } - # Return result matching app-runner pattern return @{ Platform = $this.Platform ExecutablePath = $ExecutablePath @@ -587,8 +609,180 @@ class SauceLabsProvider : DeviceProvider { return @() } + <# + .SYNOPSIS + Checks if the current app supports file sharing capability on iOS devices. + + .DESCRIPTION + Uses Appium's mobile: listApps command to retrieve app information and check + if UIFileSharingEnabled is set for the current app bundle. + + .OUTPUTS + Hashtable with app capability information including Found, FileSharingEnabled, and AllApps. + #> + [hashtable] CheckAppFileSharingCapability() { + if (-not $this.SessionId) { + throw "No active SauceLabs session. Call InstallApp first to create a session." + } + + try { + $baseUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" + $body = @{ script = 'mobile: listApps'; args = @() } + + $response = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $body, $false, $null) + + if ($response -and $response.value) { + $apps = $response.value + $bundleIds = $apps.Keys | Where-Object { $_ } + + if ($apps.ContainsKey($this.CurrentPackageName)) { + $targetApp = $apps[$this.CurrentPackageName] + return @{ + Found = $true + BundleId = $this.CurrentPackageName + FileSharingEnabled = [bool]$targetApp.UIFileSharingEnabled + Name = $( + if ($targetApp.CFBundleDisplayName) { $targetApp.CFBundleDisplayName } + elseif ($targetApp.CFBundleName) { $targetApp.CFBundleName } + else { "Unknown" } + ) + AllApps = $bundleIds + } + } + + return @{ + Found = $false + BundleId = $this.CurrentPackageName + FileSharingEnabled = $false + AllApps = $bundleIds + } + } + + return @{ Found = $false; BundleId = $this.CurrentPackageName; FileSharingEnabled = $false; AllApps = @() } + } + catch { + return @{ Found = $false; BundleId = $this.CurrentPackageName; FileSharingEnabled = $false; AllApps = @(); Error = $_.Exception.Message } + } + } + + <# + .SYNOPSIS + Copies a file from the SauceLabs device to the local machine. + + .DESCRIPTION + Retrieves files from iOS/Android devices via Appium's pull_file API. + + .PARAMETER DevicePath + Path to the file on the device: + - iOS: Bundle format @bundle.id:documents/file.log + - Android: Absolute path /data/data/package.name/files/logs/file.log (requires debuggable=true) + + .PARAMETER Destination + Local destination path where the file should be saved. + + .NOTES + iOS Requirements: + - App must have UIFileSharingEnabled=true in info.plist + - Files must be in the app's Documents directory + + Android Requirements: + - Internal storage paths are only accessible for debuggable apps + - App must be built with android:debuggable="true" in AndroidManifest.xml + #> [void] CopyDeviceItem([string]$DevicePath, [string]$Destination) { - Write-Warning "$($this.Platform): CopyDeviceItem is not supported for SauceLabs cloud devices" + if (-not $this.SessionId) { + throw "No active SauceLabs session. Call InstallApp first to create a session." + } + + try { + # Pull file from device via Appium API + $baseUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" + $response = $this.InvokeSauceLabsApi('POST', "$baseUri/appium/device/pull_file", @{ path = $DevicePath }, $false, $null) + + if (-not $response -or -not $response.value) { + throw "No file content returned from device" + } + + # Prepare destination path + if (-not [System.IO.Path]::IsPathRooted($Destination)) { + $Destination = Join-Path (Get-Location) $Destination + } + + $destinationDir = Split-Path $Destination -Parent + if ($destinationDir -and -not (Test-Path $destinationDir)) { + New-Item -Path $destinationDir -ItemType Directory -Force | Out-Null + } + + if (Test-Path $Destination) { + Remove-Item $Destination -Force -ErrorAction SilentlyContinue + } + + # Decode and save file + $fileBytes = [System.Convert]::FromBase64String($response.value) + [System.IO.File]::WriteAllBytes($Destination, $fileBytes) + + Write-Host "Successfully copied file from device: $DevicePath -> $Destination" -ForegroundColor Green + } + catch { + $this.HandleCopyDeviceItemError($_, $DevicePath) + } + } + + <# + .SYNOPSIS + Handles errors from CopyDeviceItem with helpful diagnostic information. + #> + [void] HandleCopyDeviceItemError([System.Management.Automation.ErrorRecord]$Error, [string]$DevicePath) { + $errorMsg = "Failed to copy file from device: $DevicePath. Error: $($Error.Exception.Message)" + + # Add platform-specific troubleshooting for server errors + if ($Error.Exception.Message -match "500|Internal Server Error") { + $errorMsg += "`n`nTroubleshooting $($this.MobilePlatform) file access:" + $errorMsg += "`n- App Package/Bundle ID: '$($this.CurrentPackageName)'" + $errorMsg += "`n- Requested path: '$DevicePath'" + + if ($this.MobilePlatform -eq 'iOS') { + try { + $appInfo = $this.CheckAppFileSharingCapability() + if ($appInfo.AllApps -and $appInfo.AllApps.Count -gt 0) { + $errorMsg += "`n- Available apps: $($appInfo.AllApps -join ', ')" + if ($appInfo.Found -and -not $appInfo.FileSharingEnabled) { + $errorMsg += "`n- App found but UIFileSharingEnabled=false" + } + } + } catch { + $errorMsg += "`n- Could not check app capabilities: $($_.Exception.Message)" + } + + $errorMsg += "`n`nCommon iOS causes:" + $errorMsg += "`n1. App missing UIFileSharingEnabled=true in info.plist" + $errorMsg += "`n2. File doesn't exist on device" + $errorMsg += "`n3. Incorrect path format - must use @bundle.id:documents/relative_path" + + if ($this.CurrentPackageName) { + $errorMsg += "`n`nRequired iOS format: @$($this.CurrentPackageName):documents/relative_path" + } + } + elseif ($this.MobilePlatform -eq 'Android') { + $errorMsg += "`n`nMost likely cause: App not built with debuggable flag" + $errorMsg += "`n" + $errorMsg += "`nFor Android internal storage access (/data/data/...), the app MUST be built with:" + $errorMsg += "`n android:debuggable='true' in AndroidManifest.xml" + $errorMsg += "`n" + $errorMsg += "`nOther possible causes:" + $errorMsg += "`n2. File doesn't exist on device (less likely)" + $errorMsg += "`n3. Incorrect path format or permissions" + + if ($this.CurrentPackageName) { + $errorMsg += "`n`nWorking path formats:" + $errorMsg += "`n- Internal storage: /data/data/$($this.CurrentPackageName)/files/app.log (needs debuggable=true)" + $errorMsg += "`n- App-relative: @$($this.CurrentPackageName)/files/app.log (needs debuggable=true)" + } + } + } + + Write-Warning $errorMsg + throw } # Override DetectAndSetDefaultTarget - not needed for SauceLabs diff --git a/app-runner/Private/DeviceProviders/XboxProvider.ps1 b/app-runner/Private/DeviceProviders/XboxProvider.ps1 index dc83d00..78fe16b 100644 --- a/app-runner/Private/DeviceProviders/XboxProvider.ps1 +++ b/app-runner/Private/DeviceProviders/XboxProvider.ps1 @@ -238,11 +238,12 @@ class XboxProvider : DeviceProvider { } # Launch an already-installed packaged application - [hashtable] LaunchInstalledApp([string]$PackageIdentity, [string]$Arguments) { + [hashtable] LaunchInstalledApp([string]$PackageIdentity, [string[]]$Arguments) { # Not giving the argument here stops any foreground app $this.InvokeCommand('stop-app', @('')) - $builtCommand = $this.BuildCommand('launch-app', @($PackageIdentity, $Arguments)) + $argumentsString = $Arguments -join ' ' + $builtCommand = $this.BuildCommand('launch-app', @($PackageIdentity, $argumentsString)) return $this.InvokeApplicationCommand($builtCommand, $PackageIdentity, $Arguments) } @@ -251,7 +252,11 @@ class XboxProvider : DeviceProvider { # - A directory containing loose .exe files (uses xbrun) # - A package identifier (AUMID string) for already-installed packages (uses xbapp launch) # - A .xvc file path (ERROR - user must use Install-DeviceApp first) - [hashtable] RunApplication([string]$AppPath, [string]$Arguments) { + [hashtable] RunApplication([string]$AppPath, [string[]]$Arguments, [string]$LogFilePath = $null) { + if (-not ([string]::IsNullOrEmpty($LogFilePath))) { + Write-Warning "LogFilePath parameter is not supported on this platform." + } + if (Test-Path $AppPath -PathType Container) { # It's a directory - use loose executable flow (xbrun) $appExecutableName = Get-ChildItem -Path $AppPath -File -Filter '*.exe' | Select-Object -First 1 -ExpandProperty Name @@ -261,7 +266,8 @@ class XboxProvider : DeviceProvider { Write-Host "Mirroring directory $AppPath to Xbox devkit $xboxTempDir..." $this.InvokeCommand('xbcopy', @($AppPath, "x$xboxTempDir")) - $builtCommand = $this.BuildCommand('launch', @($xboxTempDir, "$xboxTempDir\$appExecutableName", $Arguments)) + $argumentsString = $Arguments -join ' ' + $builtCommand = $this.BuildCommand('launch', @($xboxTempDir, "$xboxTempDir\$appExecutableName", $argumentsString)) return $this.InvokeApplicationCommand($builtCommand, $appExecutableName, $Arguments) } elseif (Test-Path $AppPath -PathType Leaf) { # It's a file - check if it's a .xvc package diff --git a/app-runner/Public/Invoke-DeviceApp.ps1 b/app-runner/Public/Invoke-DeviceApp.ps1 index 7180c4f..25055d4 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -11,10 +11,26 @@ function Invoke-DeviceApp { Path to the executable file to run on the device. .PARAMETER Arguments - Arguments to pass to the executable when starting it. + Array of arguments to pass to the executable when starting it. + The caller is responsible for quoting/escaping the arguments. + For example, if the executable requires arguments with spaces, they should be quoted: + Invoke-DeviceApp -ExecutablePath "Game.exe" -Arguments @('"/path/to/some file.txt"', '--debug') + + .PARAMETER LogFilePath + Optional path to a log file on the device to retrieve instead of using system logs (syslog/logcat). + This parameter is only supported on SauceLabs platforms for now. + Path format is platform-specific: + - iOS: Use bundle format like "@com.example.app:documents/logs/app.log" + - Android: Use absolute path like "/data/data/com.example.app/files/logs/app.log" + + .EXAMPLE + Invoke-DeviceApp -ExecutablePath "MyGame.exe" -Arguments @("--debug", "--level=1") .EXAMPLE Invoke-DeviceApp -ExecutablePath "MyGame.exe" -Arguments "--debug --level=1" + + .EXAMPLE + Invoke-DeviceApp -ExecutablePath "com.example.app" -LogFilePath "@com.example.app:documents/logs/app.log" #> [CmdletBinding()] param( @@ -23,7 +39,10 @@ function Invoke-DeviceApp { [string]$ExecutablePath, [Parameter(Mandatory = $false)] - [string]$Arguments = "" + [string[]]$Arguments = @(), + + [Parameter(Mandatory = $false)] + [string]$LogFilePath = $null ) Assert-DeviceSession @@ -35,7 +54,7 @@ function Invoke-DeviceApp { # Use the provider to run the application $provider = $script:CurrentSession.Provider - $result = $provider.RunApplication($ExecutablePath, $Arguments) + $result = $provider.RunApplication($ExecutablePath, $Arguments, $LogFilePath) Write-GitHub "::endgroup::" diff --git a/app-runner/Tests/AndroidHelpers.Tests.ps1 b/app-runner/Tests/AndroidHelpers.Tests.ps1 index 3e3a7c4..2c58382 100644 --- a/app-runner/Tests/AndroidHelpers.Tests.ps1 +++ b/app-runner/Tests/AndroidHelpers.Tests.ps1 @@ -59,53 +59,101 @@ Describe 'AndroidHelpers' -Tag 'Unit', 'Android' { } } - Context 'Test-IntentExtrasFormat' { - It 'Accepts valid Intent extras with -e flag' { - { Test-IntentExtrasFormat -Arguments '-e key value' } | Should -Not -Throw + Context 'Test-IntentExtrasArray' { + It 'Accepts valid Intent extras array with -e flag' { + { Test-IntentExtrasArray -Arguments @('-e', 'key', 'value') } | Should -Not -Throw } - It 'Accepts valid Intent extras with -es flag' { - { Test-IntentExtrasFormat -Arguments '-es stringKey stringValue' } | Should -Not -Throw + It 'Accepts valid Intent extras array with -es flag' { + { Test-IntentExtrasArray -Arguments @('-es', 'stringKey', 'stringValue') } | Should -Not -Throw } - It 'Accepts valid Intent extras with -ez flag' { - { Test-IntentExtrasFormat -Arguments '-ez boolKey true' } | Should -Not -Throw + It 'Accepts valid Intent extras array with --es flag' { + { Test-IntentExtrasArray -Arguments @('--es', 'stringKey', 'stringValue') } | Should -Not -Throw } - It 'Accepts valid Intent extras with -ei flag' { - { Test-IntentExtrasFormat -Arguments '-ei intKey 42' } | Should -Not -Throw + It 'Accepts valid Intent extras array with -ez flag and true' { + { Test-IntentExtrasArray -Arguments @('-ez', 'boolKey', 'true') } | Should -Not -Throw } - It 'Accepts valid Intent extras with -el flag' { - { Test-IntentExtrasFormat -Arguments '-el longKey 1234567890' } | Should -Not -Throw + It 'Accepts valid Intent extras array with -ez flag and false' { + { Test-IntentExtrasArray -Arguments @('-ez', 'boolKey', 'false') } | Should -Not -Throw } - It 'Accepts multiple Intent extras' { - { Test-IntentExtrasFormat -Arguments '-e key1 value1 -ez key2 false -ei key3 100' } | Should -Not -Throw + It 'Accepts valid Intent extras array with --ez flag and true' { + { Test-IntentExtrasArray -Arguments @('--ez', 'boolKey', 'true') } | Should -Not -Throw } - It 'Accepts empty string' { - { Test-IntentExtrasFormat -Arguments '' } | Should -Not -Throw + It 'Accepts valid Intent extras array with --ez flag and false' { + { Test-IntentExtrasArray -Arguments @('--ez', 'boolKey', 'false') } | Should -Not -Throw + } + + It 'Accepts valid Intent extras array with -ei flag' { + { Test-IntentExtrasArray -Arguments @('-ei', 'intKey', '42') } | Should -Not -Throw + } + + It 'Accepts valid Intent extras array with -el flag' { + { Test-IntentExtrasArray -Arguments @('-el', 'longKey', '1234567890') } | Should -Not -Throw + } + + It 'Accepts valid Intent extras array with --ei flag' { + { Test-IntentExtrasArray -Arguments @('--ei', 'intKey', '42') } | Should -Not -Throw + } + + It 'Accepts valid Intent extras array with --el flag' { + { Test-IntentExtrasArray -Arguments @('--el', 'longKey', '1234567890') } | Should -Not -Throw + } + + It 'Accepts multiple Intent extras in array' { + { Test-IntentExtrasArray -Arguments @('-e', 'key1', 'value1', '-ez', 'key2', 'false', '-ei', 'key3', '100') } | Should -Not -Throw + } + + It 'Accepts empty array' { + { Test-IntentExtrasArray -Arguments @() } | Should -Not -Throw } It 'Accepts null' { - { Test-IntentExtrasFormat -Arguments $null } | Should -Not -Throw + { Test-IntentExtrasArray -Arguments $null } | Should -Not -Throw + } + + It 'Accepts keys and values with spaces' { + { Test-IntentExtrasArray -Arguments @('-e', 'key with spaces', 'value with spaces') } | Should -Not -Throw + } + + It 'Accepts unknown arguments without throwing' { + { Test-IntentExtrasArray -Arguments @('key', 'value') } | Should -Not -Throw + } + + It 'Accepts unknown flags by ignoring validation' { + { Test-IntentExtrasArray -Arguments @('--new-flag', 'key', 'value') } | Should -Not -Throw + } + + It 'Throws on incomplete known flag without key and value' { + { Test-IntentExtrasArray -Arguments @('-e') } | Should -Throw '*must be followed by key and value*' + } + + It 'Throws on known flag with only key, missing value' { + { Test-IntentExtrasArray -Arguments @('-e', 'key') } | Should -Throw '*must be followed by key and value*' + } + + It 'Throws on boolean flag with invalid value' { + { Test-IntentExtrasArray -Arguments @('-ez', 'boolKey', 'invalid') } | Should -Throw '*requires ''true'' or ''false'' value*' } - It 'Accepts whitespace-only string' { - { Test-IntentExtrasFormat -Arguments ' ' } | Should -Not -Throw + It 'Throws on double-dash boolean flag with invalid value' { + { Test-IntentExtrasArray -Arguments @('--ez', 'boolKey', 'invalid') } | Should -Throw '*requires ''true'' or ''false'' value*' } - It 'Throws on invalid format without flag' { - { Test-IntentExtrasFormat -Arguments 'key value' } | Should -Throw '*Invalid Intent extras format*' + It 'Accepts mixed known and unknown flags' { + { Test-IntentExtrasArray -Arguments @('-e', 'key1', 'value1', '--new-flag', 'key2', 'value2', '-ez', 'bool', 'true') } | Should -Not -Throw } - It 'Throws on invalid format with wrong prefix' { - { Test-IntentExtrasFormat -Arguments '--key value' } | Should -Throw '*Invalid Intent extras format*' + It 'Accepts single-token arguments like --grant-read-uri-permission' { + { Test-IntentExtrasArray -Arguments @('--grant-read-uri-permission') } | Should -Not -Throw } - It 'Throws on text without proper flag format' { - { Test-IntentExtrasFormat -Arguments 'some random text' } | Should -Throw '*Invalid Intent extras format*' + It 'Accepts mixed single tokens and unknown arguments' { + { Test-IntentExtrasArray -Arguments @('not-a-flag', 'value', '--activity-clear-task') } | Should -Not -Throw } } diff --git a/app-runner/Tests/Device.Tests.ps1 b/app-runner/Tests/Device.Tests.ps1 index 3d2df9a..e4974ce 100644 --- a/app-runner/Tests/Device.Tests.ps1 +++ b/app-runner/Tests/Device.Tests.ps1 @@ -237,7 +237,7 @@ Describe '' -Tag 'RequiresDevice' -ForEach $TestTargets { } It 'Invoke-DeviceApp executes application' -Skip:$shouldSkip { - $result = Invoke-DeviceApp -ExecutablePath $testApp -Arguments '' + $result = Invoke-DeviceApp -ExecutablePath $testApp -Arguments @() $result | Should -Not -BeNullOrEmpty $result | Should -BeOfType [hashtable] $result.Keys | Should -Contain 'Output' @@ -246,7 +246,7 @@ Describe '' -Tag 'RequiresDevice' -ForEach $TestTargets { } It 'Invoke-DeviceApp with arguments works' -Skip:$shouldSkip { - $result = Invoke-DeviceApp -ExecutablePath $testApp -Arguments 'error' + $result = Invoke-DeviceApp -ExecutablePath $testApp -Arguments @('error') $result | Should -Not -BeNullOrEmpty $result.Output | Should -Contain 'Sample: ERROR' if ($Platform -ne 'Switch') { diff --git a/app-runner/Tests/Fixtures/iOS/.gitignore b/app-runner/Tests/Fixtures/iOS/.gitignore new file mode 100644 index 0000000..8f51471 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/.gitignore @@ -0,0 +1,10 @@ +# User-specific Xcode files +xcuserdata/ + +# Build artifacts from Build-TestApp.ps1 +*.xcarchive +export/ + +# Temporary build files +build/ +DerivedData/ diff --git a/app-runner/Tests/Fixtures/iOS/TestApp.ipa b/app-runner/Tests/Fixtures/iOS/TestApp.ipa new file mode 100644 index 0000000..7fa37fd Binary files /dev/null and b/app-runner/Tests/Fixtures/iOS/TestApp.ipa differ diff --git a/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/project.pbxproj b/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..01aa7ba --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/project.pbxproj @@ -0,0 +1,343 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + F3C3616B2F103A2200ADF8AE /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F3C3616D2F103A2200ADF8AE /* TestApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TestApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F3C361682F103A2200ADF8AE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F3C361622F103A2200ADF8AE = { + isa = PBXGroup; + children = ( + F3C3616D2F103A2200ADF8AE /* TestApp */, + F3C3616C2F103A2200ADF8AE /* Products */, + ); + sourceTree = ""; + }; + F3C3616C2F103A2200ADF8AE /* Products */ = { + isa = PBXGroup; + children = ( + F3C3616B2F103A2200ADF8AE /* TestApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F3C3616A2F103A2200ADF8AE /* TestApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = F3C361762F103A2200ADF8AE /* Build configuration list for PBXNativeTarget "TestApp" */; + buildPhases = ( + F3C361672F103A2200ADF8AE /* Sources */, + F3C361682F103A2200ADF8AE /* Frameworks */, + F3C361692F103A2200ADF8AE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F3C3616D2F103A2200ADF8AE /* TestApp */, + ); + name = TestApp; + packageProductDependencies = ( + ); + productName = TestApp; + productReference = F3C3616B2F103A2200ADF8AE /* TestApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F3C361632F103A2200ADF8AE /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + F3C3616A2F103A2200ADF8AE = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = F3C361662F103A2200ADF8AE /* Build configuration list for PBXProject "TestApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F3C361622F103A2200ADF8AE; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = F3C3616C2F103A2200ADF8AE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F3C3616A2F103A2200ADF8AE /* TestApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F3C361692F103A2200ADF8AE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F3C361672F103A2200ADF8AE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + F3C361742F103A2200ADF8AE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F3C361752F103A2200ADF8AE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F3C361772F103A2200ADF8AE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = U7L2ZUS4RB; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.sentry.apprunner.TestApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "AppRunner TestApp"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F3C361782F103A2200ADF8AE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = U7L2ZUS4RB; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.sentry.apprunner.TestApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "AppRunner TestApp"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F3C361662F103A2200ADF8AE /* Build configuration list for PBXProject "TestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F3C361742F103A2200ADF8AE /* Debug */, + F3C361752F103A2200ADF8AE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F3C361762F103A2200ADF8AE /* Build configuration list for PBXNativeTarget "TestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F3C361772F103A2200ADF8AE /* Debug */, + F3C361782F103A2200ADF8AE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F3C361632F103A2200ADF8AE /* Project object */; +} diff --git a/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/xcuserdata/limbonaut.xcuserdatad/xcschemes/xcschememanagement.plist b/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/xcuserdata/limbonaut.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..0203ee0 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp.xcodeproj/xcuserdata/limbonaut.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + TestApp.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json b/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/Contents.json b/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/Build-TestApp.ps1 b/app-runner/Tests/Fixtures/iOS/TestApp/Build-TestApp.ps1 new file mode 100755 index 0000000..f88f490 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/Build-TestApp.ps1 @@ -0,0 +1,96 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Builds the TestApp debug IPA. + +.DESCRIPTION + This script builds the debug version of the TestApp iOS application. + The IPA is automatically copied to the parent directory (Tests/Fixtures/iOS) after a successful build. + +.EXAMPLE + ./Build-TestApp.ps1 +#> + +$ErrorActionPreference = 'Stop' + +$ProjectRoot = $PSScriptRoot +$ProjectFile = Join-Path (Split-Path $ProjectRoot -Parent) "TestApp.xcodeproj" + +Write-Information "Building SentryTestApp debug IPA..." -InformationAction Continue + +try { + Push-Location (Split-Path $ProjectFile -Parent) + + # Clean previous builds + & xcodebuild -project TestApp.xcodeproj -scheme TestApp clean -quiet + + if ($LASTEXITCODE -ne 0) { + throw "Xcode clean failed with exit code $LASTEXITCODE" + } + + # Archive for iOS devices + $archivePath = Join-Path $ProjectRoot "TestApp.xcarchive" + if (Test-Path $archivePath) { + Remove-Item $archivePath -Recurse -Force + } + + & xcodebuild -project TestApp.xcodeproj ` + -scheme TestApp ` + -destination 'generic/platform=iOS' ` + -configuration Release ` + -archivePath $archivePath ` + archive ` + -quiet + + if ($LASTEXITCODE -ne 0) { + throw "Xcode archive failed with exit code $LASTEXITCODE" + } + + # Export IPA + $exportPath = Join-Path $ProjectRoot "export" + $exportOptionsFile = Join-Path $ProjectRoot "ExportOptions.plist" + + if (Test-Path $exportPath) { + Remove-Item $exportPath -Recurse -Force + } + + & xcodebuild -exportArchive ` + -archivePath $archivePath ` + -exportPath $exportPath ` + -exportOptionsPlist $exportOptionsFile ` + -quiet + + if ($LASTEXITCODE -ne 0) { + throw "IPA export failed with exit code $LASTEXITCODE" + } + + # Find and copy the IPA + $exportedIPA = Get-ChildItem -Path $exportPath -Filter "*.ipa" -Recurse | Select-Object -First 1 + + if (-not $exportedIPA) { + throw "No IPA file found in export directory" + } + + $targetIPA = Join-Path (Split-Path $ProjectRoot -Parent) "TestApp.ipa" + Copy-Item -Path $exportedIPA.FullName -Destination $targetIPA -Force + + Write-Information "✓ IPA built successfully: $targetIPA" -InformationAction Continue + $ipaInfo = Get-Item $targetIPA + Write-Information " Size: $([math]::Round($ipaInfo.Length / 1MB, 2)) MB" -InformationAction Continue + + # Cleanup + if (Test-Path $archivePath) { + Remove-Item $archivePath -Recurse -Force + } + if (Test-Path $exportPath) { + Remove-Item $exportPath -Recurse -Force + } + +} +catch { + Write-Error "Failed to build IPA: $_" + exit 1 +} +finally { + Pop-Location +} diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/ContentView.swift b/app-runner/Tests/Fixtures/iOS/TestApp/ContentView.swift new file mode 100644 index 0000000..b692f68 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/ContentView.swift @@ -0,0 +1,57 @@ +// +// ContentView.swift +// TestApp +// +// Created by Serhii Snitsaruk on 08/01/2026. +// + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + VStack(spacing: 20) { + Text("SentryTestApp") + .font(.title) + .fontWeight(.bold) + + Text("Auto-closing in 3 seconds...") + .font(.headline) + .foregroundColor(.orange) + + if appState.launchArguments.isEmpty { + Text("No launch arguments received") + .foregroundColor(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Launch Arguments (\(appState.launchArguments.count)):") + .font(.headline) + + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(appState.launchArguments.enumerated()), id: \.offset) { + index, arg in + Text("\(index + 1). \(arg)") + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.gray.opacity(0.1)) + .cornerRadius(4) + } + } + } + .frame(maxHeight: 200) + } + } + + Spacer() + } + .padding() + } +} + +#Preview { + ContentView() + .environmentObject(AppState()) +} diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/ExportOptions.plist b/app-runner/Tests/Fixtures/iOS/TestApp/ExportOptions.plist new file mode 100644 index 0000000..90f89eb --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/ExportOptions.plist @@ -0,0 +1,21 @@ + + + + + method + release-testing + provisioningProfiles + + io.sentry.apprunner.TestApp + da0b389f-2fb4-4241-8387-98b515f84c5a + + signingCertificate + Apple Distribution + signingStyle + manual + stripSwiftSymbols + + thinning + <none> + + diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/README.md b/app-runner/Tests/Fixtures/iOS/TestApp/README.md new file mode 100644 index 0000000..e6852b4 --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/README.md @@ -0,0 +1,196 @@ +# TestApp + +A minimal iOS test application used for automated testing of the SentryAppRunner device management functionality on real iOS devices via SauceLabs. + +## Overview + +This is a simple iOS application that: +- Accepts parameters via launch arguments +- Logs all received parameters to syslog with subsystem `io.sentry.apprunner.TestApp` and category `SentryTestApp` +- Automatically closes after 3 seconds +- Is built for real iOS device testing on SauceLabs + +## Bundle Information + +- **Bundle ID**: `io.sentry.apprunner.TestApp` +- **Display Name**: `TestApp` +- **Target Platform**: iOS 26.0+ (real devices) + +## Building + +### Prerequisites + +- **Xcode**: Version 16.0 or higher +- **macOS**: Version 15.0 or higher +- **PowerShell**: pwsh 7.0 or higher +- **Apple Developer Account**: Required for real device builds +- **Code Signing**: Valid provisioning profile and certificate + +### Code Signing Setup + +Before building, you must configure code signing for real device deployment: + +1. **Open Xcode Project**: + ```bash + open ../TestApp.xcodeproj + ``` + +2. **Configure Signing**: + - Select the `TestApp` target + - Go to "Signing & Capabilities" tab + - Select your Apple Developer team + - Ensure bundle ID `io.sentry.apprunner.TestApp` is configured + - Verify a valid provisioning profile is selected + +3. **Verify Configuration**: + - Build should succeed without code signing errors + - Certificate should be installed in Keychain Access + +### Build the IPA + +Using PowerShell (recommended): +```powershell +./Build-TestApp.ps1 +``` + +The script will: +1. Clean previous builds +2. Archive the project for iOS devices +3. Export IPA for ad-hoc distribution +4. Copy IPA to `../TestApp.ipa` (in Tests/Fixtures/iOS directory) + +### Manual Build (Alternative) + +If the PowerShell script fails, you can build manually: + +```bash +# Clean +xcodebuild -project ../TestApp.xcodeproj -scheme TestApp clean + +# Archive +xcodebuild -project ../TestApp.xcodeproj \ + -scheme TestApp \ + -destination 'generic/platform=iOS' \ + -configuration Debug \ + -archivePath TestApp.xcarchive \ + archive + +# Export (requires ExportOptions.plist) +xcodebuild -exportArchive \ + -archivePath TestApp.xcarchive \ + -exportPath export \ + -exportOptionsPlist ExportOptions.plist +``` + +## Usage + +### SauceLabs Testing + +The built IPA is designed for SauceLabs real device testing: + +```powershell +# Install the IPA +Install-DeviceApp -Path "Tests/Fixtures/iOS/TestApp.ipa" + +# Launch with arguments +Invoke-DeviceApp -ExecutablePath "io.sentry.apprunner.TestApp" -Arguments @("--test-mode", "sentry") +``` + +### Launch Arguments Support + +The app processes command-line arguments passed during launch: + +```bash +# Launch with string parameters +xcrun simctl launch booted io.sentry.apprunner.TestApp --test-mode sentry --param2 value2 + +# Launch with multiple arguments +xcrun simctl launch booted io.sentry.apprunner.TestApp --env development --debug true --count 42 +``` + +### Viewing Logs + +The app logs to the iOS system log with structured logging: + +```bash +# View app logs (on device/simulator) +xcrun simctl spawn booted log show --predicate 'subsystem == "io.sentry.apprunner.TestApp"' --info --last 5m + +# Filter for SentryTestApp category +xcrun simctl spawn booted log show --predicate 'category == "SentryTestApp"' --info --last 5m +``` + +## Auto-Close Behavior + +The app automatically terminates 3 seconds after launch. This is controlled by the `DispatchQueue.main.asyncAfter` timer in `TestAppApp.swift`. + +## Log Output Format + +The app logs the following information to iOS syslog: +``` +[io.sentry.apprunner.TestApp:SentryTestApp] Application started +[io.sentry.apprunner.TestApp:SentryTestApp] Received 2 launch argument(s): +[io.sentry.apprunner.TestApp:SentryTestApp] --test-mode = sentry +[io.sentry.apprunner.TestApp:SentryTestApp] --param2 = value2 +[io.sentry.apprunner.TestApp:SentryTestApp] Auto-closing application +[io.sentry.apprunner.TestApp:SentryTestApp] Application terminated +``` + +## Testing + +This IPA is used by the PowerShell tests in `../../SauceLabs.Tests.ps1` to verify: +- Device connection management on real iOS devices +- IPA installation via SauceLabs +- Application execution with launch arguments +- iOS syslog retrieval and parsing +- App lifecycle management + +## Troubleshooting + +### Code Signing Issues + +If you encounter code signing errors: + +1. **Check Apple Developer Account**: + - Ensure your account has device provisioning enabled + - Verify bundle ID `io.sentry.apprunner.TestApp` is registered + +2. **Verify Certificates**: + - Check Keychain Access for valid iOS Distribution/Development certificates + - Ensure certificates are not expired + +3. **Provisioning Profiles**: + - Download latest provisioning profiles from Apple Developer portal + - Ensure profile includes target devices for testing + +4. **Xcode Configuration**: + - Try "Automatically manage signing" first + - If that fails, manually select appropriate provisioning profile + +### Build Failures + +Common build issues and solutions: + +- **"No such file or directory"**: Ensure you're running the script from the TestApp directory +- **"Archive failed"**: Check code signing configuration in Xcode +- **"Export failed"**: Verify ExportOptions.plist is valid and provisioning allows ad-hoc distribution + +### SauceLabs Integration + +For SauceLabs testing issues: + +- Ensure IPA is built for real devices (not simulator) +- Verify bundle ID matches what's expected in test scripts +- Check that launch arguments are properly formatted for iOS +- Confirm syslog output is accessible via SauceLabs Appium API + +## Comparison with Android TestApp + +| Feature | Android TestApp | iOS TestApp | +|---------|----------------|-------------| +| Package/Bundle | `com.sentry.test.minimal` | `io.sentry.apprunner.TestApp` | +| Launch Args | Intent extras (`-e key value`) | Command args (`--key value`) | +| Logging | `Log.i("SentryTestApp", msg)` | `os_log(..., category: "SentryTestApp")` | +| Auto-close | `Handler.postDelayed()` → `finish()` | `DispatchQueue.asyncAfter()` → `exit()` | +| Build Output | `.apk` | `.ipa` | +| Target | Android devices/emulators | Real iOS devices | \ No newline at end of file diff --git a/app-runner/Tests/Fixtures/iOS/TestApp/TestAppApp.swift b/app-runner/Tests/Fixtures/iOS/TestApp/TestAppApp.swift new file mode 100644 index 0000000..c91356e --- /dev/null +++ b/app-runner/Tests/Fixtures/iOS/TestApp/TestAppApp.swift @@ -0,0 +1,85 @@ +// +// TestAppApp.swift +// TestApp +// +// Created by Serhii Snitsaruk on 08/01/2026. +// + +import Combine +import Foundation +import SwiftUI +import os.log + +@main +struct TestAppApp: App { + @StateObject private var appState = AppState() + + // Shared logger for consistent logging throughout the app + private static let logger = OSLog( + subsystem: "io.sentry.apprunner.TestApp", category: "SentryTestApp") + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + } + + init() { + processLaunchArguments() + startAutoCloseTimer() + } + + private func processLaunchArguments() { + let arguments = CommandLine.arguments + os_log("Application started", log: Self.logger, type: .info) + + // Skip the first argument (app name) + let launchArgs = Array(arguments.dropFirst()) + + if launchArgs.isEmpty { + os_log("No launch arguments received", log: Self.logger, type: .info) + } else { + os_log( + "Received %d launch argument(s):", log: Self.logger, type: .info, launchArgs.count) + + // Process arguments in pairs (--key value) or individually + var i = 0 + while i < launchArgs.count { + let arg = launchArgs[i] + + if arg.starts(with: "--") && i + 1 < launchArgs.count { + // Key-value pair + let key = arg + let value = launchArgs[i + 1] + os_log(" %@ = %@", log: Self.logger, type: .info, key, value) + i += 2 + } else { + // Single argument + os_log(" %@", log: Self.logger, type: .info, arg) + i += 1 + } + } + } + } + + private func startAutoCloseTimer() { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + os_log("Auto-closing application", log: Self.logger, type: .info) + self.terminateApp() + } + } + + private func terminateApp() { + os_log("Application terminated", log: Self.logger, type: .info) + exit(0) + } +} + +class AppState: ObservableObject { + @Published var launchArguments: [String] = [] + + init() { + launchArguments = Array(CommandLine.arguments.dropFirst()) + } +} diff --git a/app-runner/Tests/SauceLabs.Tests.ps1 b/app-runner/Tests/SauceLabs.Tests.ps1 index aa1087a..7181b62 100644 --- a/app-runner/Tests/SauceLabs.Tests.ps1 +++ b/app-runner/Tests/SauceLabs.Tests.ps1 @@ -8,7 +8,7 @@ BeforeDiscovery { [string]$Target, [string]$FixturePath, [string]$ExePath, - [string]$Arguments + [string[]]$Arguments ) $TargetName = "$Platform-$Target" @@ -38,7 +38,7 @@ BeforeDiscovery { -Target 'Samsung_Galaxy_S23_15_real_sjc1' ` -FixturePath $androidFixture ` -ExePath 'com.sentry.test.minimal/.MainActivity' ` - -Arguments '-e sentry test' + -Arguments @('-e', 'sentry', 'test') } else { $message = "Android fixture not found at $androidFixture" if ($isCI) { @@ -48,23 +48,23 @@ BeforeDiscovery { } } - # Check iOS Fixture (not supported yet) - # $iosFixture = Join-Path $PSScriptRoot 'Fixtures' 'iOS' 'TestApp.ipa' - # if (Test-Path $iosFixture) { - # $TestTargets += Get-TestTarget ` - # -Platform 'iOSSauceLabs' ` - # -Target 'iPhone 13 Pro' ` - # -FixturePath $iosFixture ` - # -ExePath 'com.saucelabs.mydemoapp.ios' ` - # -Arguments '--test-arg value' - # } else { - # $message = "iOS fixture not found at $iosFixture" - # if ($isCI) { - # throw "$message. This is required in CI." - # } else { - # Write-Warning "$message. iOSSauceLabs tests will be skipped." - # } - # } + # Check iOS Fixture + $iosFixture = Join-Path $PSScriptRoot 'Fixtures' 'iOS' 'TestApp.ipa' + if (Test-Path $iosFixture) { + $TestTargets += Get-TestTarget ` + -Platform 'iOSSauceLabs' ` + -Target 'iPhone_15_Pro_18_real_sjc1' ` + -FixturePath $iosFixture ` + -ExePath 'io.sentry.apprunner.TestApp' ` + -Arguments @('--test-mode', 'sentry') + } else { + $message = "iOS fixture not found at $iosFixture" + if ($isCI) { + throw "$message. This is required in CI." + } else { + Write-Warning "$message. iOSSauceLabs tests will be skipped." + } + } } else { $message = "SauceLabs credentials not found. Required environment variables: SAUCE_USERNAME, SAUCE_ACCESS_KEY, SAUCE_REGION" diff --git a/app-runner/Tests/SessionManagement.Tests.ps1 b/app-runner/Tests/SessionManagement.Tests.ps1 index de62f3e..9ce6c85 100644 --- a/app-runner/Tests/SessionManagement.Tests.ps1 +++ b/app-runner/Tests/SessionManagement.Tests.ps1 @@ -139,7 +139,7 @@ Context 'Invoke-DeviceApp' { It 'Should work with no arguments' { Connect-Device -Platform 'Mock' $result = Invoke-DeviceApp -ExecutablePath 'MyGame.exe' - $result.Arguments | Should -Be '' + $result.Arguments | Should -Be @() } }