diff --git a/.github/workflows/app-runner.yml b/.github/workflows/app-runner.yml index 32a9e24..9830b7e 100644 --- a/.github/workflows/app-runner.yml +++ b/.github/workflows/app-runner.yml @@ -1,4 +1,5 @@ name: AppRunner +permissions: read-all on: push: @@ -13,8 +14,15 @@ on: jobs: test: uses: ./.github/workflows/test-powershell-module.yml + secrets: inherit with: module-name: SentryAppRunner module-path: app-runner test-path: Tests + exclude-path: Adb.Tests.ps1 settings-path: PSScriptAnalyzerSettings.psd1 + + test-adb: + uses: ./.github/workflows/test-adb-provider.yml + with: + module-path: app-runner diff --git a/.github/workflows/test-adb-provider.yml b/.github/workflows/test-adb-provider.yml new file mode 100644 index 0000000..113ff00 --- /dev/null +++ b/.github/workflows/test-adb-provider.yml @@ -0,0 +1,64 @@ +name: Test ADB Provider +permissions: + contents: read + +on: + workflow_call: + inputs: + module-path: + description: 'Path to the module directory' + required: true + type: string + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.module-path }} + shell: pwsh + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Enable KVM group permissions + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup Android directories + run: | + mkdir -p $HOME/.android/avd + touch $HOME/.android/repositories.cfg + + - name: Install Android SDK Build Tools + shell: bash + run: | + ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "build-tools;35.0.0" + echo "${ANDROID_HOME}/build-tools/35.0.0" >> $GITHUB_PATH + + - name: Run ADB Provider Integration Tests + uses: reactivecircus/android-emulator-runner@d94c3fbe4fe6a29e4a5ba47c12fb47677c73656b + timeout-minutes: 45 + with: + api-level: 35 + target: 'google_apis' + arch: x86_64 + force-avd-creation: true + disable-animations: true + disable-spellchecker: true + emulator-options: > + -no-window + -no-snapshot-save + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -camera-front none + script: | + adb wait-for-device + echo "Android emulator is ready" + adb devices + cd ${{ inputs.module-path }} && pwsh -Command "Invoke-Pester Tests/Adb.Tests.ps1 -CI" diff --git a/.github/workflows/test-powershell-module.yml b/.github/workflows/test-powershell-module.yml index d56e713..174a8e5 100644 --- a/.github/workflows/test-powershell-module.yml +++ b/.github/workflows/test-powershell-module.yml @@ -23,6 +23,11 @@ on: required: false type: string default: '' + exclude-path: + description: 'Comma-separated list of test file paths to exclude (relative to test-path)' + required: false + type: string + default: '' settings-path: description: 'Path to PSScriptAnalyzer settings file (relative to repo root)' required: false @@ -74,6 +79,10 @@ jobs: run: working-directory: ${{ inputs.module-path }} shell: pwsh + env: + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + SAUCE_REGION: us-west-1 steps: - name: Checkout repository @@ -94,6 +103,16 @@ jobs: $config.Filter.ExcludeTag = $excludeTags.Split(',').Trim() } + $excludePath = "${{ inputs.exclude-path }}" + if ($excludePath) { + $testPath = "${{ inputs.test-path }}" + $config.Run.ExcludePath = $excludePath.Split(',').Trim() | ForEach-Object { + $relativePath = Join-Path $testPath $_ + # ExcludePath requires absolute paths + Join-Path (Get-Location) $relativePath + } + } + $testResults = Invoke-Pester -Configuration $config Write-Host "Test Summary:" -ForegroundColor Cyan diff --git a/README.md b/README.md index 235a256..ba815e3 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,9 @@ Currently supported: - Windows - macOS - Linux - -Future support planned: - -- Mobile platforms (iOS, Android) +- **Mobile Platforms:** + - Android (via ADB or SauceLabs Real Device Cloud) + - iOS (via SauceLabs - coming soon) ## Requirements diff --git a/app-runner/Private/AndroidHelpers.ps1 b/app-runner/Private/AndroidHelpers.ps1 new file mode 100644 index 0000000..c67c67b --- /dev/null +++ b/app-runner/Private/AndroidHelpers.ps1 @@ -0,0 +1,188 @@ +# Android Helper Functions +# Shared utilities for Android device providers (ADB and SauceLabs) + +<# +.SYNOPSIS +Converts an Android activity path into package name and activity name components. + +.DESCRIPTION +Converts the ExecutablePath format used by Android apps: "package.name/activity.name" +Returns a hashtable with PackageName and ActivityName properties. + +.PARAMETER ExecutablePath +The full activity path in format "package.name/activity.name" + +.EXAMPLE +ConvertFrom-AndroidActivityPath "io.sentry.unreal.sample/com.epicgames.unreal.GameActivity" +Returns: @{ PackageName = "io.sentry.unreal.sample"; ActivityName = "com.epicgames.unreal.GameActivity" } + +.EXAMPLE +ConvertFrom-AndroidActivityPath "com.example.app/.MainActivity" +Returns: @{ PackageName = "com.example.app"; ActivityName = ".MainActivity" } +#> +function ConvertFrom-AndroidActivityPath { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath + ) + + if ($ExecutablePath -notmatch '^([^/]+)/(.+)$') { + throw "ExecutablePath must be in format 'package.name/activity.name'. Got: $ExecutablePath" + } + + return @{ + PackageName = $matches[1] + ActivityName = $matches[2] + } +} + +<# +.SYNOPSIS +Validates that Android Intent extras are in the correct 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 + +.PARAMETER Arguments +The arguments string to validate/format + +.EXAMPLE +Test-IntentExtrasFormat "-e cmdline -crash-capture" +Returns: $true + +.EXAMPLE +Test-IntentExtrasFormat "-e test true -ez debug false" +Returns: $true +#> +function Test-IntentExtrasFormat { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$Arguments + ) + + if ([string]::IsNullOrWhiteSpace($Arguments)) { + 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." + } + + return $true +} + +<# +.SYNOPSIS +Extracts the package name from an APK file using aapt. + +.DESCRIPTION +Extracts the real package name from an APK file using aapt (Android Asset Packaging Tool). +Requires aapt or aapt2 to be available in PATH. + +.PARAMETER ApkPath +Path to the APK file + +.EXAMPLE +Get-ApkPackageName "MyApp.apk" +Returns: "com.example.myapp" (actual package name from AndroidManifest.xml) + +.EXAMPLE +Get-ApkPackageName "SentryPlayground.apk" +Returns: "io.sentry.sample" + +.NOTES +Requires aapt or aapt2 to be in PATH or Android SDK to be installed. +Throws an error if aapt is not available or if the package name cannot be extracted. +#> +function Get-ApkPackageName { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ApkPath + ) + + if (-not (Test-Path $ApkPath)) { + throw "APK file not found: $ApkPath" + } + + if ($ApkPath -notlike '*.apk') { + throw "File must be an .apk file. Got: $ApkPath" + } + + # Find aapt or aapt2 + $aaptCmd = Get-Command aapt -ErrorAction SilentlyContinue + if (-not $aaptCmd) { + $aaptCmd = Get-Command aapt2 -ErrorAction SilentlyContinue + } + + if (-not $aaptCmd) { + throw "aapt or aapt2 not found in PATH. Please install Android SDK Build Tools and ensure aapt is available in PATH." + } + + Write-Debug "Using $($aaptCmd.Name) to extract package name from APK" + + try { + $PSNativeCommandUseErrorActionPreference = $false + $output = & $aaptCmd.Name dump badging $ApkPath 2>&1 + + # Parse output for package name: package: name='com.example.app' + foreach ($line in $output) { + if ($line -match "package:\s+name='([^']+)'") { + $packageName = $matches[1] + Write-Debug "Extracted package name: $packageName" + return $packageName + } + } + + throw "Failed to extract package name from APK using aapt. APK may be corrupted or invalid." + } + finally { + $PSNativeCommandUseErrorActionPreference = $true + } +} + +<# +.SYNOPSIS +Parses logcat output into structured format. + +.DESCRIPTION +Converts raw logcat output (array of strings) into a consistent format +that can be used by test utilities like Get-EventIds. + +.PARAMETER LogcatOutput +Array of logcat log lines (raw output from adb or SauceLabs) + +.EXAMPLE +$logs = adb -s emulator-5554 logcat -d +Format-LogcatOutput $logs +#> +function Format-LogcatOutput { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [object[]]$LogcatOutput + ) + + if ($null -eq $LogcatOutput -or $LogcatOutput.Count -eq 0) { + return @() + } + + # Ensure output is an array of strings + return @($LogcatOutput | ForEach-Object { + if ($null -ne $_) { + $_.ToString() + } + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} diff --git a/app-runner/Private/DeviceProviders/AdbProvider.ps1 b/app-runner/Private/DeviceProviders/AdbProvider.ps1 new file mode 100644 index 0000000..5b53c82 --- /dev/null +++ b/app-runner/Private/DeviceProviders/AdbProvider.ps1 @@ -0,0 +1,513 @@ +# Android ADB Provider Implementation +# Provides device management for Android devices/emulators via ADB + +# Load the base provider +. "$PSScriptRoot\DeviceProvider.ps1" + +# Load Android helpers +. "$PSScriptRoot\..\AndroidHelpers.ps1" + +<# +.SYNOPSIS +Device provider for Android devices and emulators via ADB (Android Debug Bridge). + +.DESCRIPTION +This provider implements Android-specific device operations using ADB commands. +It supports both physical devices connected via USB and emulators running locally. + +Key features: +- Auto-discovery of connected devices +- APK installation with automatic cleanup of previous versions +- App execution with logcat monitoring +- Screenshot capture and file transfer +- Device status and diagnostics + +Requirements: +- ADB (Android Debug Bridge) must be installed and in PATH +- Android device or emulator connected and visible via 'adb devices' +- USB debugging enabled on physical devices +#> +class AdbProvider : DeviceProvider { + [string]$DeviceSerial = $null + [string]$CurrentPackageName = $null # Track current app package for monitoring + + AdbProvider() { + $this.Platform = 'Adb' + + # ADB should be in PATH - no SDK path needed + $this.SdkPath = $null + + # Configure ADB commands + # Format: 'action' = @('tool', 'arguments', optional-processing-scriptblock) + $this.Commands = @{ + # Device management + 'list-devices' = @('adb', 'devices') + 'getstatus' = @('adb', '-s {0} shell getprop') + 'reboot' = @('adb', '-s {0} reboot') + + # Package management + 'list-packages' = @('adb', '-s {0} shell pm list packages') + 'install' = @('adb', '-s {0} install {1}') + 'uninstall' = @('adb', '-s {0} uninstall {1}') + + # App execution + 'launch' = @('adb', '-s {0} shell am start -n {1} {2} -W') + 'pidof' = @('adb', '-s {0} shell pidof {1}') + + # Logging + 'logcat' = @('adb', '-s {0} logcat -d') + 'logcat-pid' = @('adb', '-s {0} logcat -d --pid={1}') + 'logcat-clear' = @('adb', '-s {0} logcat -c') + + # Diagnostics + 'screenshot' = @('adb', '-s {0} shell screencap -p {1}') + 'pull' = @('adb', '-s {0} pull {1} {2}') + 'rm' = @('adb', '-s {0} shell rm {1}') + 'ping' = @('adb', '-s {0} shell ping -c 1 {1}') + 'ps' = @('adb', '-s {0} shell ps') + } + + # Configure timeouts for slow operations + $this.Timeouts = @{ + 'launch' = 180 # App launch can take time on slower devices + 'install' = 300 # APK installation can be slow for large apps + 'run-timeout' = 300 # Maximum time to wait for app execution + 'pid-retry' = 30 # Time to wait for process ID to appear + 'process-check-interval' = 2 # Interval between process status checks + } + } + + [hashtable] Connect() { + Write-Debug "$($this.Platform): Auto-discovering connected device" + + # Get list of connected devices + $output = $this.InvokeCommand('list-devices', @()) + + # Parse 'adb devices' output + # Format: "device_serial\tdevice" (skip header line) + # Wrap in @() to ensure array type even with single device + $devices = @($output | Where-Object { $_ -match '\tdevice$' } | ForEach-Object { ($_ -split '\t')[0] }) + + if ($null -eq $devices -or $devices.Count -eq 0) { + throw "No Android devices found. Ensure a device or emulator is connected and visible via 'adb devices'" + } + + if ($devices.Count -gt 1) { + Write-Warning "$($this.Platform): Multiple devices found, using first one: $($devices[0])" + } + + $this.DeviceSerial = $devices[0] + Write-Debug "$($this.Platform): Connected to device: $($this.DeviceSerial)" + + return $this.CreateSessionInfo() + } + + [hashtable] Connect([string]$target) { + # If no target specified, fall back to auto-discovery + if ([string]::IsNullOrEmpty($target)) { + Write-Debug "$($this.Platform): No target specified, falling back to auto-discovery" + return $this.Connect() + } + + Write-Debug "$($this.Platform): Connecting to specific device: $target" + + # Validate that the specified device exists + $output = $this.InvokeCommand('list-devices', @()) + # Wrap in @() to ensure array type even with single device + $devices = @($output | Where-Object { $_ -match '\tdevice$' } | ForEach-Object { ($_ -split '\t')[0] }) + + if ($devices -notcontains $target) { + throw "Device '$target' not found. Available devices: $($devices -join ', ')" + } + + $this.DeviceSerial = $target + Write-Debug "$($this.Platform): Connected to device: $($this.DeviceSerial)" + + return $this.CreateSessionInfo() + } + + [void] Disconnect() { + Write-Debug "$($this.Platform): Disconnecting (no-op for ADB)" + $this.DeviceSerial = $null + $this.CurrentPackageName = $null + } + + [bool] TestConnection() { + Write-Debug "$($this.Platform): Testing connection to device" + + try { + $this.InvokeCommand('getstatus', @($this.DeviceSerial)) + return $true + } + catch { + return $false + } + } + + [hashtable] InstallApp([string]$PackagePath) { + Write-Debug "$($this.Platform): Installing APK: $PackagePath" + + # Validate APK file + if (-not (Test-Path $PackagePath)) { + throw "APK file not found: $PackagePath" + } + + if ($PackagePath -notlike '*.apk') { + throw "Package must be an .apk file. Got: $PackagePath" + } + + # Extract actual package name from APK + $packageName = Get-ApkPackageName -ApkPath $PackagePath + + # Check for existing installation + Write-Debug "$($this.Platform): Checking for existing package: $packageName" + $listOutput = $this.InvokeCommand('list-packages', @($this.DeviceSerial)) + $existingPackage = $listOutput | Where-Object { $_ -eq "package:$packageName" } | Select-Object -First 1 + + if ($existingPackage) { + Write-Host "Uninstalling previous version: $packageName" -ForegroundColor Yellow + + try { + $this.InvokeCommand('uninstall', @($this.DeviceSerial, $packageName)) + } + catch { + Write-Warning "Failed to uninstall previous version: $_" + } + + Start-Sleep -Seconds 1 + } + + # Install APK + Write-Host "Installing APK to device: $($this.DeviceSerial)" -ForegroundColor Yellow + $installOutput = $this.InvokeCommand('install', @($this.DeviceSerial, $PackagePath)) + + # Verify installation + # Join output to string first since -match on arrays returns matching elements, not boolean + if (($installOutput -join "`n") -notmatch 'Success') { + throw "Failed to install APK. Output: $($installOutput -join "`n")" + } + + Write-Host "APK installed successfully" -ForegroundColor Green + + return @{ + PackagePath = $PackagePath + DeviceSerial = $this.DeviceSerial + } + } + + [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + Write-Debug "$($this.Platform): Running application: $ExecutablePath" + + # Parse ExecutablePath: "package.name/activity.name" + $parsed = ConvertFrom-AndroidActivityPath -ExecutablePath $ExecutablePath + $packageName = $parsed.PackageName + $activityName = $parsed.ActivityName + $this.CurrentPackageName = $packageName + + # Validate Intent extras format + if ($Arguments) { + Test-IntentExtrasFormat -Arguments $Arguments | Out-Null + } + + $timeoutSeconds = $this.Timeouts['run-timeout'] + $pidRetrySeconds = $this.Timeouts['pid-retry'] + $processCheckIntervalSeconds = $this.Timeouts['process-check-interval'] + + $startTime = Get-Date + + # Clear logcat before launch + Write-Debug "$($this.Platform): Clearing logcat" + $this.InvokeCommand('logcat-clear', @($this.DeviceSerial)) + + # Launch activity + Write-Host "Launching: $ExecutablePath" -ForegroundColor Cyan + if ($Arguments) { + Write-Host " Arguments: $Arguments" -ForegroundColor Cyan + } + + $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) + + # Initialize log cache + [array]$logCache = @() + + if (-not $appPID) { + # App might have already exited (fast execution) - capture logs anyway + Write-Warning "Could not find process ID (app may have exited quickly)" -ForegroundColor Yellow + $logCache = @($this.InvokeCommand('logcat', @($this.DeviceSerial))) + $exitCode = 0 + } + else { + Write-Host "App 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 + + 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 + break + } + + Start-Sleep -Seconds $processCheckIntervalSeconds + } + + if (-not $processExited) { + Write-Host "Warning: Process did not exit within timeout" -ForegroundColor Yellow + } + + # Fetch all logs after app completes + Write-Host "Retrieving logs..." -ForegroundColor Yellow + $logCache = @($this.InvokeCommand('logcat-pid', @($this.DeviceSerial, $appPID))) + Write-Host "Retrieved $($logCache.Count) log lines" -ForegroundColor Cyan + + Write-Host "Android doesn't report exit codes via adb so exit code is always NULL" + $exitCode = $null + } + + # Format logs consistently + $formattedLogs = Format-LogcatOutput -LogcatOutput $logCache + + # Return result matching app-runner pattern + return @{ + Platform = $this.Platform + ExecutablePath = $ExecutablePath + Arguments = $Arguments + StartedAt = $startTime + FinishedAt = Get-Date + Output = $formattedLogs + ExitCode = $exitCode + } + } + + # Helper method to wait for process with retry + [string] WaitForProcess([string]$packageName, [int]$timeoutSeconds) { + Write-Debug "$($this.Platform): Waiting for process: $packageName" + + for ($i = 0; $i -lt $timeoutSeconds; $i++) { + try { + $pidOutput = $this.InvokeCommand('pidof', @($this.DeviceSerial, $packageName)) + + if ($pidOutput) { + $processId = "$pidOutput".ToString().Trim() + if ($processId -match '^\d+$') { + return $processId + } + else { + Write-Warning "Unexpected pidof output: $processId" + } + } + } + catch { + # Process not found yet, continue waiting + Write-Debug "$($this.Platform): Process not found yet (attempt $i/$timeoutSeconds)" + } + + Start-Sleep -Seconds 1 + } + + return $null + } + + [hashtable] GetDeviceLogs([string]$LogType, [int]$MaxEntries) { + Write-Debug "$($this.Platform): Getting device logs (type: $LogType, max: $MaxEntries)" + + $logs = $this.InvokeCommand('logcat', @($this.DeviceSerial)) + $formattedLogs = Format-LogcatOutput -LogcatOutput $logs + + if ($MaxEntries -gt 0) { + $formattedLogs = $formattedLogs | Select-Object -Last $MaxEntries + } + + return @{ + Platform = $this.Platform + LogType = $LogType + Logs = $formattedLogs + Count = $formattedLogs.Count + Timestamp = Get-Date + } + } + + [void] TakeScreenshot([string]$OutputPath) { + Write-Debug "$($this.Platform): Taking screenshot to: $OutputPath" + + # Use intermediate file on device to avoid binary data corruption in stdout capture + $tempDevicePath = "/sdcard/temp_screenshot_$([Guid]::NewGuid()).png" + + try { + # Capture to temp file on device + $this.InvokeCommand('screenshot', @($this.DeviceSerial, $tempDevicePath)) + + # Copy file from device (handles directory creation) + $this.CopyDeviceItem($tempDevicePath, $OutputPath) + + $size = (Get-Item $OutputPath).Length + Write-Debug "$($this.Platform): Screenshot saved ($size bytes)" + } + finally { + # Clean up temp file on device + try { + $this.InvokeCommand('rm', @($this.DeviceSerial, $tempDevicePath)) + } + catch { + Write-Warning "Failed to cleanup temp screenshot file: $_" + } + } + } + + [void] CopyDeviceItem([string]$DevicePath, [string]$Destination) { + Write-Debug "$($this.Platform): Copying from device: $DevicePath -> $Destination" + + # Ensure destination directory exists + $destDir = Split-Path $Destination -Parent + if ($destDir -and -not (Test-Path $destDir)) { + New-Item -Path $destDir -ItemType Directory -Force | Out-Null + } + + $this.InvokeCommand('pull', @($this.DeviceSerial, $DevicePath, $Destination)) + } + + [hashtable] GetDeviceStatus() { + Write-Debug "$($this.Platform): Getting device status" + + $props = $this.InvokeCommand('getstatus', @($this.DeviceSerial)) + + # Parse key properties + $statusData = @{} + foreach ($line in $props) { + if ($line -match '^\[(.+?)\]:\s*\[(.+?)\]$') { + $statusData[$matches[1]] = $matches[2] + } + } + + return @{ + Platform = $this.Platform + Status = 'Online' + StatusData = $statusData + Timestamp = Get-Date + } + } + + [string] GetDeviceIdentifier() { + return $this.DeviceSerial + } + + [void] StartDevice() { + Write-Warning "$($this.Platform): StartDevice is not supported for ADB devices" + } + + [void] StopDevice() { + Write-Warning "$($this.Platform): StopDevice is not supported for ADB devices" + } + + [void] RestartDevice() { + Write-Debug "$($this.Platform): Restarting device" + + $this.InvokeCommand('reboot', @($this.DeviceSerial)) + + # Wait for device to come back online + Write-Host "Waiting for device to reboot..." -ForegroundColor Yellow + Start-Sleep -Seconds 30 + + $maxWait = 120 + $waited = 0 + $isReady = $false + + while ($waited -lt $maxWait) { + try { + if ($this.TestConnection()) { + $isReady = $true + Write-Host "Device is ready after $waited seconds" -ForegroundColor Green + break + } + } + catch { + # Device not ready yet + } + + Start-Sleep -Seconds 5 + $waited += 5 + Write-Debug "$($this.Platform): Waiting for device ($waited/$maxWait seconds)" + } + + if (-not $isReady) { + throw "Device did not come back online after $maxWait seconds" + } + } + + [bool] TestInternetConnection() { + Write-Debug "$($this.Platform): Testing internet connection" + + try { + # Ping a reliable server (Google DNS) + $output = $this.InvokeCommand('ping', @($this.DeviceSerial, '8.8.8.8')) + # Join output to string first since -match on arrays returns matching elements, not boolean + return ($output -join "`n") -match '1 packets transmitted, 1 received' + } + catch { + return $false + } + } + + # Override GetRunningProcesses to provide Android process list + [object] GetRunningProcesses() { + Write-Debug "$($this.Platform): Getting running processes" + + try { + $output = $this.InvokeCommand('ps', @($this.DeviceSerial)) + + # Parse ps output + # Format varies by Android version but typically: USER PID PPID ... NAME + $processes = @() + foreach ($line in $output) { + # Skip header line + if ($line -match '^\s*USER' -or $line -match '^\s*PID') { + continue + } + + # Basic parsing - extract PID and process name + if ($line -match '\s+(\d+)\s+.*\s+([^\s]+)\s*$') { + $processes += [PSCustomObject]@{ + Id = [int]$matches[1] + Name = $matches[2] + } + } + } + + return $processes + } + catch { + Write-Warning "$($this.Platform): Failed to get process list: $_" + return @() + } + } + + # Override DetectAndSetDefaultTarget - not needed for ADB + [void] DetectAndSetDefaultTarget() { + Write-Debug "$($this.Platform): Target detection not needed for ADB" + # No-op: Device auto-discovery happens in Connect() + } +} diff --git a/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 b/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 index 8f034c1..b9bbcbe 100644 --- a/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 +++ b/app-runner/Private/DeviceProviders/DeviceProviderFactory.ps1 @@ -21,11 +21,14 @@ class DeviceProviderFactory { # Use PowerShell automatic variables to detect OS if ($global:IsWindows) { return 'Windows' - } elseif ($global:IsMacOS) { + } + elseif ($global:IsMacOS) { return 'MacOS' - } elseif ($global:IsLinux) { + } + elseif ($global:IsLinux) { return 'Linux' - } else { + } + else { throw "Unable to detect local platform. Platform is not Windows, MacOS, or Linux." } } @@ -74,12 +77,24 @@ class DeviceProviderFactory { Write-Debug "DeviceProviderFactory: Creating LinuxProvider" return [LinuxProvider]::new() } + "Adb" { + Write-Debug "DeviceProviderFactory: Creating AdbProvider" + return [AdbProvider]::new() + } + "AndroidSauceLabs" { + Write-Debug "DeviceProviderFactory: Creating SauceLabsProvider (Android)" + return [SauceLabsProvider]::new('Android') + } + "iOSSauceLabs" { + Write-Debug "DeviceProviderFactory: Creating SauceLabsProvider (iOS)" + return [SauceLabsProvider]::new('iOS') + } "Mock" { Write-Debug "DeviceProviderFactory: Creating MockDeviceProvider" return [MockDeviceProvider]::new() } default { - $errorMessage = "Unsupported platform: $Platform. Supported platforms: Xbox, PlayStation5, Switch, Windows, MacOS, Linux, Local, Mock" + $errorMessage = "Unsupported platform: $Platform. Supported platforms: Xbox, PlayStation5, Switch, Windows, MacOS, Linux, Adb, AndroidSauceLabs, iOSSauceLabs, Local, Mock" Write-Error "DeviceProviderFactory: $errorMessage" throw $errorMessage } @@ -97,7 +112,7 @@ class DeviceProviderFactory { An array of supported platform names. #> static [string[]] GetSupportedPlatforms() { - return @("Xbox", "PlayStation5", "Switch", "Windows", "MacOS", "Linux", "Local", "Mock") + return @("Xbox", "PlayStation5", "Switch", "Windows", "MacOS", "Linux", "Adb", "AndroidSauceLabs", "iOSSauceLabs", "Local", "Mock") } <# diff --git a/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 new file mode 100644 index 0000000..029dc6c --- /dev/null +++ b/app-runner/Private/DeviceProviders/SauceLabsProvider.ps1 @@ -0,0 +1,599 @@ +# SauceLabs Provider Implementation +# Provides device management for devices on SauceLabs Real Device Cloud (Android/iOS) + +# Load the base provider +. "$PSScriptRoot\DeviceProvider.ps1" + +# Load Android helpers (conditionally needed, but safe to load) +. "$PSScriptRoot\..\AndroidHelpers.ps1" + +<# +.SYNOPSIS +Device provider for mobile devices on SauceLabs Real Device Cloud. + +.DESCRIPTION +This provider implements device operations using SauceLabs Appium API. +It supports testing on real Android and iOS devices in the SauceLabs cloud infrastructure. + +Key features: +- App upload to SauceLabs storage +- Appium session management (create, reuse, delete) +- App execution with state monitoring +- Logcat/Syslog retrieval via Appium +- Screenshot capture + +Requirements: +- SauceLabs account with Real Device Cloud access +- Environment variables: + - SAUCE_USERNAME - SauceLabs username + - SAUCE_ACCESS_KEY - SauceLabs access key + - SAUCE_REGION - SauceLabs region (e.g., us-west-1, eu-central-1) + - SAUCE_DEVICE_NAME - Device name (optional if using -Target parameter) + - SAUCE_SESSION_NAME - Session name for SauceLabs dashboard (optional, defaults to "App Runner Test") + +Note: Device name must match a device available in the specified region. + +Example usage: + # Option 1: Use SAUCE_DEVICE_NAME environment variable + Connect-Device -Platform AndroidSauceLabs + + # Option 2: Explicitly specify device name + Connect-Device -Platform AndroidSauceLabs -Target "Samsung_Galaxy_S23_15_real_sjc1" +#> +class SauceLabsProvider : DeviceProvider { + [string]$SessionId = $null + [string]$StorageId = $null + [string]$Region = $null + [string]$DeviceName = $null + [string]$Username = $null + [string]$AccessKey = $null + [string]$SessionName = $null + [string]$CurrentPackageName = $null + [string]$MobilePlatform = $null # 'Android' or 'iOS' + + SauceLabsProvider([string]$MobilePlatform) { + if ($MobilePlatform -notin @('Android', 'iOS')) { + throw "SauceLabsProvider: Unsupported mobile platform '$MobilePlatform'. Must be 'Android' or 'iOS'." + } + $this.MobilePlatform = $MobilePlatform + $this.Platform = "${MobilePlatform}SauceLabs" + + # Read credentials and configuration from environment + $this.Username = $env:SAUCE_USERNAME + $this.AccessKey = $env:SAUCE_ACCESS_KEY + $this.Region = $env:SAUCE_REGION + $this.DeviceName = $env:SAUCE_DEVICE_NAME # Optional: can be overridden via -Target + + # Read optional session name (with default) + $this.SessionName = if ($env:SAUCE_SESSION_NAME) { + $env:SAUCE_SESSION_NAME + } + else { + "App Runner $MobilePlatform Test" + } + + # Validate required credentials + if (-not $this.Username -or -not $this.AccessKey) { + throw "SAUCE_USERNAME and SAUCE_ACCESS_KEY environment variables must be set" + } + + if (-not $this.Region) { + throw "SAUCE_REGION environment variable must be set" + } + + # DeviceName is optional here - will be validated in Connect() + # Can come from either SAUCE_DEVICE_NAME env var or -Target parameter + + # No SDK path needed - uses HTTP API + $this.SdkPath = $null + + # Configure timeouts for cloud operations + $this.Timeouts = @{ + 'upload' = 600 # App upload can take time + 'session' = 300 # Session creation + 'launch' = 300 # App launch on cloud device + 'run-timeout' = 300 # Maximum time to wait for app execution + 'poll-interval' = 2 # Interval between app state checks + } + } + + <# + .SYNOPSIS + Invokes SauceLabs API requests with authentication. + + .DESCRIPTION + Helper method for making authenticated HTTP requests to SauceLabs API. + Follows app-runner pattern: Invoke-WebRequest + explicit ConvertFrom-Json. + #> + [hashtable] InvokeSauceLabsApi([string]$Method, [string]$Uri, [hashtable]$Body, [bool]$IsMultipart, [string]$FilePath) { + if (-not $this.Username -or -not $this.AccessKey) { + throw "SauceLabs credentials not set" + } + + $base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("$($this.Username):$($this.AccessKey)")) + $headers = @{ + 'Authorization' = "Basic $base64Auth" + } + + try { + if ($IsMultipart) { + # Use -Form parameter for multipart uploads (PowerShell Core 7+) + $form = @{ + payload = Get-Item -Path $FilePath + name = (Split-Path $FilePath -Leaf) + } + $webResponse = Invoke-WebRequest -Uri $Uri -Method $Method -Headers $headers -Form $form + } + else { + $params = @{ + Uri = $Uri + Method = $Method + Headers = $headers + } + + if ($Body) { + $params['Body'] = ($Body | ConvertTo-Json -Depth 10) + $params['ContentType'] = 'application/json' + } + + $webResponse = Invoke-WebRequest @params + } + + # Explicit JSON parsing for better error visibility + if ($webResponse.Content) { + return $webResponse.Content | ConvertFrom-Json -AsHashtable + } + return $null + } + catch { + $ErrorMessage = "SauceLabs API request ($Method $Uri) failed: $($_.Exception.Message)" + if ($_.Exception.Response) { + $StatusCode = $_.Exception.Response.StatusCode + $ErrorMessage += " (Status: $StatusCode)" + } + throw $ErrorMessage + } + } + + [hashtable] Connect() { + throw 'Connect() requires a target device name for SauceLabsProvider. Use Connect($target) instead.' + } + + [hashtable] Connect([string]$target) { + # If no target specified, fall back to SAUCE_DEVICE_NAME env var + if ([string]::IsNullOrEmpty($target)) { + $target = $env:SAUCE_DEVICE_NAME + if ([string]::IsNullOrEmpty($target)) { + throw "$($this.Platform) requires a device name. Set SAUCE_DEVICE_NAME environment variable or use Connect-Device -Platform $($this.Platform) -Target 'DeviceName'" + } + Write-Debug "$($this.Platform): Connecting with device name from env: $($this.DeviceName)" + } else { + Write-Debug "$($this.Platform): Connecting with explicit device name: $target" + } + + # Store the device name for session creation + $this.DeviceName = $target + + # Note: App upload and session creation happen in InstallApp + # because we need the App path before creating an Appium session + + return @{ + Provider = $this + Platform = $this.Platform + ConnectedAt = Get-Date + Identifier = $target + IsConnected = $true + StatusData = @{ + DeviceName = $target + Region = $this.Region + Username = $this.Username + } + } + } + + [void] Disconnect() { + Write-Debug "$($this.Platform): Disconnecting" + + if ($this.SessionId) { + Write-Host "Ending SauceLabs session..." -ForegroundColor Yellow + try { + $sessionUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" + $this.InvokeSauceLabsApi('DELETE', $sessionUri, $null, $false, $null) + Write-Host "Session ended successfully" -ForegroundColor Green + } + catch { + Write-Warning "Failed to end session: $_" + } + $this.SessionId = $null + } + + $this.StorageId = $null + $this.DeviceName = $null + $this.CurrentPackageName = $null + } + + [bool] TestConnection() { + Write-Debug "$($this.Platform): Testing connection" + + # Check if we have valid credentials and session + if ($this.SessionId) { + try { + $baseUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" + $response = $this.InvokeSauceLabsApi('GET', $baseUri, $null, $false, $null) + return $null -ne $response + } + catch { + return $false + } + } + + return $null -ne $this.Username -and $null -ne $this.AccessKey + } + + [hashtable] InstallApp([string]$PackagePath) { + Write-Debug "$($this.Platform): Installing App: $PackagePath" + + # Validate App file + if (-not (Test-Path $PackagePath)) { + throw "App file not found: $PackagePath" + } + + $extension = [System.IO.Path]::GetExtension($PackagePath).ToLower() + if ($this.MobilePlatform -eq 'Android' -and $extension -ne '.apk') { + throw "Package must be an .apk file for Android. Got: $PackagePath" + } + if ($this.MobilePlatform -eq 'iOS' -and $extension -ne '.ipa') { + throw "Package must be an .ipa file for iOS. Got: $PackagePath" + } + + + # Upload App to SauceLabs Storage + Write-Host "Uploading App to SauceLabs Storage..." -ForegroundColor Yellow + $uploadUri = "https://api.$($this.Region).saucelabs.com/v1/storage/upload" + + $uploadResponse = $this.InvokeSauceLabsApi('POST', $uploadUri, $null, $true, $PackagePath) + + if (-not $uploadResponse.item.id) { + throw "Failed to upload App: No storage ID in response" + } + + $this.StorageId = $uploadResponse.item.id + Write-Host "App uploaded successfully. Storage ID: $($this.StorageId)" -ForegroundColor Green + + # Create Appium session with uploaded App + Write-Host "Creating SauceLabs Appium session..." -ForegroundColor Yellow + $sessionUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session" + + $capabilities = @{ + capabilities = @{ + alwaysMatch = @{ + platformName = $this.MobilePlatform + 'appium:app' = "storage:$($this.StorageId)" + 'appium:deviceName' = $this.DeviceName + 'appium:automationName' = if ($this.MobilePlatform -eq 'Android') { 'UiAutomator2' } else { 'XCUITest' } + 'appium:noReset' = $true + 'appium:autoLaunch' = $false + 'sauce:options' = @{ + name = $this.SessionName + appiumVersion = 'latest' + } + } + } + } + + $sessionResponse = $this.InvokeSauceLabsApi('POST', $sessionUri, $capabilities, $false, $null) + + # Extract session ID (response format varies) + $this.SessionId = $sessionResponse.value.sessionId + if (-not $this.SessionId) { + $this.SessionId = $sessionResponse.sessionId + } + + if (-not $this.SessionId) { + throw "Failed to create session: No session ID in response" + } + + Write-Host "Session created successfully. Session ID: $($this.SessionId)" -ForegroundColor Green + + return @{ + StorageId = $this.StorageId + SessionId = $this.SessionId + PackagePath = $PackagePath + DeviceName = $this.DeviceName + } + } + + [hashtable] RunApplication([string]$ExecutablePath, [string]$Arguments) { + Write-Debug "$($this.Platform): Running application: $ExecutablePath" + + if (-not $this.SessionId) { + throw "No active SauceLabs session. Call InstallApp first to create a session." + } + + $timeoutSeconds = $this.Timeouts['run-timeout'] + $pollIntervalSeconds = $this.Timeouts['poll-interval'] + $startTime = Get-Date + $baseUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" + + if ($this.MobilePlatform -eq 'Android') { + # Parse ExecutablePath: "package.name/activity.name" + $parsed = ConvertFrom-AndroidActivityPath -ExecutablePath $ExecutablePath + $packageName = $parsed.PackageName + $activityName = $parsed.ActivityName + $this.CurrentPackageName = $packageName + + # Validate Intent extras format + if ($Arguments) { + Test-IntentExtrasFormat -Arguments $Arguments | Out-Null + } + + # Launch activity with Intent extras + Write-Host "Launching: $packageName/$activityName" -ForegroundColor Cyan + if ($Arguments) { + Write-Host " Arguments: $Arguments" -ForegroundColor Cyan + } + + $launchBody = @{ + appPackage = $packageName + appActivity = $activityName + appWaitActivity = '*' + intentAction = 'android.intent.action.MAIN' + intentCategory = 'android.intent.category.LAUNCHER' + } + + if ($Arguments) { + $launchBody['optionalIntentArguments'] = $Arguments + } + + try { + $launchResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/appium/device/start_activity", $launchBody, $false, $null) + Write-Debug "Launch response: $($launchResponse | ConvertTo-Json)" + } + catch { + throw "Failed to launch activity: $_" + } + } + elseif ($this.MobilePlatform -eq 'iOS') { + # For iOS, ExecutablePath is typically the Bundle ID + $bundleId = $ExecutablePath + $this.CurrentPackageName = $bundleId + + Write-Host "Launching: $bundleId" -ForegroundColor Cyan + if ($Arguments) { + Write-Host " Arguments: $Arguments" -ForegroundColor Cyan + Write-Warning "Passing arguments to iOS apps via SauceLabs/Appium might require specific app capability configuration." + } + + $launchBody = @{ + bundleId = $bundleId + } + + if ($Arguments) { + # Appium 'mobile: launchApp' supports arguments? + # Or use 'appium:processArguments' capability during session creation? + # For now, we'll try to pass them if supported by the endpoint or warn. + $launchBody['arguments'] = $Arguments -split ' ' # Simple split, might need better parsing + } + + try { + # Use mobile: launchApp for iOS + $scriptBody = @{ + script = "mobile: launchApp" + args = $launchBody + } + $launchResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $scriptBody, $false, $null) + Write-Debug "Launch response: $($launchResponse | ConvertTo-Json)" + } + catch { + throw "Failed to launch app: $_" + } + } + + # Wait a moment for app to start + Start-Sleep -Seconds 3 + + # Monitor app state until it exits (generic approach - no app-specific checking) + Write-Host "Monitoring app execution..." -ForegroundColor Yellow + $completed = $false + + while ((Get-Date) - $startTime -lt [TimeSpan]::FromSeconds($timeoutSeconds)) { + # Query app state using Appium's mobile: queryAppState + $stateBody = @{ + script = 'mobile: queryAppState' + args = @( + @{ appId = $this.CurrentPackageName } # Use stored package/bundle ID + ) + } + + try { + $stateResponse = $this.InvokeSauceLabsApi('POST', "$baseUri/execute/sync", $stateBody, $false, $null) + $appState = $stateResponse.value + + Write-Debug "App state: $appState (elapsed: $([int]((Get-Date) - $startTime).TotalSeconds)s)" + + # State 1 = not running, 0 = not installed (Android) + # iOS: 1 = not running, 0 = unknown/not installed? + # Appium docs: 0 is not installed. 1 is not running. 2 is running in background or suspended. 3 is running in background. 4 is running in foreground. + if ($appState -eq 1 -or $appState -eq 0) { + Write-Host "App finished/crashed (state: $appState)" -ForegroundColor Green + $completed = $true + break + } + } + catch { + Write-Warning "Failed to query app state: $_" + } + + Start-Sleep -Seconds $pollIntervalSeconds + } + + if (-not $completed) { + Write-Host "Warning: App did not exit within timeout" -ForegroundColor Yellow + } + + # Retrieving logs after app completion + 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" + } + } | 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 + } + + # Return result matching app-runner pattern + return @{ + Platform = $this.Platform + ExecutablePath = $ExecutablePath + Arguments = $Arguments + StartedAt = $startTime + FinishedAt = Get-Date + Output = $formattedLogs + ExitCode = 0 # Mobile platforms don't reliably report exit codes here + } + } + + [hashtable] GetDeviceLogs([string]$LogType, [int]$MaxEntries) { + Write-Debug "$($this.Platform): Getting device logs" + + if (-not $this.SessionId) { + throw "No active session" + } + + $baseUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" + + # Default log type based on platform if not specified + if ([string]::IsNullOrEmpty($LogType)) { + $LogType = if ($this.MobilePlatform -eq 'iOS') { 'syslog' } else { 'logcat' } + } + + $logBody = @{ type = $LogType } + $response = $this.InvokeSauceLabsApi('POST', "$baseUri/log", $logBody, $false, $null) + + [array]$logs = @() + if ($response.value -and $response.value.Count -gt 0) { + $logs = @($response.value) + } + + if ($MaxEntries -gt 0) { + $logs = $logs | Select-Object -First $MaxEntries + } + + return @{ + Platform = $this.Platform + LogType = $LogType + Logs = $logs + Count = $logs.Count + Timestamp = Get-Date + } + } + + [void] TakeScreenshot([string]$OutputPath) { + Write-Debug "$($this.Platform): Taking screenshot to: $OutputPath" + + if (-not $this.SessionId) { + throw "No active session" + } + + # Ensure output directory exists + $directory = Split-Path $OutputPath -Parent + if ($directory -and -not (Test-Path $directory)) { + New-Item -Path $directory -ItemType Directory -Force | Out-Null + } + + $baseUri = "https://ondemand.$($this.Region).saucelabs.com/wd/hub/session/$($this.SessionId)" + $response = $this.InvokeSauceLabsApi('GET', "$baseUri/screenshot", $null, $false, $null) + + # Validate response before decoding + if (-not $response) { + throw "$($this.Platform): Screenshot API returned no response" + } + + if (-not $response.value) { + throw "$($this.Platform): Screenshot API response missing 'value' field" + } + + # Response contains base64 encoded PNG + [System.IO.File]::WriteAllBytes($OutputPath, [Convert]::FromBase64String($response.value)) + + $size = (Get-Item $OutputPath).Length + Write-Debug "$($this.Platform): Screenshot saved ($size bytes)" + } + + [hashtable] GetDeviceStatus() { + Write-Debug "$($this.Platform): Getting device status" + + return @{ + Platform = $this.Platform + Status = if ($this.SessionId) { 'Online' } else { 'Disconnected' } + StatusData = @{ + SessionId = $this.SessionId + StorageId = $this.StorageId + DeviceName = $this.DeviceName + Region = $this.Region + } + Timestamp = Get-Date + } + } + + [string] GetDeviceIdentifier() { + if ($this.SessionId) { + return "$($this.DeviceName) (Session: $($this.SessionId))" + } + return $this.DeviceName + } + + [void] StartDevice() { + Write-Warning "$($this.Platform): StartDevice is not applicable for SauceLabs cloud devices" + } + + [void] StopDevice() { + Write-Warning "$($this.Platform): StopDevice is not applicable for SauceLabs cloud devices" + } + + [void] RestartDevice() { + Write-Warning "$($this.Platform): RestartDevice is not applicable for SauceLabs cloud devices" + } + + [bool] TestInternetConnection() { + Write-Debug "$($this.Platform): TestInternetConnection not implemented for SauceLabs" + # Cloud devices always have internet connectivity + return $true + } + + [object] GetRunningProcesses() { + Write-Debug "$($this.Platform): GetRunningProcesses not implemented for SauceLabs" + return @() + } + + [void] CopyDeviceItem([string]$DevicePath, [string]$Destination) { + Write-Warning "$($this.Platform): CopyDeviceItem is not supported for SauceLabs cloud devices" + } + + # Override DetectAndSetDefaultTarget - not needed for SauceLabs + [void] DetectAndSetDefaultTarget() { + Write-Debug "$($this.Platform): Target detection not needed for SauceLabs" + # No-op: Device name is specified via Connect($deviceName) + } +} diff --git a/app-runner/Public/Connect-Device.ps1 b/app-runner/Public/Connect-Device.ps1 index 4240480..9f52b57 100644 --- a/app-runner/Public/Connect-Device.ps1 +++ b/app-runner/Public/Connect-Device.ps1 @@ -41,7 +41,7 @@ function Connect-Device { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('Xbox', 'PlayStation5', 'Switch', 'Windows', 'MacOS', 'Linux', 'Local', 'Mock')] + [ValidateSet('Xbox', 'PlayStation5', 'Switch', 'Windows', 'MacOS', 'Linux', 'Adb', 'AndroidSauceLabs', 'iOSSauceLabs', 'Local', 'Mock')] [string]$Platform, [Parameter(Mandatory = $false)] @@ -64,20 +64,35 @@ function Connect-Device { Disconnect-Device } - # Build resource name for mutex coordination + # Providers with session-based isolation don't need mutex; providers with shared physical/logical devices do. + # The rule of a thumb is: if you can run the same test in parallel multiple times on the same machine + # and same target device and they don't interfere with each other then it's ok to disable mutex. + # Example: installing the same app name to ADB (same device) would uninstall the previous app with the same name - we must use mutex. + # SauceLabs acquire a lock on the server-side (for selected device) so we don't need to handle it here. + # Also note: these mutexes are very fast so err on the side of caution. + $useMutex = $Platform -notmatch 'SauceLabs' + + # Build resource name for mutex coordination (if needed) # Xbox requires platform-level mutex (not per-target) because xb*.exe commands # operate on the "current" target set via xbconnect, which is global to the system. # Multiple processes with different target mutexes would still conflict. - $mutexTarget = if ($Platform -eq 'Xbox') { $null } else { $Target } - $resourceName = New-DeviceResourceName -Platform $Platform -Target $mutexTarget - Write-Debug "Device resource name: $resourceName" - - # Acquire exclusive access to the device resource - # Default 60-minute timeout with progress messages every minute $mutex = $null + $resourceName = $null + + if ($useMutex) { + $mutexTarget = if ($Platform -eq 'Xbox') { $null } else { $Target } + $resourceName = New-DeviceResourceName -Platform $Platform -Target $mutexTarget + Write-Debug "Device resource name: $resourceName" + } else { + Write-Debug "Skipping mutex for platform: $Platform" + } + try { - $mutex = Request-DeviceAccess -ResourceName $resourceName -TimeoutSeconds $TimeoutSeconds -ProgressIntervalSeconds 60 - Write-Output "Acquired exclusive access to device: $resourceName" + # Acquire exclusive access to the device resource (if needed) + if ($useMutex) { + $mutex = Request-DeviceAccess -ResourceName $resourceName -TimeoutSeconds $TimeoutSeconds -ProgressIntervalSeconds 60 + Write-Output "Acquired exclusive access to device: $resourceName" + } # Create provider for the specified platform $provider = [DeviceProviderFactory]::CreateProvider($Platform) diff --git a/app-runner/README.md b/app-runner/README.md index 2cdd1a2..10148f8 100644 --- a/app-runner/README.md +++ b/app-runner/README.md @@ -47,6 +47,32 @@ Get-DeviceDiagnostics -OutputDirectory "./diagnostics" Disconnect-Device ``` +### Android Platform Example + +```powershell +# Connect to Android device via ADB (auto-discovers connected devices) +Connect-Device -Platform "Adb" + +# Or connect to specific device serial +Connect-Device -Platform "Adb" -Target "emulator-5554" + +# Or use SauceLabs Real Device Cloud +Connect-Device -Platform "AndroidSauceLabs" + +# Install APK +Install-DeviceApp -Path "MyApp.apk" + +# Run Android app using package/activity format +Invoke-DeviceApp -ExecutablePath "com.example.app/.MainActivity" -Arguments "-e test_mode true" + +# Collect diagnostics +Get-DeviceScreenshot -OutputPath "screenshot.png" +Get-DeviceLogs -LogType "All" -MaxEntries 1000 + +# Disconnect +Disconnect-Device +``` + ## Supported Platforms ### Gaming Consoles @@ -55,13 +81,24 @@ Disconnect-Device - **PlayStation5** - PS5 development kits - **Switch** - Nintendo Switch development units +### Mobile Platforms + +- **Adb** - Android devices and emulators via Android Debug Bridge +- **AndroidSauceLabs** - Android devices on SauceLabs Real Device Cloud +- **iOSSauceLabs** - iOS devices on SauceLabs Real Device Cloud (coming soon) + ### Desktop Platforms - **Windows** - Local Windows machines - **MacOS** - Local macOS machines - **Linux** - Local Linux machines -Note: Desktop platforms execute applications locally on the same machine running the module. Device lifecycle operations (power on/off, reboot) are not supported for desktop platforms. +**Notes:** +- Desktop platforms execute applications locally on the same machine running the module. Device lifecycle operations (power on/off, reboot) are not supported for desktop platforms. +- Mobile platforms require separate installation and execution steps: + - Use `Install-DeviceApp "MyApp.apk"` to install APK files + - Use `Invoke-DeviceApp "package.name/.ActivityName"` to run installed apps + - Android Intent extras should be passed as Arguments in the format: `-e key value` or `-ez key true/false` ## Functions @@ -145,6 +182,18 @@ Connect-Device -Platform "Xbox" -TimeoutSeconds 300 # 5 minutes - PlayStation 5: Prospero SDK (`$env:SCE_ROOT_DIR`) - Switch: Nintendo SDK (`$env:NINTENDO_SDK_ROOT`) +### Mobile Platform Requirements + +**Android (ADB):** +- Android SDK with ADB (Android Debug Bridge) in PATH +- USB debugging enabled on physical devices +- Device connected via USB or emulator running locally + +**Android/iOS (SauceLabs):** +- SauceLabs account with Real Device Cloud access +- Environment variables: `SAUCE_USERNAME`, `SAUCE_ACCESS_KEY`, `SAUCE_REGION` +- Valid SauceLabs device ID or capabilities for device selection + ### Desktop Platform Requirements - **Windows:** PowerShell 5.0+ (included with Windows) diff --git a/app-runner/SentryAppRunner.psm1 b/app-runner/SentryAppRunner.psm1 index 68140da..a910624 100644 --- a/app-runner/SentryAppRunner.psm1 +++ b/app-runner/SentryAppRunner.psm1 @@ -1,6 +1,9 @@ $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true +# Import Android helpers first (used by Android providers) +. "$PSScriptRoot\Private\AndroidHelpers.ps1" + # Import device providers in the correct order (base provider first, then implementations, then factory) $ProviderFiles = @( "$PSScriptRoot\Private\DeviceProviders\DeviceProvider.ps1", @@ -11,6 +14,8 @@ $ProviderFiles = @( "$PSScriptRoot\Private\DeviceProviders\WindowsProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\MacOSProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\LinuxProvider.ps1", + "$PSScriptRoot\Private\DeviceProviders\AdbProvider.ps1", + "$PSScriptRoot\Private\DeviceProviders\SauceLabsProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\MockDeviceProvider.ps1", "$PSScriptRoot\Private\DeviceProviders\DeviceProviderFactory.ps1" ) diff --git a/app-runner/Tests/Adb.Tests.ps1 b/app-runner/Tests/Adb.Tests.ps1 new file mode 100644 index 0000000..c049a1a --- /dev/null +++ b/app-runner/Tests/Adb.Tests.ps1 @@ -0,0 +1,187 @@ +$ErrorActionPreference = 'Stop' + +BeforeDiscovery { + # Define test targets + function Get-TestTarget { + param( + [string]$Platform, + [string]$Target + ) + + $TargetName = if ($Target) { + "$Platform-$Target" + } + else { + $Platform + } + + return @{ + Platform = $Platform + Target = $Target + TargetName = $TargetName + } + } + + $TestTargets = @() + + # Detect if running in CI environment + $isCI = $env:CI -eq 'true' + + # Check for ADB availability for AdbProvider + if (Get-Command 'adb' -ErrorAction SilentlyContinue) { + # Check if any devices are connected + $adbDevices = adb devices + if ($adbDevices -match '\tdevice$') { + $TestTargets += Get-TestTarget -Platform 'Adb' + } + else { + $message = "No Android devices connected via ADB" + if ($isCI) { + throw "$message. This is required in CI." + } + else { + Write-Warning "$message. AdbProvider tests will be skipped." + } + } + } + else { + $message = "ADB not found in PATH" + if ($isCI) { + throw "$message. This is required in CI." + } + else { + Write-Warning "$message. AdbProvider tests will be skipped." + } + } +} + +BeforeAll { + # Import the module + Import-Module "$PSScriptRoot\..\SentryAppRunner.psm1" -Force + + # Helper function for cleanup + function Invoke-TestCleanup { + try { + if (Get-DeviceSession) { + Disconnect-Device + } + } + catch { + # Ignore cleanup errors + Write-Debug "Cleanup failed: $_" + } + } +} + +Describe '' -Tag 'Adb' -ForEach $TestTargets { + Context 'Device Connection Management' -Tag $TargetName { + AfterEach { + Invoke-TestCleanup + } + + It 'Connect-Device establishes valid session' { + { Connect-Device -Platform $Platform -Target $Target } | Should -Not -Throw + + $session = Get-DeviceSession + $session | Should -Not -BeNullOrEmpty + $session.Platform | Should -Be $Platform + $session.IsConnected | Should -BeTrue + } + + It 'Get-DeviceStatus returns status information' { + Connect-Device -Platform $Platform -Target $Target + + $status = Get-DeviceStatus + $status | Should -Not -BeNullOrEmpty + + $status.Status | Should -Be 'Online' + } + } + + Context 'Application Management' -Tag $TargetName { + BeforeAll { + Connect-Device -Platform $Platform -Target $Target + + # Path to test APK + $apkPath = Join-Path $PSScriptRoot 'Fixtures' 'Android' 'TestApp.apk' + if (-not (Test-Path $apkPath)) { + Set-ItResult -Skipped -Because "Test APK not found at $apkPath" + } + $script:apkPath = $apkPath + } + + AfterAll { + Invoke-TestCleanup + } + + It 'Install-DeviceApp installs APK' { + if (-not (Test-Path $script:apkPath)) { + Set-ItResult -Skipped -Because "Test APK not found" + return + } + + { Install-DeviceApp -Path $script:apkPath } | Should -Not -Throw + } + + It 'Invoke-DeviceApp executes application' { + if (-not (Test-Path $script:apkPath)) { + Set-ItResult -Skipped -Because "Test APK not found" + return + } + + $package = 'com.sentry.test.minimal' + $activity = '.MainActivity' + $executable = "$package/$activity" + + $result = Invoke-DeviceApp -ExecutablePath $executable + $result | Should -Not -BeNullOrEmpty + $result.Output | Should -Not -BeNullOrEmpty + } + } + + Context 'Diagnostics' -Tag $TargetName { + BeforeAll { + Connect-Device -Platform $Platform -Target $Target + } + + AfterAll { + Invoke-TestCleanup + } + + It 'Get-DeviceLogs retrieves logs' { + $logs = Get-DeviceLogs -MaxEntries 10 + $logs | Should -Not -BeNullOrEmpty + $logs.Logs | Should -Not -BeNullOrEmpty + } + } + + Context 'Screenshot Capture' -Tag $TargetName { + BeforeAll { + Connect-Device -Platform $Platform -Target $Target + } + + AfterAll { + Invoke-TestCleanup + } + + It 'Get-DeviceScreenshot captures screenshot' { + $outputPath = Join-Path $TestDrive "test_screenshot_$Platform.png" + + try { + { Get-DeviceScreenshot -OutputPath $outputPath } | Should -Not -Throw + + Test-Path $outputPath | Should -Be $true + $fileInfo = Get-Item $outputPath + $fileInfo.Length | Should -BeGreaterThan 0 + + # Verify PNG magic bytes + Get-Content $outputPath -AsByteStream -TotalCount 8 | Should -Be @(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + } + finally { + if (Test-Path $outputPath) { + Remove-Item $outputPath -Force + } + } + } + } +} diff --git a/app-runner/Tests/AndroidHelpers.Tests.ps1 b/app-runner/Tests/AndroidHelpers.Tests.ps1 new file mode 100644 index 0000000..3e3a7c4 --- /dev/null +++ b/app-runner/Tests/AndroidHelpers.Tests.ps1 @@ -0,0 +1,221 @@ +$ErrorActionPreference = 'Stop' + +BeforeAll { + # Import the module + $ModulePath = Join-Path $PSScriptRoot '..' 'SentryAppRunner.psd1' + Import-Module $ModulePath -Force + + # Dot-source AndroidHelpers for direct testing of internal functions + . "$PSScriptRoot\..\Private\AndroidHelpers.ps1" +} + +AfterAll { + Remove-Module SentryAppRunner -Force -ErrorAction SilentlyContinue +} + +Describe 'AndroidHelpers' -Tag 'Unit', 'Android' { + + Context 'ConvertFrom-AndroidActivityPath' { + It 'Parses valid activity path with package and activity' { + $result = ConvertFrom-AndroidActivityPath -ExecutablePath 'com.example.app/com.example.MainActivity' + + $result | Should -Not -BeNullOrEmpty + $result.PackageName | Should -Be 'com.example.app' + $result.ActivityName | Should -Be 'com.example.MainActivity' + } + + It 'Parses activity path with relative activity name' { + $result = ConvertFrom-AndroidActivityPath -ExecutablePath 'com.example.app/.MainActivity' + + $result.PackageName | Should -Be 'com.example.app' + $result.ActivityName | Should -Be '.MainActivity' + } + + It 'Handles complex package names' { + $result = ConvertFrom-AndroidActivityPath -ExecutablePath 'io.sentry.unreal.sample/com.epicgames.unreal.GameActivity' + + $result.PackageName | Should -Be 'io.sentry.unreal.sample' + $result.ActivityName | Should -Be 'com.epicgames.unreal.GameActivity' + } + + It 'Throws on invalid format without slash' { + { ConvertFrom-AndroidActivityPath -ExecutablePath 'com.example.app' } | Should -Throw '*must be in format*' + } + + It 'Throws on empty string' { + # PowerShell validates empty string before function runs + { ConvertFrom-AndroidActivityPath -ExecutablePath '' } | Should -Throw + } + + It 'Throws on null' { + { ConvertFrom-AndroidActivityPath -ExecutablePath $null } | Should -Throw + } + + It 'Handles multiple slashes (takes first as delimiter)' { + $result = ConvertFrom-AndroidActivityPath -ExecutablePath 'com.example.app/.MainActivity/extra' + + $result.PackageName | Should -Be 'com.example.app' + $result.ActivityName | Should -Be '.MainActivity/extra' + } + } + + 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' { + $nonExistentPath = Join-Path $TestDrive 'nonexistent-file.apk' + { Get-ApkPackageName -ApkPath $nonExistentPath } | Should -Throw '*APK file not found*' + } + + It 'Throws when file is not an APK' { + $txtFile = Join-Path $TestDrive 'notanapk.txt' + 'test content' | Out-File -FilePath $txtFile + + { Get-ApkPackageName -ApkPath $txtFile } | Should -Throw '*must be an .apk file*' + } + + It 'Throws when file has no extension' { + $noExtFile = Join-Path $TestDrive 'noextension' + 'test content' | Out-File -FilePath $noExtFile + + { Get-ApkPackageName -ApkPath $noExtFile } | Should -Throw '*must be an .apk file*' + } + + It 'Throws when file has wrong extension' { + $zipFile = Join-Path $TestDrive 'package.zip' + 'test content' | Out-File -FilePath $zipFile + + { Get-ApkPackageName -ApkPath $zipFile } | Should -Throw '*must be an .apk file*' + } + } + + Context 'Format-LogcatOutput' { + It 'Formats array of log lines' { + $logLines = @( + '01-01 12:00:00.000 1234 5678 I MyApp: Starting application', + '01-01 12:00:01.000 1234 5678 D MyApp: Debug message', + '01-01 12:00:02.000 1234 5678 E MyApp: Error occurred' + ) + + $result = Format-LogcatOutput -LogcatOutput $logLines + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 3 + $result[0] | Should -Be '01-01 12:00:00.000 1234 5678 I MyApp: Starting application' + $result[1] | Should -Be '01-01 12:00:01.000 1234 5678 D MyApp: Debug message' + $result[2] | Should -Be '01-01 12:00:02.000 1234 5678 E MyApp: Error occurred' + } + + It 'Filters out empty lines' { + $logLines = @( + 'Line 1', + '', + ' ', + 'Line 2', + $null, + 'Line 3' + ) + + $result = Format-LogcatOutput -LogcatOutput $logLines + + $result.Count | Should -Be 3 + $result[0] | Should -Be 'Line 1' + $result[1] | Should -Be 'Line 2' + $result[2] | Should -Be 'Line 3' + } + + It 'Returns empty array for null input' { + $result = Format-LogcatOutput -LogcatOutput $null + + # Function returns empty array @() which may be $null in some contexts + if ($null -eq $result) { + $result = @() + } + $result.Count | Should -Be 0 + } + + It 'Returns empty array for empty input' { + $result = Format-LogcatOutput -LogcatOutput @() + + $result.Count | Should -Be 0 + } + + It 'Converts non-string objects to strings' { + $logLines = @( + 123, + 'String line', + $true + ) + + $result = Format-LogcatOutput -LogcatOutput $logLines + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 3 + $result | ForEach-Object { $_ | Should -BeOfType [string] } + $result[0] | Should -Be '123' + $result[1] | Should -Be 'String line' + $result[2] | Should -Be 'True' + } + + It 'Preserves multi-line log content' { + $logLines = @( + '01-01 12:00:00.000 1234 5678 I Tag: First message', + '01-01 12:00:01.000 1234 5678 E Tag: Error with special chars: @#$%^&*()' + ) + + $result = Format-LogcatOutput -LogcatOutput $logLines + + $result.Count | Should -Be 2 + $result[0] | Should -Be '01-01 12:00:00.000 1234 5678 I Tag: First message' + $result[1] | Should -Be '01-01 12:00:01.000 1234 5678 E Tag: Error with special chars: @#$%^&*()' + } + } +} diff --git a/app-runner/Tests/Fixtures/Android/TestApp.apk b/app-runner/Tests/Fixtures/Android/TestApp.apk new file mode 100644 index 0000000..5464ccb Binary files /dev/null and b/app-runner/Tests/Fixtures/Android/TestApp.apk differ diff --git a/app-runner/Tests/Fixtures/Android/TestApp/.gitignore b/app-runner/Tests/Fixtures/Android/TestApp/.gitignore new file mode 100644 index 0000000..083d5de --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/.gitignore @@ -0,0 +1,50 @@ +# Built application files +*.apk +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# Keystore files +*.jks +*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Version control +.svn/ + +# OS-specific files +.DS_Store +Thumbs.db diff --git a/app-runner/Tests/Fixtures/Android/TestApp/Build-TestApp.ps1 b/app-runner/Tests/Fixtures/Android/TestApp/Build-TestApp.ps1 new file mode 100644 index 0000000..120c73d --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/Build-TestApp.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Builds the TestApp debug APK. + +.DESCRIPTION + This script builds the debug version of the TestApp Android application. + The APK is automatically copied to the parent directory (Tests/Fixtures/Android) by the Gradle build task. + +.EXAMPLE + ./Build-TestApp.ps1 +#> + +$ErrorActionPreference = 'Stop' + +$ProjectRoot = $PSScriptRoot + +Write-Information "Building SentryTestApp debug APK..." -InformationAction Continue + +try { + Push-Location $ProjectRoot + + # Use gradlew to build the debug APK + if ($IsWindows) { + & "$ProjectRoot\gradlew.bat" assembleDebug + } + else { + & "$ProjectRoot/gradlew" assembleDebug + } + + if ($LASTEXITCODE -ne 0) { + throw "Gradle build failed with exit code $LASTEXITCODE" + } + + $apkPath = Join-Path $ProjectRoot ".." "SentryTestApp.apk" + if (Test-Path $apkPath) { + Write-Information "✓ APK built successfully: $apkPath" -InformationAction Continue + $apkInfo = Get-Item $apkPath + Write-Information " Size: $([math]::Round($apkInfo.Length / 1MB, 2)) MB" -InformationAction Continue + } + else { + Write-Warning "APK was built but not found at expected location: $apkPath" + } +} +catch { + Write-Error "Failed to build APK: $_" + exit 1 +} +finally { + Pop-Location +} diff --git a/app-runner/Tests/Fixtures/Android/TestApp/README.md b/app-runner/Tests/Fixtures/Android/TestApp/README.md new file mode 100644 index 0000000..8c1fb96 --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/README.md @@ -0,0 +1,111 @@ +# TestApp + +A minimal Android test application used for automated testing of the SentryAppRunner device management functionality. + +## Overview + +This is a simple Android application that: +- Accepts parameters via intent extras +- Logs all received parameters to logcat with tag `SentryTestApp` +- Automatically closes after 3 seconds +- Is built in debug mode for log parsing + +## Package Information + +- **Package Name**: `com.sentry.test.minimal` +- **Main Activity**: `.MainActivity` +- **Full Activity Path**: `com.sentry.test.minimal/.MainActivity` + +## Building + +### Prerequisites + +- Java Development Kit (JDK) 8 or higher +- Android SDK (automatically downloaded by Gradle if not present) + +### Build the Debug APK + +Using PowerShell (recommended): +```powershell +./Build-TestApp.ps1 +``` + +Using Gradle directly: +```bash +# On macOS/Linux +./gradlew assembleDebug + +# On Windows +gradlew.bat assembleDebug +``` + +The APK will be automatically copied to `../SentryTestApp.apk` (in the Tests/Fixtures directory) after a successful build. + +## Usage + +### Installing the APK + +```bash +adb install -r SentryTestApp.apk +``` + +### Launching with Intent Parameters + +```bash +# Launch with string parameters +adb shell am start -n com.sentry.test.minimal/.MainActivity \ + --es param1 "value1" \ + --es param2 "value2" + +# Launch with integer parameter +adb shell am start -n com.sentry.test.minimal/.MainActivity \ + --ei count 42 + +# Launch with boolean parameter +adb shell am start -n com.sentry.test.minimal/.MainActivity \ + --ez enabled true +``` + +### Viewing Logs + +```bash +# View all SentryTestApp logs +adb logcat -s SentryTestApp:V + +# Clear logs and view new ones +adb logcat -c && adb logcat -s SentryTestApp:V +``` + +## Intent Parameter Types + +The app supports all standard Android intent extra types: +- `--es ` - String +- `--ei ` - Integer +- `--el ` - Long +- `--ez ` - Boolean (true/false) +- `--ef ` - Float +- `--ed ` - Double + +## Auto-Close Behavior + +The app automatically closes 3 seconds after launch. This is controlled by the `AUTO_CLOSE_DELAY_MS` constant in `MainActivity.java`. + +## Log Output Format + +The app logs the following information: +``` +I/SentryTestApp: MainActivity started +I/SentryTestApp: Received 2 intent parameter(s): +I/SentryTestApp: param1 = value1 +I/SentryTestApp: param2 = value2 +I/SentryTestApp: Auto-closing activity +I/SentryTestApp: MainActivity destroyed +``` + +## Testing + +This APK is used by the PowerShell tests in `Tests/Android.Tests.ps1` to verify: +- Device connection management +- APK installation +- Application execution with intent parameters +- Log retrieval and parsing diff --git a/app-runner/Tests/Fixtures/Android/TestApp/app/build.gradle b/app-runner/Tests/Fixtures/Android/TestApp/app/build.gradle new file mode 100644 index 0000000..5970c6a --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/app/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.sentry.test.minimal' + compileSdk 34 + + defaultConfig { + applicationId "com.sentry.test.minimal" + minSdk 21 + targetSdk 34 + versionCode 1 + versionName "1.0" + } + + buildTypes { + debug { + debuggable true + minifyEnabled false + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' +} + +// Copy the debug APK to the Fixtures/Android directory after build +afterEvaluate { + tasks.named('assembleDebug') { + doLast { + def apkFile = layout.buildDirectory.file('outputs/apk/debug/app-debug.apk').get().asFile + def destFile = new File(projectDir.parentFile.parentFile, 'TestApp.apk') + if (apkFile.exists()) { + destFile.bytes = apkFile.bytes + println "✓ Copied APK to: ${destFile.absolutePath}" + } else { + println "⚠ APK not found at: ${apkFile.absolutePath}" + } + } + } +} diff --git a/app-runner/Tests/Fixtures/Android/TestApp/app/proguard-rules.pro b/app-runner/Tests/Fixtures/Android/TestApp/app/proguard-rules.pro new file mode 100644 index 0000000..7075c38 --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project specific ProGuard rules here. +# This is a minimal test app, so we don't need any special ProGuard rules. diff --git a/app-runner/Tests/Fixtures/Android/TestApp/app/src/main/AndroidManifest.xml b/app-runner/Tests/Fixtures/Android/TestApp/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3d38220 --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/app-runner/Tests/Fixtures/Android/TestApp/app/src/main/java/com/sentry/test/minimal/MainActivity.java b/app-runner/Tests/Fixtures/Android/TestApp/app/src/main/java/com/sentry/test/minimal/MainActivity.java new file mode 100644 index 0000000..6c0a20f --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/app/src/main/java/com/sentry/test/minimal/MainActivity.java @@ -0,0 +1,52 @@ +package com.sentry.test.minimal; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; + +/** + * Minimal test activity that accepts intent parameters and auto-closes after a few seconds. + * Used for automated testing of Android device management. + */ +public class MainActivity extends Activity { + private static final String TAG = "SentryTestApp"; + private static final int AUTO_CLOSE_DELAY_MS = 3000; // 3 seconds + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Log.i(TAG, "MainActivity started"); + + // Log all intent extras + Intent intent = getIntent(); + if (intent != null && intent.getExtras() != null) { + Bundle extras = intent.getExtras(); + Log.i(TAG, "Received " + extras.size() + " intent parameter(s):"); + for (String key : extras.keySet()) { + Object value = extras.get(key); + Log.i(TAG, " " + key + " = " + value); + } + } else { + Log.i(TAG, "No intent parameters received"); + } + + // Auto-close after delay + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Auto-closing activity"); + finish(); + System.exit(0); + } + }, AUTO_CLOSE_DELAY_MS); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + Log.i(TAG, "MainActivity destroyed"); + } +} diff --git a/app-runner/Tests/Fixtures/Android/TestApp/build.gradle b/app-runner/Tests/Fixtures/Android/TestApp/build.gradle new file mode 100644 index 0000000..eeaddcb --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/build.gradle @@ -0,0 +1,14 @@ +// Top-level build file +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/app-runner/Tests/Fixtures/Android/TestApp/gradle.properties b/app-runner/Tests/Fixtures/Android/TestApp/gradle.properties new file mode 100644 index 0000000..9208e9b --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/gradle.properties @@ -0,0 +1,11 @@ +# Project-wide Gradle settings +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +# AndroidX package structure +android.useAndroidX=true +android.enableJetifier=true + +# Kotlin code style +kotlin.code.style=official diff --git a/app-runner/Tests/Fixtures/Android/TestApp/gradle/wrapper/gradle-wrapper.jar b/app-runner/Tests/Fixtures/Android/TestApp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/app-runner/Tests/Fixtures/Android/TestApp/gradle/wrapper/gradle-wrapper.jar differ diff --git a/app-runner/Tests/Fixtures/Android/TestApp/gradle/wrapper/gradle-wrapper.properties b/app-runner/Tests/Fixtures/Android/TestApp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62f495d --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/app-runner/Tests/Fixtures/Android/TestApp/gradlew b/app-runner/Tests/Fixtures/Android/TestApp/gradlew new file mode 100755 index 0000000..da14925 --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/gradlew @@ -0,0 +1,166 @@ +#!/bin/sh + +############################################################################## +# Gradle start up script for POSIX +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/app-runner/Tests/Fixtures/Android/TestApp/gradlew.bat b/app-runner/Tests/Fixtures/Android/TestApp/gradlew.bat new file mode 100644 index 0000000..43f6267 --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/gradlew.bat @@ -0,0 +1,68 @@ +@rem Gradle startup script for Windows + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/app-runner/Tests/Fixtures/Android/TestApp/settings.gradle b/app-runner/Tests/Fixtures/Android/TestApp/settings.gradle new file mode 100644 index 0000000..3f39e04 --- /dev/null +++ b/app-runner/Tests/Fixtures/Android/TestApp/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'TestApp' +include ':app' diff --git a/app-runner/Tests/SauceLabs.Tests.ps1 b/app-runner/Tests/SauceLabs.Tests.ps1 new file mode 100644 index 0000000..aa1087a --- /dev/null +++ b/app-runner/Tests/SauceLabs.Tests.ps1 @@ -0,0 +1,150 @@ +$ErrorActionPreference = 'Stop' + +BeforeDiscovery { + # Define test targets + function Get-TestTarget { + param( + [string]$Platform, + [string]$Target, + [string]$FixturePath, + [string]$ExePath, + [string]$Arguments + ) + + $TargetName = "$Platform-$Target" + + return @{ + Platform = $Platform + Target = $Target + TargetName = $TargetName + FixturePath = $FixturePath + ExePath = $ExePath + Arguments = $Arguments + } + } + + $TestTargets = @() + + # Detect if running in CI environment + $isCI = $env:CI -eq 'true' + + # Check for SauceLabs credentials + if ($env:SAUCE_USERNAME -and $env:SAUCE_ACCESS_KEY -and $env:SAUCE_REGION) { + # Check Android Fixture + $androidFixture = Join-Path $PSScriptRoot 'Fixtures' 'Android' 'TestApp.apk' + if (Test-Path $androidFixture) { + $TestTargets += Get-TestTarget ` + -Platform 'AndroidSauceLabs' ` + -Target 'Samsung_Galaxy_S23_15_real_sjc1' ` + -FixturePath $androidFixture ` + -ExePath 'com.sentry.test.minimal/.MainActivity' ` + -Arguments '-e sentry test' + } else { + $message = "Android fixture not found at $androidFixture" + if ($isCI) { + throw "$message. This is required in CI." + } else { + Write-Warning "$message. AndroidSauceLabs tests will be skipped." + } + } + + # Check iOS Fixture (not supported yet) + # $iosFixture = Join-Path $PSScriptRoot 'Fixtures' 'iOS' 'TestApp.ipa' + # if (Test-Path $iosFixture) { + # $TestTargets += Get-TestTarget ` + # -Platform 'iOSSauceLabs' ` + # -Target 'iPhone 13 Pro' ` + # -FixturePath $iosFixture ` + # -ExePath 'com.saucelabs.mydemoapp.ios' ` + # -Arguments '--test-arg value' + # } else { + # $message = "iOS fixture not found at $iosFixture" + # if ($isCI) { + # throw "$message. This is required in CI." + # } else { + # Write-Warning "$message. iOSSauceLabs tests will be skipped." + # } + # } + } + else { + $message = "SauceLabs credentials not found. Required environment variables: SAUCE_USERNAME, SAUCE_ACCESS_KEY, SAUCE_REGION" + if ($isCI) { + throw "$message. These are required in CI." + } else { + Write-Warning "$message. SauceLabs tests will be skipped." + } + } +} + +BeforeAll { + # Import the module + Import-Module "$PSScriptRoot/../SentryAppRunner.psm1" -Force + + # Helper function for cleanup + function Invoke-TestCleanup { + try { + if (Get-DeviceSession) { + Disconnect-Device + } + } + catch { + # Ignore cleanup errors + Write-Debug "Cleanup failed: $_" + } + } +} + +Describe '' -Tag 'SauceLabs' -ForEach $TestTargets { + Context 'Device Connection Management' -Tag $TargetName { + AfterEach { + Invoke-TestCleanup + } + + It 'Connect-Device establishes valid session' { + { Connect-Device -Platform $Platform -Target $Target } | Should -Not -Throw + + $session = Get-DeviceSession + $session | Should -Not -BeNullOrEmpty + $session.Platform | Should -Be $Platform + $session.IsConnected | Should -BeTrue + } + } + + Context 'Application Management' -Tag $TargetName { + BeforeAll { + Connect-Device -Platform $Platform -Target $Target + + # Set fixture path for tests + if (-not (Test-Path $FixturePath)) { + Set-ItResult -Skipped -Because "Test app not found at $FixturePath" + } + $script:FixturePath = $FixturePath + $script:ExePath = $ExePath + $script:Arguments = $Arguments + } + + AfterAll { + Invoke-TestCleanup + } + + It 'Install-DeviceApp installs application' { + if (-not (Test-Path $script:FixturePath)) { + Set-ItResult -Skipped -Because "Test app not found" + return + } + + { Install-DeviceApp -Path $script:FixturePath } | Should -Not -Throw + } + + It 'Invoke-DeviceApp executes application' { + if (-not (Test-Path $script:FixturePath)) { + Set-ItResult -Skipped -Because "Test app not found" + return + } + + $result = Invoke-DeviceApp -ExecutablePath $script:ExePath -Arguments $script:Arguments + $result | Should -Not -BeNullOrEmpty + $result.Output | Should -Not -BeNullOrEmpty + } + } +}