diff --git a/app-runner/Private/AndroidHelpers.ps1 b/app-runner/Private/AndroidHelpers.ps1 index c67c67b..0ae8dac 100644 --- a/app-runner/Private/AndroidHelpers.ps1 +++ b/app-runner/Private/AndroidHelpers.ps1 @@ -39,45 +39,69 @@ 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 common Intent extras flags that we understand + # Ignore unknown flags to avoid breaking when Android adds new ones + $knownFlags = @('-e', '-es', '--es', '-ez', '--ez', '-ei', '--ei', '-el', '--el') + + $i = 0 + while ($i -lt $Arguments.Count) { + $currentArg = $Arguments[$i] + + # If this looks like a flag we know, validate its structure + if ($knownFlags -contains $currentArg) { + # Ensure we have at least 2 more arguments (key and value) + 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 processed + $i += 3 + } else { + # Unknown flag - skip it + # This allows other Android flags to work without breaking our validation + $i += 1 + } } return $true @@ -132,7 +156,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..45cb7db 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -195,7 +195,8 @@ 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" # Parse ExecutablePath: "package.name/activity.name" @@ -205,8 +206,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 +222,13 @@ class AdbProvider : DeviceProvider { # Launch activity Write-Host "Launching: $ExecutablePath" -ForegroundColor Cyan - if ($Arguments) { - Write-Host " Arguments: $Arguments" -ForegroundColor Cyan + + $argumentsString = $this.ConvertArgumentsToString($Arguments) + 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..1046d6f 100644 --- a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 @@ -120,6 +120,33 @@ class DeviceProvider { return [BuiltCommand]::new($command, $processingCommand) } + # Helper method to convert string array arguments to properly formatted string + # Joins with spaces and quotes arguments containing spaces or special characters + # Preserves arguments that are already quoted with matching quotes + [string] ConvertArgumentsToString([string[]]$Arguments) { + if (-not $Arguments -or $Arguments.Count -eq 0) { + return "" + } + + $formattedArgs = @() + + foreach ($arg in $Arguments) { + # If argument is already quoted with matching quotes, preserve it as-is + if ($arg.Length -ge 2 -and (($arg[0] -eq '"' -and $arg[-1] -eq '"') -or ($arg[0] -eq "'" -and $arg[-1] -eq "'"))) { + # Preserve original formatting for already-quoted arguments because + # the argument was intentionally quoted by the caller for a specific reason + $formattedArgs += $arg + } elseif ($arg -match '[\s"''&|<>^]' -or $arg -eq '--') { + # Escape single quotes with shell-compatible escaping + $escapedArg = $arg -replace "'", "'\''" + $formattedArgs += "'" + $escapedArg + "'" + } else { + $formattedArgs += $arg + } + } + + return $formattedArgs -join ' ' + } [void] LogNotImplemented([string]$operation) { Write-Warning "$($this.Platform) $operation not yet implemented" @@ -372,14 +399,15 @@ 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)) + $argumentsString = $this.ConvertArgumentsToString($Arguments) + $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..8ca086b 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 = $this.ConvertArgumentsToString($Arguments) + 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,7 +365,6 @@ 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 = @{ @@ -369,10 +372,17 @@ class SauceLabsProvider : DeviceProvider { } 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 + # Parse arguments string into array, handling quoted strings and standalone "--" separator + $argumentsArray = @() + + # Split the arguments string by spaces, but handle quoted strings (both single and double quotes) + $argTokens = [regex]::Matches($Arguments, '(\"[^\"]*\"|''[^'']*''|\S+)') | ForEach-Object { $_.Value.Trim('"', "'") } + + foreach ($token in $argTokens) { + $argumentsArray += $token + } + + $launchBody['arguments'] = $argumentsArray } try { @@ -398,10 +408,12 @@ class SauceLabsProvider : DeviceProvider { while ((Get-Date) - $startTime -lt [TimeSpan]::FromSeconds($timeoutSeconds)) { # Query app state using Appium's mobile: queryAppState + # Use correct parameter name based on platform: appId for Android, bundleId for iOS + $appParameter = if ($this.MobilePlatform -eq 'Android') { 'appId' } else { 'bundleId' } $stateBody = @{ script = 'mobile: queryAppState' args = @( - @{ appId = $this.CurrentPackageName } # Use stored package/bundle ID + @{ $appParameter = $this.CurrentPackageName } # Use stored package/bundle ID ) } @@ -431,38 +443,54 @@ 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 + } + } } - # Return result matching app-runner pattern return @{ Platform = $this.Platform ExecutablePath = $ExecutablePath @@ -587,8 +615,176 @@ 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)" + $scriptBody = @{ script = 'mobile: listApps'; args = @() } + + $response = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $scriptBody, $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 = $targetApp.CFBundleDisplayName -or $targetApp.CFBundleName -or "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-Error $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..10312ae 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 = $this.ConvertArgumentsToString($Arguments) + $builtCommand = $this.BuildCommand('launch-app', @($PackageIdentity, $argumentsString)) return $this.InvokeApplicationCommand($builtCommand, $PackageIdentity, $Arguments) } @@ -251,7 +252,8 @@ 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) { + # LogFilePath parameter ignored in this implementation 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 +263,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 = $this.ConvertArgumentsToString($Arguments) + $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..0a1ff71 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -11,10 +11,19 @@ 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. + + .PARAMETER LogFilePath + Optional path to a log file on the device to retrieve instead of using system logs (syslog/logcat). + 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" + Invoke-DeviceApp -ExecutablePath "com.example.app" -LogFilePath "@com.example.app:documents/logs/app.log" #> [CmdletBinding()] param( @@ -23,7 +32,10 @@ function Invoke-DeviceApp { [string]$ExecutablePath, [Parameter(Mandatory = $false)] - [string]$Arguments = "" + [string[]]$Arguments = @(), + + [Parameter(Mandatory = $false)] + [string]$LogFilePath = $null ) Assert-DeviceSession @@ -35,7 +47,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..15cac36 100644 --- a/app-runner/Tests/AndroidHelpers.Tests.ps1 +++ b/app-runner/Tests/AndroidHelpers.Tests.ps1 @@ -59,55 +59,7 @@ 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 - } - - It 'Accepts valid Intent extras with -es flag' { - { Test-IntentExtrasFormat -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 with -ei flag' { - { Test-IntentExtrasFormat -Arguments '-ei intKey 42' } | Should -Not -Throw - } - - It 'Accepts valid Intent extras with -el flag' { - { Test-IntentExtrasFormat -Arguments '-el longKey 1234567890' } | 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 empty string' { - { Test-IntentExtrasFormat -Arguments '' } | Should -Not -Throw - } - - It 'Accepts null' { - { Test-IntentExtrasFormat -Arguments $null } | Should -Not -Throw - } - - It 'Accepts whitespace-only string' { - { Test-IntentExtrasFormat -Arguments ' ' } | Should -Not -Throw - } - - It 'Throws on invalid format without flag' { - { Test-IntentExtrasFormat -Arguments 'key value' } | Should -Throw '*Invalid Intent extras format*' - } - - It 'Throws on invalid format with wrong prefix' { - { Test-IntentExtrasFormat -Arguments '--key value' } | Should -Throw '*Invalid Intent extras format*' - } - It 'Throws on text without proper flag format' { - { Test-IntentExtrasFormat -Arguments 'some random text' } | Should -Throw '*Invalid Intent extras format*' - } - } Context 'Get-ApkPackageName error handling' { It 'Throws when APK file does not exist' { @@ -219,3 +171,99 @@ Describe 'AndroidHelpers' -Tag 'Unit', 'Android' { } } } + +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 array with -es flag' { + { Test-IntentExtrasArray -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 array with -ez flag and true' { + { Test-IntentExtrasArray -Arguments @('-ez', 'boolKey', 'true') } | 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 --ez flag and true' { + { Test-IntentExtrasArray -Arguments @('--ez', 'boolKey', 'true') } | 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-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 'Throws on invalid format without flag' { + { Test-IntentExtrasArray -Arguments @('key', 'value') } | Should -Throw '*Invalid Intent extras format*' + } + + 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 'Throws on double-dash boolean flag with invalid value' { + { Test-IntentExtrasArray -Arguments @('--ez', 'boolKey', 'invalid') } | Should -Throw '*requires ''true'' or ''false'' value*' + } + + 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 non-flag arguments' { + { Test-IntentExtrasArray -Arguments @('not-a-flag', 'value') } | Should -Throw '*Expected Intent extras flag*' + } +} + + diff --git a/app-runner/Tests/Desktop.Tests.ps1 b/app-runner/Tests/Desktop.Tests.ps1 index 9e420ae..ad77af1 100644 --- a/app-runner/Tests/Desktop.Tests.ps1 +++ b/app-runner/Tests/Desktop.Tests.ps1 @@ -156,7 +156,7 @@ Describe '' -Tag 'Desktop', 'Integration' -ForEach $TestTargets { } It 'Invoke-DeviceApp executes pwsh successfully' { - $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments '-Command "Write-Host ''test-output''"' + $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments @('-Command', "Write-Host 'test-output'") $result | Should -Not -BeNullOrEmpty $result | Should -BeOfType [hashtable] @@ -169,14 +169,14 @@ Describe '' -Tag 'Desktop', 'Integration' -ForEach $TestTargets { } It 'Invoke-DeviceApp captures non-zero exit codes' { - $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments '-Command "exit 42"' + $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments @('-Command', 'exit 42') $result | Should -Not -BeNullOrEmpty $result.ExitCode | Should -Be 42 } It 'Invoke-DeviceApp captures multi-line output' { - $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments '-Command "Write-Host ''line1''; Write-Host ''line2''; Write-Host ''line3''"' + $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments @('-Command', "Write-Host 'line1'; Write-Host 'line2'; Write-Host 'line3'") $result.Output | Should -Contain 'line1' $result.Output | Should -Contain 'line2' @@ -184,7 +184,7 @@ Describe '' -Tag 'Desktop', 'Integration' -ForEach $TestTargets { } It 'Invoke-DeviceApp includes timing information' { - $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments '-Command "Start-Sleep -Milliseconds 100"' + $result = Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments @('-Command', 'Start-Sleep -Milliseconds 100') $result.Keys | Should -Contain 'StartedAt' $result.Keys | Should -Contain 'FinishedAt' @@ -334,7 +334,7 @@ Describe '' -Tag 'Desktop', 'Integration' -ForEach $TestTargets { try { Disconnect-Device } catch { } { Get-DeviceStatus } | Should -Throw '*No active device session*' - { Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments '' } | Should -Throw '*No active device session*' + { Invoke-DeviceApp -ExecutablePath 'pwsh' -Arguments @() } | Should -Throw '*No active device session*' } } diff --git a/app-runner/Tests/Device.Tests.ps1 b/app-runner/Tests/Device.Tests.ps1 index 3d2df9a..e7333b1 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') { @@ -559,3 +559,74 @@ Describe '' -Tag 'RequiresDevice' -ForEach $TestTargets { } } } + +Context 'ConvertArgumentsToString (via Mock Provider)' { + BeforeEach { + Connect-Device -Platform 'Mock' + $script:provider = (Get-DeviceSession).Provider + } + + AfterEach { + Disconnect-Device + } + + It 'Handles empty array' { + $result = $provider.ConvertArgumentsToString(@()) + $result | Should -Be "" + } + + It 'Handles null array' { + $result = $provider.ConvertArgumentsToString($null) + $result | Should -Be "" + } + + It 'Handles simple arguments without spaces' { + $result = $provider.ConvertArgumentsToString(@('--debug', '--verbose')) + $result | Should -Be "--debug --verbose" + } + + It 'Handles arguments with spaces using single quotes' { + $result = $provider.ConvertArgumentsToString(@('--config', 'my config.txt')) + $result | Should -Be "--config 'my config.txt'" + } + + It 'Handles arguments with single quotes properly' { + $result = $provider.ConvertArgumentsToString(@('--message', "It's working")) + $result | Should -Be "--message 'It'\''s working'" + } + + It 'Handles arguments with double quotes by escaping them' { + $result = $provider.ConvertArgumentsToString(@('--text', 'He said "hello"')) + $result | Should -Be '--text ''He said "hello"''' + } + + It 'Handles arguments with special characters' { + $result = $provider.ConvertArgumentsToString(@('--regex', '[a-z]+')) + $result | Should -Be '--regex [a-z]+' + } + + It 'Handles mixed argument types' { + $result = $provider.ConvertArgumentsToString(@('--simple', '--with spaces', "it's", 'normal')) + $result | Should -Be "--simple '--with spaces' 'it'\''s' normal" + } + + It 'Handles pipe characters' { + $result = $provider.ConvertArgumentsToString(@('--command', 'echo hello | grep hi')) + $result | Should -Be "--command 'echo hello | grep hi'" + } + + It 'Handles ampersand characters' { + $result = $provider.ConvertArgumentsToString(@('--url', 'http://example.com?a=1&b=2')) + $result | Should -Be "--url 'http://example.com?a=1&b=2'" + } + + It 'Handles empty string arguments' { + $result = $provider.ConvertArgumentsToString(@('--flag', '', 'value')) + $result | Should -Be '--flag value' + } + + It 'Handles arguments with redirections' { + $result = $provider.ConvertArgumentsToString(@('--output', 'file > /dev/null')) + $result | Should -Be "--output 'file > /dev/null'" + } +} diff --git a/app-runner/Tests/SauceLabs.Tests.ps1 b/app-runner/Tests/SauceLabs.Tests.ps1 index aa1087a..3cb07ef 100644 --- a/app-runner/Tests/SauceLabs.Tests.ps1 +++ b/app-runner/Tests/SauceLabs.Tests.ps1 @@ -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..0933615 100644 --- a/app-runner/Tests/SessionManagement.Tests.ps1 +++ b/app-runner/Tests/SessionManagement.Tests.ps1 @@ -129,17 +129,17 @@ Context 'Invoke-DeviceApp' { It 'Should accept executable path and arguments' { Connect-Device -Platform 'Mock' - $result = Invoke-DeviceApp -ExecutablePath 'MyGame.exe' -Arguments '--debug' + $result = Invoke-DeviceApp -ExecutablePath 'MyGame.exe' -Arguments @('--debug') $result | Should -Not -Be $null $result.ExecutablePath | Should -Be 'MyGame.exe' - $result.Arguments | Should -Be '--debug' + $result.Arguments | Should -Be @('--debug') $result.Platform | Should -Be 'Mock' } It 'Should work with no arguments' { Connect-Device -Platform 'Mock' $result = Invoke-DeviceApp -ExecutablePath 'MyGame.exe' - $result.Arguments | Should -Be '' + $result.Arguments | Should -Be @() } } diff --git a/app-runner/examples/SessionBasedWorkflow.ps1 b/app-runner/examples/SessionBasedWorkflow.ps1 index 5332dab..a89fe85 100644 --- a/app-runner/examples/SessionBasedWorkflow.ps1 +++ b/app-runner/examples/SessionBasedWorkflow.ps1 @@ -37,7 +37,7 @@ try { # Step 5: Run an application (session-aware) Write-Host "`n4. Running application..." -ForegroundColor Green - $result = Invoke-DeviceApp -ExecutablePath 'MyTestGame.exe' -Arguments '--debug --level=verbose' + $result = Invoke-DeviceApp -ExecutablePath 'MyTestGame.exe' -Arguments @('--debug', '--level=verbose') Write-Host " Application started successfully on $($result.Platform)" -ForegroundColor Green # Step 6: Collect diagnostics (all session-aware)