From 3e5924a213a441f0c8a3122b2168cd0bc3260e97 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 8 Dec 2025 21:39:53 +0100 Subject: [PATCH 01/27] Fix argument splitting --- .../Private/DeviceProviders/SauceLabsProvider.ps1 | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 029dc6c..cf50282 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -369,10 +369,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 { From 663dac8a9d88e898b9aa0c76168dcf5f866704de Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 8 Dec 2025 21:40:32 +0100 Subject: [PATCH 02/27] Use proper parameter on iOS vs Android --- app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index cf50282..73fd1eb 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -405,10 +405,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 ) } From 7dd6fe1f05a8b2c07c261b624b08a6d801f53b52 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Tue, 9 Dec 2025 21:50:42 +0100 Subject: [PATCH 03/27] Implement CopyDeviceItem (iOS tested) --- .../DeviceProviders/SauceLabsProvider.ps1 | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 73fd1eb..dfd6413 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -596,8 +596,152 @@ 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. For iOS, must use bundle format: @bundle.id:documents/file.txt + + .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 + #> [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 iOS-specific troubleshooting for server errors + if ($this.MobilePlatform -eq 'iOS' -and $Error.Exception.Message -match "500|Internal Server Error") { + $errorMsg += "`n`nTroubleshooting iOS file access:" + $errorMsg += "`n- App Bundle ID: '$($this.CurrentPackageName)'" + $errorMsg += "`n- Requested path: '$DevicePath'" + + 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 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 format: @$($this.CurrentPackageName):documents/relative_path" + } + } + + Write-Error $errorMsg + throw } # Override DetectAndSetDefaultTarget - not needed for SauceLabs From a748051617a34bc44ab4954c3512640d86d9d5a9 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 10:35:05 +0100 Subject: [PATCH 04/27] Add Android-specific diagnostics --- .../DeviceProviders/SauceLabsProvider.ps1 | 68 +++++++++++++------ 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index dfd6413..668b672 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -654,9 +654,11 @@ class SauceLabsProvider : DeviceProvider { .DESCRIPTION Retrieves files from iOS/Android devices via Appium's pull_file API. - + .PARAMETER DevicePath - Path to the file on the device. For iOS, must use bundle format: @bundle.id:documents/file.txt + 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. @@ -665,6 +667,10 @@ class SauceLabsProvider : DeviceProvider { 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) { if (-not $this.SessionId) { @@ -712,31 +718,49 @@ class SauceLabsProvider : DeviceProvider { [void] HandleCopyDeviceItemError([System.Management.Automation.ErrorRecord]$Error, [string]$DevicePath) { $errorMsg = "Failed to copy file from device: $DevicePath. Error: $($Error.Exception.Message)" - # Add iOS-specific troubleshooting for server errors - if ($this.MobilePlatform -eq 'iOS' -and $Error.Exception.Message -match "500|Internal Server Error") { - $errorMsg += "`n`nTroubleshooting iOS file access:" - $errorMsg += "`n- App Bundle ID: '$($this.CurrentPackageName)'" + # 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'" - 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" + 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)" } - } catch { - $errorMsg += "`n- Could not check app capabilities: $($_.Exception.Message)" - } - $errorMsg += "`n`nCommon 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 format: @$($this.CurrentPackageName):documents/relative_path" + $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)" + } } } From 89c0ca14af945a6c63e0f3b7ba2f02ddfa7bd884 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 12:18:21 +0100 Subject: [PATCH 05/27] Add log file override support to RunApplication - Add optional LogFilePath parameter to Invoke-DeviceApp and RunApplication - Implement log file retrieval with fallback to system logs in SauceLabsProvider - Add method overloads to AdbProvider and XboxProvider for compatibility - Reduce code footprint by consolidating log retrieval logic - Maintain backward compatibility with existing provider implementations Co-authored-by: Claude Sonnet --- .../Private/DeviceProviders/AdbProvider.ps1 | 47 +++++------ .../DeviceProviders/SauceLabsProvider.ps1 | 80 ++++++++++++------- .../Private/DeviceProviders/XboxProvider.ps1 | 5 ++ app-runner/Public/Invoke-DeviceApp.ps1 | 20 ++++- 4 files changed, 91 insertions(+), 61 deletions(-) diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 index 5b53c82..102b2d8 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -196,6 +196,11 @@ class AdbProvider : DeviceProvider { } [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + return $this.RunApplication($ExecutablePath, $Arguments, $null) + } + + [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments, [string]$LogFilePath) { + # NOTE: LogFilePath parameter ignored in this implementation. Write-Debug "$($this.Platform): Running application: $ExecutablePath" # Parse ExecutablePath: "package.name/activity.name" @@ -220,19 +225,15 @@ class AdbProvider : DeviceProvider { $this.InvokeCommand('logcat-clear', @($this.DeviceSerial)) # Launch activity - Write-Host "Launching: $ExecutablePath" -ForegroundColor Cyan + Write-Host "Launching: $packageName/$activityName" -ForegroundColor Cyan if ($Arguments) { Write-Host " Arguments: $Arguments" -ForegroundColor Cyan } + $this.InvokeCommand('launch', @($this.DeviceSerial, $packageName, $activityName, $Arguments)) - $launchOutput = $this.InvokeCommand('launch', @($this.DeviceSerial, $ExecutablePath, $Arguments)) - - # Join output to string first since -match on arrays returns matching elements, not boolean - if (($launchOutput -join "`n") -match 'Error') { - throw "Failed to start activity: $($launchOutput -join "`n")" - } + # Wait before searching for process (app needs time to start) + Start-Sleep -Seconds 2 - # Wait for process to appear Write-Debug "$($this.Platform): Waiting for app process..." $appPID = $this.WaitForProcess($packageName, $pidRetrySeconds) @@ -247,35 +248,23 @@ class AdbProvider : DeviceProvider { $exitCode = 0 } else { - Write-Host "App PID: $appPID" -ForegroundColor Green + Write-Host "Found process PID: $appPID" -ForegroundColor Green - # Monitor process until it exits (generic approach - no app-specific log checking) - Write-Host "Monitoring app execution..." -ForegroundColor Yellow - $processExited = $false + # Keep monitoring the process until it exits or timeout + Write-Host "Monitoring process until app exits..." -ForegroundColor Yellow while ((Get-Date) - $startTime -lt [TimeSpan]::FromSeconds($timeoutSeconds)) { - # Check if process still exists - try { - $pidCheck = $this.InvokeCommand('pidof', @($this.DeviceSerial, $packageName)) - - if (-not $pidCheck) { - # Process exited - Write-Host "App process exited" -ForegroundColor Green - $processExited = $true - break - } - } - catch { - # Process not found - assume exited - Write-Host "App process exited" -ForegroundColor Green - $processExited = $true + $isRunning = $this.IsProcessRunning($appPID) + if (-not $isRunning) { + Write-Host "Process $appPID has exited" -ForegroundColor Green break } - Start-Sleep -Seconds $processCheckIntervalSeconds } - if (-not $processExited) { + # Check once more after the loop to see if the process really exited + $isRunning = $this.IsProcessRunning($appPID) + if ($isRunning) { Write-Host "Warning: Process did not exit within timeout" -ForegroundColor Yellow } diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 668b672..0d3879b 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -19,7 +19,7 @@ Key features: - App upload to SauceLabs storage - 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: @@ -304,6 +304,10 @@ class SauceLabsProvider : DeviceProvider { } [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + return $this.RunApplication($ExecutablePath, $Arguments, $null) + } + + [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments, [string]$LogFilePath) { Write-Debug "$($this.Platform): Running application: $ExecutablePath" if (-not $this.SessionId) { @@ -440,38 +444,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 - } - - # Format logs consistently (Android only for now) - $formattedLogs = $logCache - if ($this.MobilePlatform -eq 'Android') { - $formattedLogs = Format-LogcatOutput -LogcatOutput $logCache + } + catch { + Write-Warning "Failed to retrieve log file: $($_.Exception.Message)" + Write-Host "Falling back to system logs..." -ForegroundColor Yellow + } + } + + # 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 @@ -479,7 +499,7 @@ class SauceLabsProvider : DeviceProvider { StartedAt = $startTime FinishedAt = Get-Date Output = $formattedLogs - ExitCode = 0 # Mobile platforms don't reliably report exit codes here + ExitCode = 0 } } diff --git a/app-runner/Private/DeviceProviders/XboxProvider.ps1 b/app-runner/Private/DeviceProviders/XboxProvider.ps1 index dc83d00..ceafe26 100644 --- a/app-runner/Private/DeviceProviders/XboxProvider.ps1 +++ b/app-runner/Private/DeviceProviders/XboxProvider.ps1 @@ -252,6 +252,11 @@ class XboxProvider : DeviceProvider { # - 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) { + return $this.RunApplication($AppPath, $Arguments, $null) + } + + [hashtable] RunApplication([string]$AppPath, [string]$Arguments, [string]$LogFilePath) { + # NOTE: 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 diff --git a/app-runner/Public/Invoke-DeviceApp.ps1 b/app-runner/Public/Invoke-DeviceApp.ps1 index 7180c4f..b8e4974 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -13,8 +13,17 @@ function Invoke-DeviceApp { .PARAMETER Arguments 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 "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,11 @@ function Invoke-DeviceApp { # Use the provider to run the application $provider = $script:CurrentSession.Provider - $result = $provider.RunApplication($ExecutablePath, $Arguments) + if (-not [string]::IsNullOrWhiteSpace($LogFilePath)) { + $result = $provider.RunApplication($ExecutablePath, $Arguments, $LogFilePath) + } else { + $result = $provider.RunApplication($ExecutablePath, $Arguments) + } Write-GitHub "::endgroup::" From 4aee5aea9573cc768d8ee46d797d83ad1e112c47 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 12:28:24 +0100 Subject: [PATCH 06/27] Undo changes in AdbProvider --- .../Private/DeviceProviders/AdbProvider.ps1 | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 index 102b2d8..941a4ee 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -225,15 +225,19 @@ class AdbProvider : DeviceProvider { $this.InvokeCommand('logcat-clear', @($this.DeviceSerial)) # Launch activity - Write-Host "Launching: $packageName/$activityName" -ForegroundColor Cyan + Write-Host "Launching: $ExecutablePath" -ForegroundColor Cyan if ($Arguments) { Write-Host " Arguments: $Arguments" -ForegroundColor Cyan } - $this.InvokeCommand('launch', @($this.DeviceSerial, $packageName, $activityName, $Arguments)) - # Wait before searching for process (app needs time to start) - Start-Sleep -Seconds 2 + $launchOutput = $this.InvokeCommand('launch', @($this.DeviceSerial, $ExecutablePath, $Arguments)) + # Join output to string first since -match on arrays returns matching elements, not boolean + if (($launchOutput -join "`n") -match 'Error') { + throw "Failed to start activity: $($launchOutput -join "`n")" + } + + # Wait for process to appear Write-Debug "$($this.Platform): Waiting for app process..." $appPID = $this.WaitForProcess($packageName, $pidRetrySeconds) @@ -248,23 +252,35 @@ class AdbProvider : DeviceProvider { $exitCode = 0 } else { - Write-Host "Found process PID: $appPID" -ForegroundColor Green + Write-Host "App PID: $appPID" -ForegroundColor Green - # Keep monitoring the process until it exits or timeout - Write-Host "Monitoring process until app exits..." -ForegroundColor Yellow + # Monitor process until it exits (generic approach - no app-specific log checking) + Write-Host "Monitoring app execution..." -ForegroundColor Yellow + $processExited = $false while ((Get-Date) - $startTime -lt [TimeSpan]::FromSeconds($timeoutSeconds)) { - $isRunning = $this.IsProcessRunning($appPID) - if (-not $isRunning) { - Write-Host "Process $appPID has exited" -ForegroundColor Green + # Check if process still exists + try { + $pidCheck = $this.InvokeCommand('pidof', @($this.DeviceSerial, $packageName)) + + if (-not $pidCheck) { + # Process exited + Write-Host "App process exited" -ForegroundColor Green + $processExited = $true + break + } + } + catch { + # Process not found - assume exited + Write-Host "App process exited" -ForegroundColor Green + $processExited = $true break } + Start-Sleep -Seconds $processCheckIntervalSeconds } - # Check once more after the loop to see if the process really exited - $isRunning = $this.IsProcessRunning($appPID) - if ($isRunning) { + if (-not $processExited) { Write-Host "Warning: Process did not exit within timeout" -ForegroundColor Yellow } From 04087e89b970819c9e4ca94d7036b41ca8a59e57 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 12:31:02 +0100 Subject: [PATCH 07/27] Restore comment --- .../DeviceProviders/SauceLabsProvider.ps1 | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 0d3879b..b2900ab 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -446,19 +446,19 @@ class SauceLabsProvider : DeviceProvider { # Retrieve logs - try log file first if provided, otherwise use system logs Write-Host "Retrieving logs..." -ForegroundColor Yellow - + $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 @@ -472,7 +472,7 @@ class SauceLabsProvider : DeviceProvider { Write-Host "Falling back to system logs..." -ForegroundColor Yellow } } - + # Fallback to system logs if log file not retrieved if (-not $formattedLogs) { $logType = if ($this.MobilePlatform -eq 'iOS') { 'syslog' } else { 'logcat' } @@ -484,10 +484,10 @@ class SauceLabsProvider : DeviceProvider { "$($_.timestamp) $($_.level) $($_.message)" } | Where-Object { $_ } - $formattedLogs = if ($this.MobilePlatform -eq 'Android') { - Format-LogcatOutput -LogcatOutput $logCache - } else { - $logCache + $formattedLogs = if ($this.MobilePlatform -eq 'Android') { + Format-LogcatOutput -LogcatOutput $logCache + } else { + $logCache } } } @@ -499,7 +499,7 @@ class SauceLabsProvider : DeviceProvider { StartedAt = $startTime FinishedAt = Get-Date Output = $formattedLogs - ExitCode = 0 + ExitCode = 0 # Mobile platforms don't reliably report exit codes here } } From 72f28c0aa5484cef6e98d91102e630210124dcf0 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 12:33:09 +0100 Subject: [PATCH 08/27] Restore another comment --- app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index b2900ab..f0d8514 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -19,6 +19,7 @@ Key features: - App upload to SauceLabs storage - 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 From cac36d6688a1c6abf881df03ff3e9042e8e69956 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 12:48:06 +0100 Subject: [PATCH 09/27] Refactor RunApplication to use optional parameter instead of overloads - Replace method overloads with single method using optional LogFilePath parameter - Update base DeviceProvider class to include optional parameter - Simplify all provider implementations (SauceLabs, Adb, Xbox) - Remove conditional logic from Invoke-DeviceApp - single method call - Clean up code by eliminating method overload complexity Co-authored-by: Claude Sonnet --- app-runner/Private/DeviceProviders/AdbProvider.ps1 | 8 ++------ app-runner/Private/DeviceProviders/DeviceProvider.ps1 | 2 +- app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 | 6 +----- app-runner/Private/DeviceProviders/XboxProvider.ps1 | 8 ++------ app-runner/Public/Invoke-DeviceApp.ps1 | 6 +----- 5 files changed, 7 insertions(+), 23 deletions(-) diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 index 941a4ee..c338e2d 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -195,12 +195,8 @@ class AdbProvider : DeviceProvider { } } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { - return $this.RunApplication($ExecutablePath, $Arguments, $null) - } - - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments, [string]$LogFilePath) { - # NOTE: LogFilePath parameter ignored in this implementation. + [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" diff --git a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 index 037c3be..50d19fd 100644 --- a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 @@ -372,7 +372,7 @@ 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)) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index f0d8514..9ed2c7c 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -304,11 +304,7 @@ class SauceLabsProvider : DeviceProvider { } } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { - return $this.RunApplication($ExecutablePath, $Arguments, $null) - } - - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments, [string]$LogFilePath) { + [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments, [string]$LogFilePath = $null) { Write-Debug "$($this.Platform): Running application: $ExecutablePath" if (-not $this.SessionId) { diff --git a/app-runner/Private/DeviceProviders/XboxProvider.ps1 b/app-runner/Private/DeviceProviders/XboxProvider.ps1 index ceafe26..3e707a5 100644 --- a/app-runner/Private/DeviceProviders/XboxProvider.ps1 +++ b/app-runner/Private/DeviceProviders/XboxProvider.ps1 @@ -251,12 +251,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) { - return $this.RunApplication($AppPath, $Arguments, $null) - } - - [hashtable] RunApplication([string]$AppPath, [string]$Arguments, [string]$LogFilePath) { - # NOTE: LogFilePath parameter ignored in this implementation. + [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 diff --git a/app-runner/Public/Invoke-DeviceApp.ps1 b/app-runner/Public/Invoke-DeviceApp.ps1 index b8e4974..1930b78 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -47,11 +47,7 @@ function Invoke-DeviceApp { # Use the provider to run the application $provider = $script:CurrentSession.Provider - if (-not [string]::IsNullOrWhiteSpace($LogFilePath)) { - $result = $provider.RunApplication($ExecutablePath, $Arguments, $LogFilePath) - } else { - $result = $provider.RunApplication($ExecutablePath, $Arguments) - } + $result = $provider.RunApplication($ExecutablePath, $Arguments, $LogFilePath) Write-GitHub "::endgroup::" From c629164c11c6f50f479934b120f72d070988503b Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 12:51:58 +0100 Subject: [PATCH 10/27] Update MockDeviceProvider RunApplication signature - Add optional LogFilePath parameter to match other providers - Ensures consistent method signature across all provider implementations - Completes the refactoring to use optional parameters instead of overloads Co-authored-by: Claude Sonnet --- app-runner/Private/DeviceProviders/MockDeviceProvider.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-runner/Private/DeviceProviders/MockDeviceProvider.ps1 b/app-runner/Private/DeviceProviders/MockDeviceProvider.ps1 index 90597a8..1af5d3c 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 From c88f48cea32e203946814d989589f5ed72cdf4e6 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Wed, 10 Dec 2025 13:27:07 +0100 Subject: [PATCH 11/27] Remove outdated warning - args are supported on iOS --- app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 9ed2c7c..a936a20 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -362,7 +362,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 = @{ From 6e0824bbc07180825c28ca4cd4407123bdbc74ce Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Sat, 13 Dec 2025 17:14:18 +0100 Subject: [PATCH 12/27] Refactor API to pass arguments as arrays instead of strings Updated device provider APIs to consistently accept arguments as string arrays rather than pre-formatted strings, improving type safety and eliminating argument parsing ambiguities. Added ConvertArgumentsToString method to DeviceProvider base class for consistent string conversion when needed by underlying tools. Co-authored-by: Claude --- app-runner/Private/AndroidHelpers.ps1 | 68 ++++++--- .../Private/DeviceProviders/AdbProvider.ps1 | 14 +- .../DeviceProviders/DeviceProvider.ps1 | 34 ++++- .../DeviceProviders/MockDeviceProvider.ps1 | 2 +- .../DeviceProviders/SauceLabsProvider.ps1 | 17 ++- .../Private/DeviceProviders/XboxProvider.ps1 | 10 +- app-runner/Public/Invoke-DeviceApp.ps1 | 6 +- app-runner/Tests/AndroidHelpers.Tests.ps1 | 144 ++++++++++++------ app-runner/Tests/Desktop.Tests.ps1 | 10 +- app-runner/Tests/Device.Tests.ps1 | 75 ++++++++- app-runner/Tests/SauceLabs.Tests.ps1 | 4 +- app-runner/Tests/SessionManagement.Tests.ps1 | 6 +- app-runner/examples/SessionBasedWorkflow.ps1 | 2 +- 13 files changed, 285 insertions(+), 107 deletions(-) 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 c338e2d..45cb7db 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -195,7 +195,7 @@ class AdbProvider : DeviceProvider { } } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments, [string]$LogFilePath = $null) { + [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { # LogFilePath parameter ignored in this implementation Write-Debug "$($this.Platform): Running application: $ExecutablePath" @@ -206,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'] @@ -222,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 50d19fd..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, [string]$LogFilePath = $null) { + [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 1af5d3c..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, [string]$LogFilePath = $null) { + [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 a936a20..8ca086b 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -304,7 +304,7 @@ class SauceLabsProvider : DeviceProvider { } } - [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments, [string]$LogFilePath = $null) { + [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { Write-Debug "$($this.Platform): Running application: $ExecutablePath" if (-not $this.SessionId) { @@ -324,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 = @{ @@ -342,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)" } diff --git a/app-runner/Private/DeviceProviders/XboxProvider.ps1 b/app-runner/Private/DeviceProviders/XboxProvider.ps1 index 3e707a5..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,7 @@ 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, [string]$LogFilePath = $null) { + [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) @@ -262,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 1930b78..0a1ff71 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -11,7 +11,7 @@ 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). @@ -20,7 +20,7 @@ function Invoke-DeviceApp { - Android: Use absolute path like "/data/data/com.example.app/files/logs/app.log" .EXAMPLE - Invoke-DeviceApp -ExecutablePath "MyGame.exe" -Arguments "--debug --level=1" + Invoke-DeviceApp -ExecutablePath "MyGame.exe" -Arguments @("--debug", "--level=1") .EXAMPLE Invoke-DeviceApp -ExecutablePath "com.example.app" -LogFilePath "@com.example.app:documents/logs/app.log" @@ -32,7 +32,7 @@ function Invoke-DeviceApp { [string]$ExecutablePath, [Parameter(Mandatory = $false)] - [string]$Arguments = "", + [string[]]$Arguments = @(), [Parameter(Mandatory = $false)] [string]$LogFilePath = $null 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) From 2b15f36240f355365a2655708de74d7fb69edda8 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 17:15:53 +0100 Subject: [PATCH 13/27] Extract argument conversion to standalone function Also relax Intent extras validation to only validate known patterns --- app-runner/Private/AndroidHelpers.ps1 | 28 +++---- app-runner/Private/Conversions.ps1 | 53 +++++++++++++ .../Private/DeviceProviders/AdbProvider.ps1 | 2 +- .../DeviceProviders/DeviceProvider.ps1 | 28 +------ .../DeviceProviders/SauceLabsProvider.ps1 | 14 +--- .../Private/DeviceProviders/XboxProvider.ps1 | 4 +- app-runner/Tests/AndroidHelpers.Tests.ps1 | 14 ++-- app-runner/Tests/Converstions.Tests.ps1 | 79 +++++++++++++++++++ app-runner/Tests/Desktop.Tests.ps1 | 2 + app-runner/Tests/Device.Tests.ps1 | 71 ----------------- 10 files changed, 161 insertions(+), 134 deletions(-) create mode 100644 app-runner/Private/Conversions.ps1 create mode 100644 app-runner/Tests/Converstions.Tests.ps1 diff --git a/app-runner/Private/AndroidHelpers.ps1 b/app-runner/Private/AndroidHelpers.ps1 index 0ae8dac..a136be0 100644 --- a/app-runner/Private/AndroidHelpers.ps1 +++ b/app-runner/Private/AndroidHelpers.ps1 @@ -72,17 +72,17 @@ function Test-IntentExtrasArray { return $true } - # 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') + # 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') - $i = 0 - while ($i -lt $Arguments.Count) { + # Validate only the patterns we understand + for ($i = 0; $i -lt $Arguments.Count; $i++) { $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) + + # Only validate arguments that are known key-value flags + 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." } @@ -95,13 +95,11 @@ function Test-IntentExtrasArray { 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 + # 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 diff --git a/app-runner/Private/Conversions.ps1 b/app-runner/Private/Conversions.ps1 new file mode 100644 index 0000000..5779e5e --- /dev/null +++ b/app-runner/Private/Conversions.ps1 @@ -0,0 +1,53 @@ +# Conversion Functions + +<# +.SYNOPSIS +Converts an array of arguments to a PowerShell Invoke-Expression-safe string. + +.DESCRIPTION +Converts an array of string arguments to a properly formatted string for PowerShell's +Invoke-Expression context. Uses a different escaping strategy that works with PowerShell's +parsing of command strings. + +.PARAMETER Arguments +Array of string arguments to convert + +.EXAMPLE +ConvertTo-ArgumentString @('--debug', '--config', 'my config.txt') +Returns: "--debug --config 'my config.txt'" + +.EXAMPLE +ConvertTo-ArgumentString @('-Command', "Write-Host 'test'") +Returns: "-Command 'Write-Host ''test'''" +#> +function ConvertTo-ArgumentString { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [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"''&|<>^;]') { + # For PowerShell Invoke-Expression context, use PowerShell-style single quote escaping + # In PowerShell, single quotes are escaped by doubling them + $escapedArg = $arg -replace "'", "''" + $formattedArgs += "'" + $escapedArg + "'" + } else { + $formattedArgs += $arg + } + } + + return $formattedArgs -join ' ' +} diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 index 45cb7db..2785fe9 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -223,7 +223,7 @@ class AdbProvider : DeviceProvider { # Launch activity Write-Host "Launching: $ExecutablePath" -ForegroundColor Cyan - $argumentsString = $this.ConvertArgumentsToString($Arguments) + $argumentsString = ConvertTo-ArgumentString $Arguments if ($argumentsString) { Write-Host " Arguments: $argumentsString" -ForegroundColor Cyan } diff --git a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 index 1046d6f..d0de9b8 100644 --- a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 @@ -120,33 +120,7 @@ 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" @@ -402,7 +376,7 @@ class DeviceProvider { [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { Write-Debug "$($this.Platform): Running application: $ExecutablePath with arguments: $Arguments" - $argumentsString = $this.ConvertArgumentsToString($Arguments) + $argumentsString = ConvertTo-ArgumentString($Arguments) $command = $this.BuildCommand('launch', @($ExecutablePath, $argumentsString)) return $this.InvokeApplicationCommand($command, $ExecutablePath, $Arguments) } diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 8ca086b..936ee01 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -331,7 +331,7 @@ class SauceLabsProvider : DeviceProvider { # Launch activity with Intent extras Write-Host "Launching: $packageName/$activityName" -ForegroundColor Cyan - $argumentsString = $this.ConvertArgumentsToString($Arguments) + $argumentsString = ConvertTo-ArgumentString $Arguments if ($argumentsString) { Write-Host " Arguments: $argumentsString" -ForegroundColor Cyan } @@ -372,17 +372,7 @@ class SauceLabsProvider : DeviceProvider { } if ($Arguments) { - # 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 + $launchBody['arguments'] = $Arguments } try { diff --git a/app-runner/Private/DeviceProviders/XboxProvider.ps1 b/app-runner/Private/DeviceProviders/XboxProvider.ps1 index 10312ae..d281c10 100644 --- a/app-runner/Private/DeviceProviders/XboxProvider.ps1 +++ b/app-runner/Private/DeviceProviders/XboxProvider.ps1 @@ -242,7 +242,7 @@ class XboxProvider : DeviceProvider { # Not giving the argument here stops any foreground app $this.InvokeCommand('stop-app', @('')) - $argumentsString = $this.ConvertArgumentsToString($Arguments) + $argumentsString = ConvertTo-ArgumentString $Arguments $builtCommand = $this.BuildCommand('launch-app', @($PackageIdentity, $argumentsString)) return $this.InvokeApplicationCommand($builtCommand, $PackageIdentity, $Arguments) } @@ -263,7 +263,7 @@ class XboxProvider : DeviceProvider { Write-Host "Mirroring directory $AppPath to Xbox devkit $xboxTempDir..." $this.InvokeCommand('xbcopy', @($AppPath, "x$xboxTempDir")) - $argumentsString = $this.ConvertArgumentsToString($Arguments) + $argumentsString = ConvertTo-ArgumentString $Arguments $builtCommand = $this.BuildCommand('launch', @($xboxTempDir, "$xboxTempDir\$appExecutableName", $argumentsString)) return $this.InvokeApplicationCommand($builtCommand, $appExecutableName, $Arguments) } elseif (Test-Path $AppPath -PathType Leaf) { diff --git a/app-runner/Tests/AndroidHelpers.Tests.ps1 b/app-runner/Tests/AndroidHelpers.Tests.ps1 index 15cac36..fa3b0eb 100644 --- a/app-runner/Tests/AndroidHelpers.Tests.ps1 +++ b/app-runner/Tests/AndroidHelpers.Tests.ps1 @@ -233,8 +233,8 @@ Context 'Test-IntentExtrasArray' { { 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 arguments without throwing' { + { Test-IntentExtrasArray -Arguments @('key', 'value') } | Should -Not -Throw } It 'Accepts unknown flags by ignoring validation' { @@ -261,9 +261,11 @@ Context 'Test-IntentExtrasArray' { { 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*' + It 'Accepts single-token arguments like --grant-read-uri-permission' { + { Test-IntentExtrasArray -Arguments @('--grant-read-uri-permission') } | Should -Not -Throw } -} - + 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/Converstions.Tests.ps1 b/app-runner/Tests/Converstions.Tests.ps1 new file mode 100644 index 0000000..e8ac5eb --- /dev/null +++ b/app-runner/Tests/Converstions.Tests.ps1 @@ -0,0 +1,79 @@ +$ErrorActionPreference = 'Stop' + +BeforeAll { + $ModulePath = Join-Path $PSScriptRoot '..' 'SentryAppRunner.psd1' + Import-Module $ModulePath -Force + + . "$PSScriptRoot\..\Private\Conversions.ps1" +} + +AfterAll { + Remove-Module SentryAppRunner -Force -ErrorAction SilentlyContinue +} + +Context 'ConvertTo-ArgumentString' { + It 'Handles empty array' { + $result = ConvertTo-ArgumentString @() + $result | Should -Be "" + } + + It 'Handles null array' { + $result = ConvertTo-ArgumentString $null + $result | Should -Be "" + } + + It 'Handles simple arguments without spaces' { + $result = ConvertTo-ArgumentString @('--debug', '--verbose') + $result | Should -Be "--debug --verbose" + } + + It 'Handles arguments with spaces using single quotes' { + $result = ConvertTo-ArgumentString @('--config', 'my config.txt') + $result | Should -Be "--config 'my config.txt'" + } + + It 'Handles arguments with single quotes using PowerShell escaping' { + $result = ConvertTo-ArgumentString @('--message', "It's working") + $result | Should -Be "--message 'It''s working'" + } + + It 'Handles arguments with double quotes by escaping them' { + $result = ConvertTo-ArgumentString @('--text', 'He said "hello"') + $result | Should -Be '--text ''He said "hello"''' + } + + It 'Handles arguments with special characters' { + $result = ConvertTo-ArgumentString @('--regex', '[a-z]+') + $result | Should -Be '--regex [a-z]+' + } + + It 'Handles PowerShell commands with single quotes' { + $result = ConvertTo-ArgumentString @('-Command', "Write-Host 'test-output'") + $result | Should -Be "-Command 'Write-Host ''test-output'''" + } + + It 'Handles PowerShell commands with semicolons' { + $result = ConvertTo-ArgumentString @('-Command', "Write-Host 'line1'; Write-Host 'line2'") + $result | Should -Be "-Command 'Write-Host ''line1''; Write-Host ''line2'''" + } + + It 'Handles pipe characters' { + $result = ConvertTo-ArgumentString @('--command', 'echo hello | grep hi') + $result | Should -Be "--command 'echo hello | grep hi'" + } + + It 'Handles ampersand characters' { + $result = ConvertTo-ArgumentString @('--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 = ConvertTo-ArgumentString @('--flag', '', 'value') + $result | Should -Be '--flag value' + } + + It 'Handles arguments with redirections' { + $result = ConvertTo-ArgumentString @('--output', 'file > /dev/null') + $result | Should -Be "--output 'file > /dev/null'" + } +} diff --git a/app-runner/Tests/Desktop.Tests.ps1 b/app-runner/Tests/Desktop.Tests.ps1 index ad77af1..8302ff1 100644 --- a/app-runner/Tests/Desktop.Tests.ps1 +++ b/app-runner/Tests/Desktop.Tests.ps1 @@ -6,6 +6,8 @@ # For REMOTE gaming consoles (Xbox, PS5, Switch), see Device.Tests.ps1 which requires platform SDKs. $ErrorActionPreference = 'Stop' +$global:DebugPreference = "Continue" + BeforeDiscovery { # Detect current platform and add as test target function Get-CurrentDesktopPlatform { diff --git a/app-runner/Tests/Device.Tests.ps1 b/app-runner/Tests/Device.Tests.ps1 index e7333b1..e4974ce 100644 --- a/app-runner/Tests/Device.Tests.ps1 +++ b/app-runner/Tests/Device.Tests.ps1 @@ -559,74 +559,3 @@ 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'" - } -} From 1910626cd3e0aa3b59e9a3d579755d1a90ca3878 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 17:59:21 +0100 Subject: [PATCH 14/27] Fix app name resolution logic in SauceLabsProvider --- app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 936ee01..0f217b4 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -637,7 +637,11 @@ class SauceLabsProvider : DeviceProvider { Found = $true BundleId = $this.CurrentPackageName FileSharingEnabled = [bool]$targetApp.UIFileSharingEnabled - Name = $targetApp.CFBundleDisplayName -or $targetApp.CFBundleName -or "Unknown" + Name = $( + if ($targetApp.CFBundleDisplayName) { $targetApp.CFBundleDisplayName } + elseif ($targetApp.CFBundleName) { $targetApp.CFBundleName } + else { "Unknown" } + ) AllApps = $bundleIds } } From 6352e574a28c01a5ed453f2d8f98ccc870955fe6 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 18:01:15 +0100 Subject: [PATCH 15/27] Use Write-Warning --- app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 0f217b4..d1c4dc2 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -777,7 +777,7 @@ class SauceLabsProvider : DeviceProvider { } } - Write-Error $errorMsg + Write-Warning $errorMsg throw } From fc1ccac50cc3fe4c08ba02cd1d47712ecdf7a368 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 18:10:28 +0100 Subject: [PATCH 16/27] Update AndroidHelpers.Tests.ps1 --- app-runner/Tests/AndroidHelpers.Tests.ps1 | 194 +++++++++++----------- 1 file changed, 96 insertions(+), 98 deletions(-) diff --git a/app-runner/Tests/AndroidHelpers.Tests.ps1 b/app-runner/Tests/AndroidHelpers.Tests.ps1 index fa3b0eb..2c58382 100644 --- a/app-runner/Tests/AndroidHelpers.Tests.ps1 +++ b/app-runner/Tests/AndroidHelpers.Tests.ps1 @@ -59,7 +59,103 @@ 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 '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 '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 'Accepts single-token arguments like --grant-read-uri-permission' { + { Test-IntentExtrasArray -Arguments @('--grant-read-uri-permission') } | Should -Not -Throw + } + It 'Accepts mixed single tokens and unknown arguments' { + { Test-IntentExtrasArray -Arguments @('not-a-flag', 'value', '--activity-clear-task') } | Should -Not -Throw + } + } Context 'Get-ApkPackageName error handling' { It 'Throws when APK file does not exist' { @@ -171,101 +267,3 @@ 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 '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 '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 'Accepts single-token arguments like --grant-read-uri-permission' { - { Test-IntentExtrasArray -Arguments @('--grant-read-uri-permission') } | Should -Not -Throw - } - - It 'Accepts mixed single tokens and unknown arguments' { - { Test-IntentExtrasArray -Arguments @('not-a-flag', 'value', '--activity-clear-task') } | Should -Not -Throw - } -} From 8310373328aa6647eb8e907a04d3e84553d1a65a Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 18:33:43 +0100 Subject: [PATCH 17/27] Fix typo in test filename --- .../Tests/{Converstions.Tests.ps1 => Conversions.Tests.ps1} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app-runner/Tests/{Converstions.Tests.ps1 => Conversions.Tests.ps1} (100%) diff --git a/app-runner/Tests/Converstions.Tests.ps1 b/app-runner/Tests/Conversions.Tests.ps1 similarity index 100% rename from app-runner/Tests/Converstions.Tests.ps1 rename to app-runner/Tests/Conversions.Tests.ps1 From bb84b7c683b95c831814bcf6225fdd8a0040cd7d Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 18:38:43 +0100 Subject: [PATCH 18/27] Remove debug preference configuration from tests --- app-runner/Tests/Desktop.Tests.ps1 | 2 -- 1 file changed, 2 deletions(-) diff --git a/app-runner/Tests/Desktop.Tests.ps1 b/app-runner/Tests/Desktop.Tests.ps1 index 8302ff1..ad77af1 100644 --- a/app-runner/Tests/Desktop.Tests.ps1 +++ b/app-runner/Tests/Desktop.Tests.ps1 @@ -6,8 +6,6 @@ # For REMOTE gaming consoles (Xbox, PS5, Switch), see Device.Tests.ps1 which requires platform SDKs. $ErrorActionPreference = 'Stop' -$global:DebugPreference = "Continue" - BeforeDiscovery { # Detect current platform and add as test target function Get-CurrentDesktopPlatform { From 554694721b755fbf9488c75116ba2e3e7b3f04e5 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 20:10:43 +0100 Subject: [PATCH 19/27] Simplify Appium request body construction --- .../DeviceProviders/SauceLabsProvider.ps1 | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index d1c4dc2..2f18e8e 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -367,21 +367,15 @@ class SauceLabsProvider : DeviceProvider { Write-Host " Arguments: $Arguments" -ForegroundColor Cyan } - $launchBody = @{ - bundleId = $bundleId - } - - if ($Arguments) { - $launchBody['arguments'] = $Arguments - } - 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 { @@ -400,15 +394,15 @@ class SauceLabsProvider : DeviceProvider { # 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 = @{ + $body = @{ script = 'mobile: queryAppState' - args = @( - @{ $appParameter = $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)" @@ -623,9 +617,9 @@ class SauceLabsProvider : DeviceProvider { try { $baseUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" - $scriptBody = @{ script = 'mobile: listApps'; args = @() } + $body = @{ script = 'mobile: listApps'; args = @() } - $response = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $scriptBody, $false, $null) + $response = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $body, $false, $null) if ($response -and $response.value) { $apps = $response.value From 57ae3b57fab42e2cb109de84332e6b5578af0cd9 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 20:23:50 +0100 Subject: [PATCH 20/27] Fix tests --- app-runner/Tests/SauceLabs.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-runner/Tests/SauceLabs.Tests.ps1 b/app-runner/Tests/SauceLabs.Tests.ps1 index 3cb07ef..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" From ebb2875a09e25fd1bffbae745973cca08eb864c4 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 20:42:47 +0100 Subject: [PATCH 21/27] Print log grouped in GHA --- app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 2f18e8e..1a1c927 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -475,6 +475,15 @@ class SauceLabsProvider : DeviceProvider { } } + # Output logs as folded group in GitHub Actions + if ($formattedLogs -and $env:GITHUB_ACTIONS -eq 'true') { + Write-Host "::group::Logs" + $formattedLogs | ForEach-Object { + Write-Debug "$_" + } + Write-Host "::endgroup::" + } + return @{ Platform = $this.Platform ExecutablePath = $ExecutablePath From d28c68e811fecf0ac8ca2634993e0f6c6d15b0eb Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 20:58:54 +0100 Subject: [PATCH 22/27] Fix indentation and improve log output handling --- .../DeviceProviders/SauceLabsProvider.ps1 | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 1a1c927..0bc986a 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -371,8 +371,8 @@ class SauceLabsProvider : DeviceProvider { $body = @{ script = "mobile: launchApp" args = @{ - bundleId = $bundleId - arguments = $Arguments + bundleId = $bundleId + arguments = $Arguments } } $launchResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $body, $false, $null) @@ -397,7 +397,7 @@ class SauceLabsProvider : DeviceProvider { $body = @{ script = 'mobile: queryAppState' args = @{ - $appParameter = $this.CurrentPackageName + $appParameter = $this.CurrentPackageName } } @@ -475,13 +475,14 @@ class SauceLabsProvider : DeviceProvider { } } - # Output logs as folded group in GitHub Actions + # 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-Host "::group::Logs" - $formattedLogs | ForEach-Object { - Write-Debug "$_" - } - Write-Host "::endgroup::" + Write-GitHub "::group::Logs" + $formattedLogs | ForEach-Object { + Write-Debug "$_" + } + Write-GitHub "::endgroup::" } return @{ From ad71c2aebc47a7096b7f0e2bfa9ee090419c8600 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 21:40:30 +0100 Subject: [PATCH 23/27] Warn when LogFilePath parameter is unsupported Add warnings to device providers that don't support the LogFilePath parameter, and clarify in documentation that it's currently only supported on SauceLabs platforms. --- app-runner/Private/DeviceProviders/AdbProvider.ps1 | 4 ++++ app-runner/Private/DeviceProviders/DeviceProvider.ps1 | 4 ++++ app-runner/Private/DeviceProviders/XboxProvider.ps1 | 5 ++++- app-runner/Public/Invoke-DeviceApp.ps1 | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 index 2785fe9..80569d3 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -199,6 +199,10 @@ class AdbProvider : DeviceProvider { # 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 diff --git a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 index d0de9b8..bed5fa9 100644 --- a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 @@ -376,6 +376,10 @@ class DeviceProvider { [hashtable] RunApplication([string]$ExecutablePath, [string[]]$Arguments, [string]$LogFilePath = $null) { Write-Debug "$($this.Platform): Running application: $ExecutablePath with arguments: $Arguments" + if (-not ([string]::IsNullOrEmpty($LogFilePath))) { + Write-Warning "LogFilePath parameter is not supported on this platform." + } + $argumentsString = ConvertTo-ArgumentString($Arguments) $command = $this.BuildCommand('launch', @($ExecutablePath, $argumentsString)) return $this.InvokeApplicationCommand($command, $ExecutablePath, $Arguments) diff --git a/app-runner/Private/DeviceProviders/XboxProvider.ps1 b/app-runner/Private/DeviceProviders/XboxProvider.ps1 index d281c10..9054164 100644 --- a/app-runner/Private/DeviceProviders/XboxProvider.ps1 +++ b/app-runner/Private/DeviceProviders/XboxProvider.ps1 @@ -253,7 +253,10 @@ class XboxProvider : DeviceProvider { # - 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, [string]$LogFilePath = $null) { - # LogFilePath parameter ignored in this implementation + 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 diff --git a/app-runner/Public/Invoke-DeviceApp.ps1 b/app-runner/Public/Invoke-DeviceApp.ps1 index 0a1ff71..bddad47 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -15,6 +15,7 @@ function Invoke-DeviceApp { .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" From 8b18f2dd00baa904a0330a43d56cf26193f5dbba Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 22:39:21 +0100 Subject: [PATCH 24/27] Remove argument guarantees and document expectations --- app-runner/Private/AndroidHelpers.ps1 | 4 +- app-runner/Private/Conversions.ps1 | 53 ------------- .../Private/DeviceProviders/AdbProvider.ps1 | 2 +- .../DeviceProviders/DeviceProvider.ps1 | 3 +- .../DeviceProviders/SauceLabsProvider.ps1 | 4 +- .../Private/DeviceProviders/XboxProvider.ps1 | 4 +- app-runner/Public/Invoke-DeviceApp.ps1 | 6 ++ app-runner/Tests/Conversions.Tests.ps1 | 79 ------------------- 8 files changed, 13 insertions(+), 142 deletions(-) delete mode 100644 app-runner/Private/Conversions.ps1 delete mode 100644 app-runner/Tests/Conversions.Tests.ps1 diff --git a/app-runner/Private/AndroidHelpers.ps1 b/app-runner/Private/AndroidHelpers.ps1 index a136be0..1eab77a 100644 --- a/app-runner/Private/AndroidHelpers.ps1 +++ b/app-runner/Private/AndroidHelpers.ps1 @@ -76,11 +76,9 @@ function Test-IntentExtrasArray { # Don't throw errors on unknown patterns - just validate what we know $knownKeyValueFlags = @('-e', '-es', '--es', '-ez', '--ez', '-ei', '--ei', '-el', '--el') - # Validate only the patterns we understand for ($i = 0; $i -lt $Arguments.Count; $i++) { $currentArg = $Arguments[$i] - - # Only validate arguments that are known key-value flags + if ($knownKeyValueFlags -contains $currentArg) { # For known key-value flags, ensure proper structure if ($i + 2 -ge $Arguments.Count) { diff --git a/app-runner/Private/Conversions.ps1 b/app-runner/Private/Conversions.ps1 deleted file mode 100644 index 5779e5e..0000000 --- a/app-runner/Private/Conversions.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -# Conversion Functions - -<# -.SYNOPSIS -Converts an array of arguments to a PowerShell Invoke-Expression-safe string. - -.DESCRIPTION -Converts an array of string arguments to a properly formatted string for PowerShell's -Invoke-Expression context. Uses a different escaping strategy that works with PowerShell's -parsing of command strings. - -.PARAMETER Arguments -Array of string arguments to convert - -.EXAMPLE -ConvertTo-ArgumentString @('--debug', '--config', 'my config.txt') -Returns: "--debug --config 'my config.txt'" - -.EXAMPLE -ConvertTo-ArgumentString @('-Command', "Write-Host 'test'") -Returns: "-Command 'Write-Host ''test'''" -#> -function ConvertTo-ArgumentString { - [CmdletBinding()] - param( - [Parameter(Mandatory = $false)] - [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"''&|<>^;]') { - # For PowerShell Invoke-Expression context, use PowerShell-style single quote escaping - # In PowerShell, single quotes are escaped by doubling them - $escapedArg = $arg -replace "'", "''" - $formattedArgs += "'" + $escapedArg + "'" - } else { - $formattedArgs += $arg - } - } - - return $formattedArgs -join ' ' -} diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 index 80569d3..0f1bee1 100644 --- a/app-runner/Private/DeviceProviders/AdbProvider.ps1 +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -227,7 +227,7 @@ class AdbProvider : DeviceProvider { # Launch activity Write-Host "Launching: $ExecutablePath" -ForegroundColor Cyan - $argumentsString = ConvertTo-ArgumentString $Arguments + $argumentsString = $Arguments -join ' ' if ($argumentsString) { Write-Host " Arguments: $argumentsString" -ForegroundColor Cyan } diff --git a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 index bed5fa9..a1bc8df 100644 --- a/app-runner/Private/DeviceProviders/DeviceProvider.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProvider.ps1 @@ -121,7 +121,6 @@ class DeviceProvider { } - [void] LogNotImplemented([string]$operation) { Write-Warning "$($this.Platform) $operation not yet implemented" } @@ -380,7 +379,7 @@ class DeviceProvider { Write-Warning "LogFilePath parameter is not supported on this platform." } - $argumentsString = ConvertTo-ArgumentString($Arguments) + $argumentsString = $Arguments -join ' ' $command = $this.BuildCommand('launch', @($ExecutablePath, $argumentsString)) return $this.InvokeApplicationCommand($command, $ExecutablePath, $Arguments) } diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 index 0bc986a..20bb87b 100644 --- a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -331,7 +331,7 @@ class SauceLabsProvider : DeviceProvider { # Launch activity with Intent extras Write-Host "Launching: $packageName/$activityName" -ForegroundColor Cyan - $argumentsString = ConvertTo-ArgumentString $Arguments + $argumentsString = $Arguments -join ' ' if ($argumentsString) { Write-Host " Arguments: $argumentsString" -ForegroundColor Cyan } @@ -480,7 +480,7 @@ class SauceLabsProvider : DeviceProvider { if ($formattedLogs -and $env:GITHUB_ACTIONS -eq 'true') { Write-GitHub "::group::Logs" $formattedLogs | ForEach-Object { - Write-Debug "$_" + Write-Host "$_" } Write-GitHub "::endgroup::" } diff --git a/app-runner/Private/DeviceProviders/XboxProvider.ps1 b/app-runner/Private/DeviceProviders/XboxProvider.ps1 index 9054164..e3e21ad 100644 --- a/app-runner/Private/DeviceProviders/XboxProvider.ps1 +++ b/app-runner/Private/DeviceProviders/XboxProvider.ps1 @@ -242,7 +242,7 @@ class XboxProvider : DeviceProvider { # Not giving the argument here stops any foreground app $this.InvokeCommand('stop-app', @('')) - $argumentsString = ConvertTo-ArgumentString $Arguments + $argumentsString = $Arguments -join ' ' $builtCommand = $this.BuildCommand('launch-app', @($PackageIdentity, $argumentsString)) return $this.InvokeApplicationCommand($builtCommand, $PackageIdentity, $Arguments) } @@ -266,7 +266,7 @@ class XboxProvider : DeviceProvider { Write-Host "Mirroring directory $AppPath to Xbox devkit $xboxTempDir..." $this.InvokeCommand('xbcopy', @($AppPath, "x$xboxTempDir")) - $argumentsString = ConvertTo-ArgumentString $Arguments + $argumentsString = $Arguments -join ' ' $builtCommand = $this.BuildCommand('launch', @($xboxTempDir, "$xboxTempDir\$appExecutableName", $argumentsString)) return $this.InvokeApplicationCommand($builtCommand, $appExecutableName, $Arguments) } elseif (Test-Path $AppPath -PathType Leaf) { diff --git a/app-runner/Public/Invoke-DeviceApp.ps1 b/app-runner/Public/Invoke-DeviceApp.ps1 index bddad47..25055d4 100644 --- a/app-runner/Public/Invoke-DeviceApp.ps1 +++ b/app-runner/Public/Invoke-DeviceApp.ps1 @@ -12,6 +12,9 @@ function Invoke-DeviceApp { .PARAMETER Arguments 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). @@ -23,6 +26,9 @@ function Invoke-DeviceApp { .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" #> diff --git a/app-runner/Tests/Conversions.Tests.ps1 b/app-runner/Tests/Conversions.Tests.ps1 deleted file mode 100644 index e8ac5eb..0000000 --- a/app-runner/Tests/Conversions.Tests.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -$ErrorActionPreference = 'Stop' - -BeforeAll { - $ModulePath = Join-Path $PSScriptRoot '..' 'SentryAppRunner.psd1' - Import-Module $ModulePath -Force - - . "$PSScriptRoot\..\Private\Conversions.ps1" -} - -AfterAll { - Remove-Module SentryAppRunner -Force -ErrorAction SilentlyContinue -} - -Context 'ConvertTo-ArgumentString' { - It 'Handles empty array' { - $result = ConvertTo-ArgumentString @() - $result | Should -Be "" - } - - It 'Handles null array' { - $result = ConvertTo-ArgumentString $null - $result | Should -Be "" - } - - It 'Handles simple arguments without spaces' { - $result = ConvertTo-ArgumentString @('--debug', '--verbose') - $result | Should -Be "--debug --verbose" - } - - It 'Handles arguments with spaces using single quotes' { - $result = ConvertTo-ArgumentString @('--config', 'my config.txt') - $result | Should -Be "--config 'my config.txt'" - } - - It 'Handles arguments with single quotes using PowerShell escaping' { - $result = ConvertTo-ArgumentString @('--message', "It's working") - $result | Should -Be "--message 'It''s working'" - } - - It 'Handles arguments with double quotes by escaping them' { - $result = ConvertTo-ArgumentString @('--text', 'He said "hello"') - $result | Should -Be '--text ''He said "hello"''' - } - - It 'Handles arguments with special characters' { - $result = ConvertTo-ArgumentString @('--regex', '[a-z]+') - $result | Should -Be '--regex [a-z]+' - } - - It 'Handles PowerShell commands with single quotes' { - $result = ConvertTo-ArgumentString @('-Command', "Write-Host 'test-output'") - $result | Should -Be "-Command 'Write-Host ''test-output'''" - } - - It 'Handles PowerShell commands with semicolons' { - $result = ConvertTo-ArgumentString @('-Command', "Write-Host 'line1'; Write-Host 'line2'") - $result | Should -Be "-Command 'Write-Host ''line1''; Write-Host ''line2'''" - } - - It 'Handles pipe characters' { - $result = ConvertTo-ArgumentString @('--command', 'echo hello | grep hi') - $result | Should -Be "--command 'echo hello | grep hi'" - } - - It 'Handles ampersand characters' { - $result = ConvertTo-ArgumentString @('--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 = ConvertTo-ArgumentString @('--flag', '', 'value') - $result | Should -Be '--flag value' - } - - It 'Handles arguments with redirections' { - $result = ConvertTo-ArgumentString @('--output', 'file > /dev/null') - $result | Should -Be "--output 'file > /dev/null'" - } -} From a45e24de8a635a00826b5631aec242c00bcf689b Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 15 Dec 2025 22:47:24 +0100 Subject: [PATCH 25/27] Revert tests --- app-runner/Tests/Desktop.Tests.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app-runner/Tests/Desktop.Tests.ps1 b/app-runner/Tests/Desktop.Tests.ps1 index ad77af1..9e420ae 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*' } } From e20da39874e26d2c758934e3e3494f3c762320fc Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Tue, 16 Dec 2025 11:02:44 +0100 Subject: [PATCH 26/27] Revert more tests --- app-runner/Tests/SessionManagement.Tests.ps1 | 6 +++--- app-runner/examples/SessionBasedWorkflow.ps1 | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app-runner/Tests/SessionManagement.Tests.ps1 b/app-runner/Tests/SessionManagement.Tests.ps1 index 0933615..de62f3e 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 a89fe85..5332dab 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) From dbbcac88ea9cf755a79dd67a611fdba7a998316b Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Tue, 16 Dec 2025 11:38:28 +0100 Subject: [PATCH 27/27] Fix test --- app-runner/Tests/SessionManagement.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @() } }