From 3e5924a213a441f0c8a3122b2168cd0bc3260e97 Mon Sep 17 00:00:00 2001 From: Serhii Snitsaruk Date: Mon, 8 Dec 2025 21:39:53 +0100 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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)