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..e3e21ad 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/SauceLabs.Tests.ps1 b/app-runner/Tests/SauceLabs.Tests.ps1 index aa1087a..2929d9a 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) { @@ -56,7 +56,7 @@ BeforeDiscovery { # -Target 'iPhone 13 Pro' ` # -FixturePath $iosFixture ` # -ExePath 'com.saucelabs.mydemoapp.ios' ` - # -Arguments '--test-arg value' + # -Arguments @('--test-arg', 'value') # } else { # $message = "iOS fixture not found at $iosFixture" # if ($isCI) { 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 @() } }