diff --git a/samples/copilotstudiokit/README.md b/samples/copilotstudiokit/README.md index 530ee7e1..dc91a266 100644 --- a/samples/copilotstudiokit/README.md +++ b/samples/copilotstudiokit/README.md @@ -37,11 +37,7 @@ dotnet tool install --global Microsoft.PowerApps.CLI.Tool 5. Granted System Administrator or System Customizer roles as documented in [Microsoft Learn](https://learn.microsoft.com/power-apps/maker/model-driven-apps/privileges-required-customization#system-administrator-and-system-customizer-security-roles) -6. Git Client has been installed. For example using [GitHub Desktop](https://desktop.github.com/download/) or the [Git application](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). For example on Windows you could use the following command -```pwsh -winget install --id Git.Git -e --source winget -``` 7. The Azure CLI has been [installed](https://learn.microsoft.com/cli/azure/install-azure-cli) @@ -65,6 +61,11 @@ pac application install --environment-id 00000000-0000-0000-0000-000000000000 -- ## Verification +To ensure your environment is ready: +```powershell +.\validate.ps1 +``` + > NOTE: If at any stage you find that a component is not installed, you may need to restart you command line session to verify that the component has been installed 1. Verify you have .Net 8.0 SDK installed @@ -167,10 +168,6 @@ code . "userAuth": "storagestate", "runInstall": true, "installPlaywright": true, - "compile": true, - "record": false, - "runTests": true, - "debugTests": false, "useStaticContext": false, "getLatest": false, "testScripts": { diff --git a/samples/copilotstudiokit/RunTests.ps1 b/samples/copilotstudiokit/RunTests.ps1 index f2509d4a..12a1381b 100644 --- a/samples/copilotstudiokit/RunTests.ps1 +++ b/samples/copilotstudiokit/RunTests.ps1 @@ -1,32 +1,457 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# Check for optional command line argument for last run time +# Script: RunTests.ps1 +# This script runs tests for Copilot Studio Kit applications. +# It generates an Azure DevOps style HTML report by default + +# +# Usage examples: +# .\RunTests.ps1 # Run all tests with Azure DevOps style report +# .\RunTests.ps1 -entityFilter "account" # Run only tests related to the account entity +# .\RunTests.ps1 -pageTypeFilter "entitylist" # Run only list view tests +# .\RunTests.ps1 -pageTypeFilter "entityrecord" # Run only details view tests +# .\RunTests.ps1 -pageTypeFilter "custom" # Run only custom page tests +# .\RunTests.ps1 -pageTypeFilter "entitylist","entityrecord" # Run both list and details tests (no custom pages) +# .\RunTests.ps1 -pageTypeFilter @("entitylist","custom") # Run list and custom page tests (no details) +# .\RunTests.ps1 -customPageFilter "dashboard" # Run only custom pages with "dashboard" in the name +# .\RunTests.ps1 -startTime "2025-05-20 09:00" # Run tests and show results since 2025-05-20 09:00 +# .\RunTests.ps1 -startTime "2025-05-20 09:00" -endTime "2025-05-24 09:00" # Generate report from existing test runs between the specified dates without executing tests +# .\RunTests.ps1 -testEngineBranch "feature/my-branch" # Use a specific branch of PowerApps-TestEngine +# .\RunTests.ps1 -generateReportOnly # Generate report from existing test data without running tests +# +# Multiple filters can be combined: +# .\RunTests.ps1 -entityFilter "account" -pageTypeFilter "entityrecord" + +# Check for optional command line arguments param ( - [string]$lastRunTime + [string]$startTime, # Start time for the test results to include in the report + [string]$endTime, # End time for the test results to include in the report (when both startTime and endTime are provided, tests are not executed) + [string]$entityFilter, # Filter tests by entity name + [string[]]$pageTypeFilter, # Filter by page type(s) (list, details, custom) - can be multiple values + [string]$customPageFilter, # Filter by custom page name + [string]$testEngineBranch = "user/grant-archibald-ms/report-594", # Optional branch to use for PowerApps-TestEngine + [switch]$forceRebuild, # Force rebuild of PowerApps-TestEngine even if it exists + [switch]$generateReportOnly, # Only generate a report without running tests + [switch]$useStaticContext = $false, # Use static context for test execution + [switch]$usePacTest = $true, # Use 'pac test run' instead of direct PowerAppsTestEngine.dll execution + [switch]$ShowSensitiveValues = $false # Show actual values for sensitive data like email, tenant ID, environment ID + # (by default these are masked with ***) ) +# Helper function to mask sensitive values for screen recording safety +function Hide-SensitiveValue { + param( + [string]$Value, + [int]$VisibleChars = 4 + ) + + if ([string]::IsNullOrEmpty($Value)) { + return $Value + } + + # If ShowSensitiveValues is enabled, return the value as-is + if ($ShowSensitiveValues) { + return $Value + } + + # For URLs, show protocol and domain structure but mask the subdomain + if ($Value -match '^(https?://)([^\.]+)\.(.+)$') { + $protocol = $matches[1] # e.g., "https://" + $subdomain = $matches[2] # the subdomain part to mask + $domain = $matches[3] # e.g., "dynamics.crm.com" + + return "$protocol****.$domain" + } + + # For file paths containing Users\username, mask the username + if ($Value -match '([A-Za-z]:\\Users\\)([^\\]+)(\\.*)?') { + $prefix = $matches[1] # e.g., "C:\Users\" + $username = $matches[2] # the username part + $suffix = $matches[3] # the rest of the path (if any) + + if ([string]::IsNullOrEmpty($suffix)) { + $suffix = "" + } + + return "$prefix***$suffix" + } + + # For emails, show first few chars and domain + if ($Value -match '^[^@]+@[^@]+$') { + $parts = $Value.Split('@') + $username = $parts[0] + $domain = $parts[1] + + if ($username.Length -le $VisibleChars) { + return "***@$domain" + } else { + return "$($username.Substring(0, $VisibleChars))***@$domain" + } + } + # Check if it's a GUID format (8-4-4-4-12 characters separated by hyphens) + if ($Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + # For GUIDs, show the first section and mask the rest + $parts = $Value.Split('-') + $firstSection = $parts[0] + $maskedParts = @($firstSection) + + # Mask remaining sections by replacing each alphanumeric character with * + for ($i = 1; $i -lt $parts.Length; $i++) { + $maskedSection = $parts[$i] -replace '[0-9a-fA-F]', '*' + $maskedParts += $maskedSection + } + + return $maskedParts -join '-' + } + + # Check if the value is a username (like from $env:username) + # This handles cases where the username appears directly as a value + if (-not [string]::IsNullOrEmpty($env:username) -and $Value -eq $env:username) { + return "***" + } + + # Also check for common username patterns (alphanumeric, possibly with dots, hyphens, underscores) + # but exclude obvious non-usernames like GUIDs, URLs, file extensions + if ($Value -match '^[a-zA-Z][a-zA-Z0-9\.\-_]{2,19}$' -and + $Value -notmatch '\.' -and # Exclude things that look like file extensions or domains + $Value -notmatch '^https?:' -and # Exclude URLs + $Value -notmatch '[0-9a-fA-F]{8,}') { # Exclude long hex strings + return "***" + } + + # For other long values, show first few chars + if ($Value.Length -le $VisibleChars) { + return "***" + } else { + return "$($Value.Substring(0, $VisibleChars))***" + } +} + +# Function to execute a test, either using PowerAppsTestEngine.dll directly or pac test run +# Note: Report generation is always handled by PowerAppsTestEngine.dll regardless of execution mode +function Execute-Test { + param( + [string]$testScriptPath, # Path to the test script file + [string]$targetUrl, # URL to test + [string]$logLevel = "Debug", # Log level + [switch]$useStaticContextArg # Whether to use static context + ) + + $staticContextArgValue = if ($useStaticContextArg) { "TRUE" } else { "FALSE" } + $debugTestArgValue = if ($debugTests) { "TRUE" } else { "FALSE" } + + if ($usePacTest) { + # Use pac test run command + Write-Host "Running test using pac test run..." -ForegroundColor Green + + # Build the pac test run command + $pacArgs = @( + "test", "run", + "--test-plan-file", "$testScriptPath", + "--domain", "$targetUrl", + "--tenant", $tenantId, + "--environment-id", $environmentId, + "--provider", "mda" + ) + + # Add optional arguments + if ($useStaticContextArg) { + $pacArgs += "--use-static-context" + } + + if ($debugTests) { + $pacArgs += "--debug" + } # Note: We won't use --run-id with pac test, as we'll update the generated TRX files later + # This gives us more control over the test result processing + # Create masked versions for logging + $maskedTenantId = Hide-SensitiveValue -Value $tenantId + $maskedEnvironmentId = Hide-SensitiveValue -Value $environmentId + $maskedTargetUrl = Hide-SensitiveValue -Value $targetUrl + $maskedTestScriptPath = Hide-SensitiveValue -Value $testScriptPath + + # Log the command being executed (with masked sensitive values) + $maskedPacArgs = @( + "test", "run", + "--test-plan-file", "$maskedTestScriptPath", + "--domain", "$maskedTargetUrl", + "--tenant", $maskedTenantId, + "--environment-id", $maskedEnvironmentId, + "--provider", "mda" + ) + + if ($useStaticContextArg) { + $maskedPacArgs += "--use-static-context" + } + + if ($debugTests) { + $maskedPacArgs += "--debug" + } + + Write-Host "Executing: pac $($maskedPacArgs -join ' ')" -ForegroundColor DarkGray + + # Execute pac command + & pac $pacArgs + + if ($LASTEXITCODE -ne 0) { + Write-Host "Test execution failed with exit code: $LASTEXITCODE" -ForegroundColor Red + } + } + else { + # Use PowerAppsTestEngine.dll directly + Write-Host "Running test using PowerAppsTestEngine.dll directly..." -ForegroundColor Green + # Navigate to the test engine directory + Push-Location $testEnginePath + try { + # Create masked versions for logging + $maskedTenantId = Hide-SensitiveValue -Value $tenantId + $maskedEnvironmentId = Hide-SensitiveValue -Value $environmentId + $maskedTargetUrl = Hide-SensitiveValue -Value $targetUrl + $maskedTestScriptPath = Hide-SensitiveValue -Value $testScriptPath + + # Log the command being executed (with masked sensitive values) + Write-Host "Executing: dotnet PowerAppsTestEngine.dll -c `"$staticContextArgValue`" -w `"$debugTestArgValue`" -u `"$userAuth`" -a `"$authType`" -p `"mda`" -a `"none`" -i `"$maskedTestScriptPath`" -t $maskedTenantId -e $maskedEnvironmentId -d `"$maskedTargetUrl`" -l `"$logLevel`" --run-name $runName" -ForegroundColor DarkGray + + # Execute the test using dotnet command + dotnet PowerAppsTestEngine.dll -c "$staticContextArgValue" -w "$debugTestArgValue" -u "$userAuth" -a "$authType" -p "mda" -a "none" -i "$testScriptPath" -t $tenantId -e $environmentId -d "$targetUrl" -l "$logLevel" --run-name $runName + + if ($LASTEXITCODE -ne 0) { + Write-Host "Test execution failed with exit code: $LASTEXITCODE" -ForegroundColor Red + } + } + finally { + Pop-Location + } + } +} + +$runName = [Guid]::NewGuid().Guid.ToString() + # Get current directory so we can reset back to it after running the tests $currentDirectory = Get-Location +# Define the PowerApps Test Engine repository information +$testEngineRepoUrl = "https://github.com/microsoft/PowerApps-TestEngine" +$testEngineDirectory = Join-Path -Path $PSScriptRoot -ChildPath "..\PowerApps-TestEngine" +$testEngineBuildDir = Join-Path -Path $testEngineDirectory -ChildPath "src" +$testEngineBinDir = Join-Path -Path $testEngineDirectory -ChildPath "bin\Debug\PowerAppsTestEngine" + +# Function to check if current directory is part of PowerApps-TestEngine +function Test-IsInTestEngineRepo { + try { + # Get the git root directory + $gitRootDir = git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -ne 0) { + # Not in a git repo + return $false + } + + # Check if the directory name indicates it's the PowerApps-TestEngine repo + $dirName = Split-Path -Path $gitRootDir -Leaf + if ($dirName -eq "PowerApps-TestEngine") { + return $true + } + + # Check remote URLs for PowerApps-TestEngine + $remotes = git remote -v 2>$null + foreach ($remote in $remotes) { + if ($remote -like "*PowerApps-TestEngine*") { + return $true + } + } + + return $false + } + catch { + return $false + } +} + +# Function to setup the PowerApps Test Engine +# Function to build the PowerApps Test Engine +function Build-TestEngine { + param( + [string]$srcDir, # Source directory containing the code to build + [string]$message = "Building PowerApps Test Engine..." # Custom message for build + ) + + Write-Host $message -ForegroundColor Green + + # Navigate to the src directory + Push-Location $srcDir + + try { + # Build the Test Engine + dotnet build + + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to build PowerApps Test Engine. Please check the build logs." + exit 1 + } + + Write-Host "PowerApps Test Engine built successfully!" -ForegroundColor Green + return $true + } + finally { + Pop-Location # Return from src directory + } +} + +# Function to verify the PowerApps Test Engine binary exists +function Test-TestEngineBinary { + param( + [string]$binDir # Directory where the binary should exist + ) + + $dllPath = Join-Path -Path $binDir -ChildPath "PowerAppsTestEngine.dll" + if (-not (Test-Path -Path $dllPath)) { + Write-Error "PowerAppsTestEngine.dll not found at $dllPath. Please check the build process." + return $false + } + + $maskedDllPath = Hide-SensitiveValue -Value $dllPath + Write-Host "Found PowerAppsTestEngine.dll at $maskedDllPath" -ForegroundColor Green + return $true +} + +# Function to setup the PowerApps Test Engine +function Setup-TestEngine { + # Check if we're already in the PowerApps-TestEngine repository + $isInTestEngineRepo = Test-IsInTestEngineRepo + + if ($isInTestEngineRepo) { + Write-Host "Detected current directory is part of PowerApps-TestEngine repository" -ForegroundColor Green + + # Get the root directory of the repository + $repoRootDir = git rev-parse --show-toplevel 2>$null + + # Use paths relative to the repository root + $relativeSrcDir = Join-Path -Path $repoRootDir -ChildPath "src" + $relativeBinDir = Join-Path -Path $repoRootDir -ChildPath "bin\Debug\PowerAppsTestEngine" + $binDebugDir = Join-Path -Path $repoRootDir -ChildPath "bin\Debug" + + Write-Host "Using repository root directory: $(Hide-SensitiveValue $repoRootDir)" -ForegroundColor Green + + # Check if build is needed + $needsBuild = $false + + # Check if the bin\Debug directory exists + if (-not (Test-Path -Path $binDebugDir)) { + Write-Host "bin\Debug directory doesn't exist. Building the project..." -ForegroundColor Yellow + $needsBuild = $true + } + # Check if the PowerAppsTestEngine.dll exists + elseif (-not (Test-Path -Path "$relativeBinDir\PowerAppsTestEngine.dll")) { + Write-Host "PowerAppsTestEngine.dll not found. Building the project..." -ForegroundColor Yellow + $needsBuild = $true + } + # Honor forceRebuild if specified + elseif ($forceRebuild) { + Write-Host "Force rebuild requested. Building the project..." -ForegroundColor Yellow + $needsBuild = $true + } else { + Write-Host "Using existing build in $(Hide-SensitiveValue $relativeBinDir)" -ForegroundColor Green + } + + # Build if needed + if ($needsBuild) { + Build-TestEngine -srcDir $relativeSrcDir -message "Building PowerApps Test Engine from local source..." + } # Verify binary exists + if (Test-TestEngineBinary -binDir $relativeBinDir) { + Write-Host "Binary verified at $(Hide-SensitiveValue $relativeBinDir)" -ForegroundColor Green + return $relativeBinDir + } else { + Write-Error "Failed to verify binary at $relativeBinDir" + exit 1 + } + } + else { + # Check if the PowerApps-TestEngine directory exists + if (-not (Test-Path -Path $testEngineDirectory) -or $forceRebuild) { + Write-Host "Setting up PowerApps Test Engine..." -ForegroundColor Cyan + + # Remove existing directory if it exists + if (Test-Path -Path $testEngineDirectory) { + Write-Host "Get latest changes..." -ForegroundColor Yellow + Set-Location $testEngineDirectory + git pull + } else { # Clone the repository + Write-Host "Cloning PowerApps Test Engine repository from $(Hide-SensitiveValue $testEngineRepoUrl)..." -ForegroundColor Green + git clone "$testEngineRepoUrl" "$testEngineDirectory" + } + + # Navigate to the repository directory + Push-Location $testEngineDirectory + + try { + # Check if a specific branch was specified + if ($testEngineBranch -ne "main") { + Write-Host "Switching to branch: $testEngineBranch" -ForegroundColor Green + git checkout $testEngineBranch + + # Check if checkout was successful + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to switch to branch $testEngineBranch. Using main branch instead." -ForegroundColor Yellow + git checkout main + } + } + + # Build the Test Engine using shared function + Build-TestEngine -srcDir $testEngineBuildDir + } + finally { + Pop-Location # Return from repository directory + } + } else { + Write-Host "PowerApps Test Engine directory already exists at $(Hide-SensitiveValue $testEngineDirectory)" -ForegroundColor Green + } # Verify binary exists + if (Test-TestEngineBinary -binDir $testEngineBinDir) { + Write-Host "Binary verified at $(Hide-SensitiveValue $testEngineBinDir)" -ForegroundColor Green + return $testEngineBinDir + } else { + Write-Error "Failed to verify binary at $testEngineBinDir" + exit 1 + } + } +} + +# This line was redundant, as we call Setup-TestEngine below + +# Set up the Test Engine and get the path to the built binary +$testEnginePath = Setup-TestEngine +if ($testEnginePath -is [array]) { + Write-Host "Converting array to string for testEnginePath" -ForegroundColor Yellow + $testEnginePath = $testEnginePath[0] +} +Write-Host "Test Engine Path: $(Hide-SensitiveValue $testEnginePath)" -ForegroundColor Green + +Set-Location $currentDirectory + $jsonContent = Get-Content -Path .\config.json -Raw $config = $jsonContent | ConvertFrom-Json $tenantId = $config.tenantId $environmentId = $config.environmentId $user1Email = $config.user1Email $record = $config.record -$compile = $config.compile + # Extract pages and corresponding Test Scripts $customPages = $config.pages.customPages $entities = $config.pages.entities $testScripts = $config.testScripts -$runTests = $config.runTests -$useStaticContext = $config.useStaticContext + +# Initialize $runTests - default to true if not specified in config or overridden by testRunTime +$runTests = if ([bool]::TryParse($config.runTests, [ref]$null)) { [bool]$config.runTests } else { $true } + +# Check if useStaticContext parameter was provided, otherwise get it from config +if (-not $PSBoundParameters.ContainsKey('useStaticContext')) { + $useStaticContext = if ($null -eq $config.useStaticContext) { $false } else { $config.useStaticContext } +} +# Otherwise, the useStaticContext parameter value will be used (already set from param block) $appName = $config.appName $debugTests = $config.debugTests -$getLatest = $config.getLatest $userAuth = $config.userAuth $authType = "default" +$environmentUrl = $config.environmentUrl if ([string]::IsNullOrEmpty($userAuth)) { $userAuth = "storagestate" @@ -36,16 +461,30 @@ if ($userAuth -eq "dataverse") { $authType = "storagestate" } -# Define the folder path and time threshold -$folderPath = "$env:USERPROFILE\AppData\Local\Temp\Microsoft\TestEngine\TestOutput" +# Define the folder paths for test outputs +$testEngineBasePath = "$env:USERPROFILE\AppData\Local\Temp\Microsoft\TestEngine" +$folderPath = "$testEngineBasePath\TestOutput" $extraArgs = "" $debugTestValue = "FALSE" $staticContext = "FALSE" -if ($useStaticContext) { +# Check if useStaticContext is true and set staticContext accordingly +if ($useStaticContext -eq $true) { + Write-Host "Using static context: TRUE" -ForegroundColor Green $staticContext = "TRUE" +} else { + Write-Host "Using static context: FALSE" -ForegroundColor Green + $staticContext = "FALSE" +} + +# Display execution mode +if ($usePacTest) { + Write-Host "Test execution mode: pac test run (Power Platform CLI)" -ForegroundColor Cyan + Write-Host "Report generation will still use PowerAppsTestEngine.dll" -ForegroundColor Cyan +} else { + Write-Host "Test execution mode: PowerAppsTestEngine.dll (direct)" -ForegroundColor Cyan } if ($debugTests) { @@ -56,100 +495,84 @@ if ($getLatest) { git pull } -if ($lastRunTime) { - try { - $timeThreshold = [datetime]::ParseExact($lastRunTime, "yyyy-MM-dd HH:mm", $null) +# Define start and end times for test reporting +$startTimeThreshold = $null +$endTimeThreshold = $null + +# Process startTime parameter +if ($startTime) { + try { # Try parsing with multiple possible formats + try { + $startTimeThreshold = [DateTime]::ParseExact($startTime, "yyyy-MM-dd HH:mm", [System.Globalization.CultureInfo]::InvariantCulture) + # Format successfully parsed + } catch { + # Try general parsing as fallback + try { + $startTimeThreshold = [DateTime]::Parse($startTime) + } catch { + throw "Could not parse the startTime format" + } + } + Write-Host "Including test results from after $startTime" -ForegroundColor Yellow } catch { - Write-Error "Invalid date format. Please use 'yyyy-MM-DD HH:mm'." + Write-Error "Invalid startTime format. Please use 'yyyy-MM-dd HH:mm'. Error: $($_.Exception.Message)" return } } else { - $timeThreshold = Get-Date + # Default: use current time as the start time + $startTimeThreshold = Get-Date + Write-Host "No start time provided. Using current time: $($startTimeThreshold.ToString('yyyy-MM-dd HH:mm'))" -ForegroundColor Yellow } -function Update-TestData { - param ( - [string]$folderPath, - [datetime]$timeThreshold, - [string]$entityName, - [string]$entityType - ) - - AddOrUpdate -key ($entityName + "-" + $entityType) -value (New-Object TestData($entityName, $entityType, 0, 0)) - - # Find all folders newer than the specified time - $folders = Get-ChildItem -Path $folderPath -Directory | Where-Object { $_.LastWriteTime -gt $timeThreshold } - - # Initialize array to store .trx files - $trxFiles = @() - - # Iterate through each folder and find .trx files - foreach ($folder in $folders) { - $trxFiles += Get-ChildItem -Path $folder.FullName -Filter "*.trx" - } - - # Parse each .trx file and update pass/fail counts in TestData - foreach ($trxFile in $trxFiles) { - $xmlContent = Get-Content -Path $trxFile.FullName -Raw - $xml = [xml]::new() - $xml.LoadXml($xmlContent) - - # Create a namespace manager - $namespaceManager = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) - $namespaceManager.AddNamespace("ns", "http://microsoft.com/schemas/VisualStudio/TeamTest/2010") - - # Find the Counters element - $counters = $xml.SelectSingleNode("//ns:Counters", $namespaceManager) - - # Extract the counter properties and update TestData - if ($counters) { - $passCount = [int]$counters.passed - $failCount = [int]$counters.failed - - AddOrUpdate -key ($entityName + "-" + $entityType) -value (New-Object TestData($entityName, $entityType, $passCount, $failCount)) +# Process endTime parameter +if ($endTime) { + try { # Try parsing with multiple possible formats + try { + $endTimeThreshold = [DateTime]::ParseExact($endTime, "yyyy-MM-dd HH:mm", [System.Globalization.CultureInfo]::InvariantCulture) + # Format successfully parsed + } catch { + # Try general parsing as fallback + try { + $endTimeThreshold = [DateTime]::Parse($endTime) + } catch { + throw "Could not parse the endTime format" + } } + Write-Host "Including test results until $endTime" -ForegroundColor Yellow + } catch { + Write-Error "Invalid endTime format. Please use 'yyyy-MM-dd HH:mm'. Error: $($_.Exception.Message)" + return } +} else { + # Default: use current time as the end time, will be updated after tests run + $endTimeThreshold = Get-Date + # Store the original parameter state to know if it was explicitly provided + $endTimeProvided = $false } -# Initialize the dictionary (hash table) -$dictionary = @{} - -# Define the TestData class -class TestData { - [string]$EntityName - [string]$EntityType # list, record, custom - [int]$PassCount - [int]$FailCount - - TestData([string]$entityName, [string]$entityType, [int]$passCount, [int]$failCount) { - $this.EntityName = $entityName - $this.EntityType = $entityType - $this.PassCount = $passCount - $this.FailCount = $failCount - } - - # Override ToString method for better display - [string]ToString() { - return "EntityName: $($this.EntityName), EntityType: $($this.EntityType), PassCount: $($this.PassCount), FailCount: $($this.FailCount)" - } -} - -# Function to add or update a key-value pair -function AddOrUpdate { - param ( - [string]$key, - [object]$value - ) - - if ($dictionary.ContainsKey($key)) { - # Update the pass/fail properties if the key exists - $dictionary[$key].PassCount += $value.PassCount - $dictionary[$key].FailCount += $value.FailCount - Write-Host "Updated key '$key' with value '$($dictionary[$key])'." +# Decide whether to run tests or just generate a report +if ($generateReportOnly -or ($startTime -and $endTime)) { + # Don't run tests if generateReportOnly is specified or both start and end times are provided + $runTests = $false + + if ($generateReportOnly) { + Write-Host "Generating report only from existing test results" -ForegroundColor Yellow + # If no specific time range was provided with -generateReportOnly, use a wide default range + if (-not $startTime) { + $startTimeThreshold = (Get-Date).AddDays(-7) # Default to last 7 days if no start time provided + Write-Host "Using default time range: last 7 days" -ForegroundColor Yellow + } } else { - # Add the key-value pair if the key does not exist - $dictionary[$key] = $value - Write-Host "Added key '$key' with value '$value'." + Write-Host "Generating report from existing test results between $startTime and $endTime" -ForegroundColor Yellow + } + + Write-Host "Tests will not be executed" -ForegroundColor Yellow +} else { + # Keep runTests as initialized earlier (from config or default to true) + # Since we now default startTime to current time when not provided, + # we want to make it clear we're running tests from now + if (-not $startTime) { + Write-Host "Will run tests and include results from current run only" -ForegroundColor Yellow } } @@ -165,31 +588,6 @@ if ($azTenantId -ne $tenantId) { return } -$foundEnvironment = $false -$textResult = (pac env select --environment $environmentId) -$textResult = (pac env list) - -$environmentUrl = "" - -Write-Host "Searching for $environmentId" - -foreach ($line in $textResult) { - if ($line -match $environmentId) { - if ($line -match "(https://\S+/)") { - $environmentUrl = $matches[0].Substring(0,$matches[0].Length - 1) - $foundEnvironment = $true - break - } - } -} - -if ($foundEnvironment) { - Write-Output "Found matching Environment URL: $environmentUrl" -} else { - Write-Error "Environment ID not found." - return -} - $token = (az account get-access-token --resource $environmentUrl | ConvertFrom-Json) if ($token -eq $null) { @@ -208,13 +606,6 @@ $appDescriptor = $appInfo.descriptor | ConvertFrom-Json $appEntities = $appDescriptor.appInfo.AppComponents.Entities | Measure-Object | Select-Object -ExpandProperty Count; -foreach ($entity in ($appDescriptor.appInfo.AppComponents.Entities | Sort-Object -Property LogicalName)) { - $entityName = $entity.LogicalName - # Add the entity in the dictionary - AddOrUpdate -key ($entityName + "-list") -value (New-Object TestData($entityName, "list", 0, 0)) - AddOrUpdate -key ($entityName + "-record") -value (New-Object TestData($entityName, "record", 0, 0)) -} - if ($runTests) { $appTotal = ($appDescriptor.appInfo.AppElements.Count + ($appEntities * 2)) @@ -223,45 +614,41 @@ if ($runTests) Write-Error "App id not found. Check that the Copilot Studio Kit has been installed" return } - - # Build the latest debug version of Test Engine from source - Set-Location ..\..\src - if ($compile) { - Write-Host "Compiling the project..." - dotnet build - } else { - Write-Host "Skipping compilation..." - } - + if ($config.installPlaywright) { - Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait + # Get the absolute path to playwright.ps1 + $playwrightScriptPath = Join-Path -Path $testEnginePath -ChildPath "playwright.ps1" + # Check if the file exists + if (Test-Path -Path $playwrightScriptPath) { + Write-Host "Running Playwright installer from: $(Hide-SensitiveValue $playwrightScriptPath)" -ForegroundColor Green + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"$playwrightScriptPath install`"" -Wait + } else { + Write-Error "Playwright script not found at: $(Hide-SensitiveValue $playwrightScriptPath)" + } } else { Write-Host "Skipped playwright install" } - Set-Location ..\bin\Debug\PowerAppsTestEngine $env:user1Email = $user1Email - if ($record) { - Write-Host "========================================" -ForegroundColor Blue - Write-Host "RECODE MODE" -ForegroundColor Blue - Write-Host "========================================" -ForegroundColor Blue - } - Write-Host "========================================" -ForegroundColor Green Write-Host "ENTITIES" -ForegroundColor Green - Write-Host "========================================" -ForegroundColor Green - - # Loop through Entity (List and details) and Execute Tests + Write-Host "========================================" -ForegroundColor Green # Loop through Entity (List and details) and Execute Tests foreach ($entity in $entities) { + $formName = $entity.name + $entityName = $entity.entity + + # Skip if entity filter is specified and doesn't match current entity + if (-not [string]::IsNullOrEmpty($entityFilter) -and $entityName -notlike "*$entityFilter*" -and $formName -notlike "*$entityFilter*") { + Write-Host "Skipping $formName ($entityName) - doesn't match entity filter: $entityFilter" -ForegroundColor Gray + continue + } + Write-Host "----------------------------------------" -ForegroundColor Yellow Write-Host $entity.name -ForegroundColor Yellow Write-Host "----------------------------------------" -ForegroundColor Yellow - $formName = $entity.name - $entityName = $entity.entity - - if ($config.pages.list) { + if ($config.pages.list -and ([string]::IsNullOrEmpty($pageTypeFilter) -or $pageTypeFilter -contains "entitylist")) { $matchingScript = "$formName-list.te.yaml" if (-not (Test-Path -Path "$currentDirectory\$matchingScript") ) { @@ -274,25 +661,17 @@ if ($runTests) $response = Invoke-RestMethod -Uri $lookup -Method Get -Headers @{Authorization = "Bearer $($token.accessToken)"} $viewId = $response.value.savedqueryid - $testStart = Get-Date - $mdaUrl = "$environmentUrl/main.aspx?appid=$appId&pagetype=entitylist&etn=$entityName&viewid=$viewId&viewType=1039" - if ($record) { - # Run the tests for each user in the configuration file. - dotnet PowerAppsTestEngine.dll -c "$staticContext" -w "$debugTestValue" -u "$userAuth" -a "$authType" --provider "mda" -a "none" -r "True" -i "$currentDirectory\$matchingScript" -t $tenantId -e $environmentId -d "$mdaUrl" -l "Debug" - } else { - Write-Host "Skipped recording" - # Run the tests for each user in the configuration file. - dotnet PowerAppsTestEngine.dll -c "$staticContext" -w "$debugTestValue" -u "$userAuth" -a "$authType" --provider "mda" -a "none" -i "$currentDirectory\$matchingScript" -t $tenantId -e $environmentId -d "$mdaUrl" -l "Debug" - } - Update-TestData -folderPath $folderPath -timeThreshold $testStart -entityName $entityName -entityType "list" + # Execute the test using our helper function + $testScriptPath = "$currentDirectory\$matchingScript" + Execute-Test -testScriptPath $testScriptPath -targetUrl $mdaUrl -useStaticContextArg:$useStaticContext } else { Write-Host "Skipped list test script" } - - if ($config.pages.details) { + + if ($config.pages.details -and ([string]::IsNullOrEmpty($pageTypeFilter) -or $pageTypeFilter -contains "entityrecord")) { $matchingScript = "$formName-details.te.yaml" if (-not (Test-Path -Path "$currentDirectory\$matchingScript") ) { @@ -313,38 +692,36 @@ if ($runTests) $entityResponse = Invoke-RestMethod -Uri $lookup -Method Get -Headers @{Authorization = "Bearer $($token.accessToken)"} $recordId = $entityResponse.value | Select-Object -ExpandProperty $idColumn - $testStart = Get-Date - if ([string]::IsNullOrEmpty($recordId)) { $mdaUrl = "$environmentUrl/main.aspx?appid=$appId&pagetype=entityrecord&etn=$entityName" } else { $mdaUrl = "$environmentUrl/main.aspx?appid=$appId&pagetype=entityrecord&etn=$entityName&id=$recordId" } - - if ($record) { - # Run the tests for each user in the configuration file. - dotnet PowerAppsTestEngine.dll -c "$staticContext" -w "$debugTestValue" -u "$userAuth" -a "$authType" --provider "mda" -a "none" -r "True" -i "$currentDirectory\$matchingScript" -t $tenantId -e $environmentId -d "$mdaUrl" -l "Debug" - } else { - Write-Host "Skipped recording" - # Run the tests for each user in the configuration file. - dotnet PowerAppsTestEngine.dll -c "$staticContext" -w "$debugTestValue" -u "$userAuth" -a "$authType" --provider "mda" -a "none" -i "$currentDirectory\$matchingScript" -t $tenantId -e $environmentId -d "$mdaUrl" -l "Debug" - } - - Update-TestData -folderPath $folderPath -timeThreshold $testStart -entityName $entityName -entityType "details" + + + Write-Host "Skipped recording" + # Run the tests for each user in the configuration file. + $testScriptPath = "$currentDirectory\$matchingScript" + Execute-Test -testScriptPath $testScriptPath -targetUrl $mdaUrl -useStaticContextArg:$useStaticContext } else { Write-Host "Skipped details test script" } } - - if ($config.pages.customPage) { + + if ($config.pages.customPage -and ([string]::IsNullOrEmpty($pageTypeFilter) -or $pageTypeFilter -contains "custom")) { Write-Host "========================================" -ForegroundColor Green Write-Host "CUSTOM PAGES" -ForegroundColor Green Write-Host "========================================" -ForegroundColor Green # Loop through Custom Pages and Execute Tests foreach ($customPage in $customPages) { + # Skip if custom page filter is specified and doesn't match current custom page + if (-not [string]::IsNullOrEmpty($customPageFilter) -and $customPage -notlike "*$customPageFilter*") { + Write-Host "Skipping custom page $customPage - doesn't match filter: $customPageFilter" -ForegroundColor Gray + continue + } # Ensure testScripts is an array $testScriptList = @($testScripts.customPageTestScripts) # Extract values explicitly @@ -383,286 +760,161 @@ if ($runTests) Write-Host "----------------------------------------" -ForegroundColor Yellow Write-Host $matchingScript -ForegroundColor Yellow - Write-Host "----------------------------------------" -ForegroundColor Yellow - - $testStart = Get-Date + Write-Host "----------------------------------------" -ForegroundColor Yellow $testStart = Get-Date $mdaUrl = "$environmentUrl/main.aspx?appid=$appId&pagetype=custom&name=$customPage" - if ($record) { - # Run the tests for each user in the configuration file. - dotnet PowerAppsTestEngine.dll -c "$staticContext" -w "$debugTestValue" -u "$userAuth" -a "$authType" --provider "mda" -a "none" -r "True" -i "$currentDirectory\$matchingScript" -t $tenantId -e $environmentId -d "$mdaUrl" -l "Debug" - } else { - Write-Host "Skipped recording" - # Run the tests for each user in the configuration file. - dotnet PowerAppsTestEngine.dll -c "$staticContext" -w "$debugTestValue" -u "$userAuth" -a "$authType" --provider "mda" -a "none" -i "$currentDirectory\$matchingScript" -t $tenantId -e $environmentId -d "$mdaUrl" -l "Debug" - } + + Write-Host "Skipped recording" - Update-TestData -folderPath $folderPath -timeThreshold $testStart -entityName $customPage -entityType "custom" + # Run the tests for each user in the configuration file. + $testScriptPath = "$currentDirectory\$matchingScript" + Execute-Test -testScriptPath $testScriptPath -targetUrl $mdaUrl -useStaticContextArg:$useStaticContext } Write-Host "All custompages executed" - } - # Reset the location back to the original directory. + } # Reset the location back to the original directory. Set-Location $currentDirectory -} - -$global:healthPercentage = "" - -# Find all folders newer than the specified time -$folders = Get-ChildItem -Path $folderPath -Directory | Where-Object { $_.LastWriteTime -gt $timeThreshold } - -# Initialize arrays to store .trx files and test results -$trxFiles = @() -$testResults = @() - -# Iterate through each folder and find .trx files -foreach ($folder in $folders) { - $trxFiles += Get-ChildItem -Path $folder.FullName -Filter "*.trx" -} - -# Parse each .trx file and count pass and fail tests -foreach ($trxFile in $trxFiles) { - $xmlContent = Get-Content -Path $trxFile.FullName -Raw - $xml = [xml]::new() - $xml.LoadXml($xmlContent) - - # Create a namespace manager - $namespaceManager = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) - $namespaceManager.AddNamespace("ns", "http://microsoft.com/schemas/VisualStudio/TeamTest/2010") - - # Find the Counters element - $counters = $xml.SelectSingleNode("//ns:Counters", $namespaceManager) - - # Extract the counter properties - $total = [int]$counters.total - $executed = [int]$counters.executed - $passed = [int]$counters.passed - $failed = [int]$counters.failed - $error = [int]$counters.error - $timeout = [int]$counters.timeout - $aborted = [int]$counters.aborted - $inconclusive = [int]$counters.inconclusive - $passedButRunAborted = [int]$counters.passedButRunAborted - $notRunnable = [int]$counters.notRunnable - $notExecuted = [int]$counters.notExecuted - $disconnected = [int]$counters.disconnected - $warning = [int]$counters.warning - $completed = [int]$counters.completed - $inProgress = [int]$counters.inProgress - $pending = [int]$counters.pending - - $testResults += [PSCustomObject]@{ - File = $trxFile.FullName - Total = $total - Executed = $executed - Passed = $passed - Failed = $failed - Error = $error - Timeout = $timeout - Aborted = $aborted - Inconclusive = $inconclusive - PassedButRunAborted = $passedButRunAborted - NotRunnable = $notRunnable - NotExecuted = $notExecuted - Disconnected = $disconnected - Warning = $warning - Completed = $completed - InProgress = $inProgress - Pending = $pending + + # Update the endTimeThreshold to current time after tests have run + # Only do this if endTime wasn't explicitly provided + if (-not $endTime) { + $endTimeThreshold = Get-Date } } -# Generate HTML summary report with timestamp -$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" -$reportPath = "$folderPath\summary_report_$timestamp.html" - -# Define PreContent with injected JavaScript for badges -$preContent = @" - - - - - Test Engine Summary Report - - - - - - - -

Test Summary Report

-
- -
- #Coverage# - - - - -"@ - -# Function to generate HTML table representation of the TestData dictionary -function Generate-HTMLTable { - param ( - [hashtable]$dictionary, - [int] $total +# Function to update the run name in TRX files to make them compatible with PowerAppsTestEngine.dll report generation +function Update-TrxFilesRunName { + param( + [string]$searchPath, # Path to search for TRX files + [DateTime]$startTime, # Only process files created after this time + [string]$newRunName # The run name to insert into the TRX files ) - - # Initialize HTML table - $htmlTable = @" -

Health Check Coverage

- - - - - - - -"@ - - $numerator = 0; - # Iterate through the dictionary and add rows to the table - foreach ($key in $dictionary.Keys | Sort-Object) { - $value = $dictionary[$key] - $numerator += ($value.PassCount -gt 0) -and ($value.FailCount -eq 0) ? 1 : 0 - $htmlTable += "" + + Write-Host "Searching for TRX files in $searchPath created after $($startTime.ToString('yyyy-MM-dd HH:mm:ss'))..." -ForegroundColor Cyan + + # Get all TRX files created after the start time + $trxFiles = Get-ChildItem -Path $searchPath -Filter "*.trx" -Recurse | + Where-Object { $_.CreationTime -ge $startTime } + + if ($trxFiles.Count -eq 0) { + Write-Host "No TRX files found matching the criteria." -ForegroundColor Yellow + return } - - # Close the table - $htmlTable += "
NameTypePass CountFail Count
$($value.EntityName)$($value.EntityType)$($value.PassCount)$($value.FailCount)
" - $global:healthPercentage = $total -eq 0 ? "0" : ($numerator / $total * 100).ToString("0") - $percentage = $global:healthPercentage + "%" + Write-Host "Found $($trxFiles.Count) TRX file(s) to process." -ForegroundColor Green - # Add KaTeX code to show the calculation - $katexCode = @" - - - - -

Calculation:

-

`$`$ = \left( \frac{\text{Distinct of test types with no failures}}{\text{Total Entities}} \right) \times 100 `$`$

-

`$`$ = \left( \frac{$numerator}{$total} \right) \times 100 `$`$

-

`$`$ = $percentage `$`$%

-"@ - - $htmlTable += $katexCode - - return $htmlTable + + # Save the modified TRX file + $trxXml.Save($file.FullName) + Write-Host " Updated TRX file saved successfully." -ForegroundColor Green + } + else { Write-Host " Warning: TestRun element not found in TRX file." -ForegroundColor Yellow + } + } + catch { + Write-Host " Error processing TRX file: $($_.Exception.Message)" -ForegroundColor Red + Write-Host " Exception details: $($_.Exception.GetType().FullName)" -ForegroundColor Red + Write-Host " Error processing TRX file: $_" -ForegroundColor Red + } + } } -# Replace placeholders with actual values -$passCount = ($testResults | Measure-Object -Property Passed -Sum).Sum -$failCount = ($testResults | Measure-Object -Property Failed -Sum).Sum -$preContent = $preContent -replace "#PassCount#", $passCount -$preContent = $preContent -replace "#FailCount#", $failCount -$preContent = $preContent -replace "#Coverage#", (Generate-HTMLTable -dictionary $dictionary -total $appTotal) -$preContent = $preContent -replace "#HealthPercent#", $global:healthPercentage +if ($usePacTest) { + # When using pac test, we need to update the runName in all TRX files + # to ensure they're identified by the PowerAppsTestEngine.dll report generator + Write-Host "Processing test results from pac test run..." -ForegroundColor Cyan + + # Get the start time of this script execution as a reference point + # Only process TRX files created after this script started running + $scriptStartTime = $startTimeThreshold + + # Update the TRX files to use our runName - search in the entire TestEngine directory + Update-TrxFilesRunName -searchPath $testEngineBasePath -startTime $scriptStartTime -newRunName $runName +} -# Generate HTML report -$reportHtml = $testResults | ConvertTo-Html -Property File, Total, Executed, Passed, Failed, Error, Timeout, Aborted, Inconclusive, PassedButRunAborted, NotRunnable, NotExecuted, Disconnected, Warning, Completed, InProgress, Pending -Title "Test Summary Report" -PreContent $preContent -$reportHtml | Out-File -FilePath $reportPath +$reportPath = [System.IO.Path]::Combine($folderPath, "test_summary_$runName.html") -Write-Host "HTML summary report generated successfully at $folderPath." +# Generate report using PowerAppsTestEngine.dll directly, regardless of test execution mode +Write-Host "Generating report using PowerAppsTestEngine.dll..." -ForegroundColor Green -Write-Host "Opening report in browser..." -Write-Host $reportPath +Push-Location $testEnginePath +try { + # Create masked versions for logging + $maskedReportPath = Hide-SensitiveValue -Value $reportPath + + # Log the command being executed (with masked sensitive values) + Write-Host "Executing: dotnet PowerAppsTestEngine.dll --run-name $runName --output-file `"$maskedReportPath`" --start-time $startTimeThreshold" -ForegroundColor DarkGray + + dotnet PowerAppsTestEngine.dll --run-name $runName --output-file $reportPath --start-time $startTimeThreshold +} +finally { + Pop-Location +} -Invoke-Item $reportPath +# Report was successfully generated (either Azure DevOps style or classic) +$maskedReportPath = Hide-SensitiveValue -Value $reportPath +Write-Host "HTML summary report available at $maskedReportPath." -ForegroundColor Green + +# Open the report in the default browser +Write-Host "Opening report in browser..." -ForegroundColor Green +Start-Process $reportPath +# Add information about how to regenerate this report using the startTime and endTime parameters +# Always use the most current timestamp as the end time +$currentTime = (Get-Date).ToString("yyyy-MM-dd HH:mm") + +# Format the start time that was used - this is either the provided startTime or the current time when the script started +$formattedStartTime = $startTimeThreshold.ToString("yyyy-MM-dd HH:mm") + +Write-Host "=============================================" -ForegroundColor Magenta +Write-Host "REPORT REGENERATION INFORMATION" -ForegroundColor Magenta +Write-Host "=============================================" -ForegroundColor Magenta +Write-Host "To regenerate this exact report without running tests again, use:" -ForegroundColor Magenta +Write-Host "./RunTests.ps1 -runName `"$runName`"" -ForegroundColor Yellow +Write-Host "=============================================" -ForegroundColor Magenta diff --git a/samples/copilotstudiokit/testSettings.yaml b/samples/copilotstudiokit/testSettings.yaml index 9d65dd09..4174e7ad 100644 --- a/samples/copilotstudiokit/testSettings.yaml +++ b/samples/copilotstudiokit/testSettings.yaml @@ -4,6 +4,8 @@ headless: false recordVideo: true extensionModules: enable: true + allowPowerFxNamespaces: + - Preview parameters: enableDataverseFunctions: true timeout: 1200000 diff --git a/samples/copilotstudiokit/validate.ps1 b/samples/copilotstudiokit/validate.ps1 new file mode 100644 index 00000000..e9f50291 --- /dev/null +++ b/samples/copilotstudiokit/validate.ps1 @@ -0,0 +1,744 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Script: validate.ps1 +# This script validates that all required tools and dependencies are installed for the Copilot Studio Kit testing +# +# Parameters: +# -Fix: Automatically attempt to fix missing dependencies (not yet implemented) +# -Verbose: Enable verbose output +# -ShowSensitiveValues: Show actual values for sensitive data like email, tenant ID, environment ID +# (by default these are masked with ***) + +param( + [switch]$Fix = $false, + [switch]$Verbose = $false, + [switch]$ShowSensitiveValues = $false +) + +# If -Verbose is specified, enable verbose output system-wide +if ($Verbose) { + $VerbosePreference = "Continue" +} + +# Helper function to mask sensitive values for screen recording safety +function Hide-SensitiveValue { + param( + [string]$Value, + [int]$VisibleChars = 4 + ) + + if ([string]::IsNullOrEmpty($Value)) { + return $Value + } + + # If ShowSensitiveValues is enabled, return the value as-is + if ($ShowSensitiveValues) { + return $Value + } # For URLs, show protocol and domain structure but mask the subdomain + if ($Value -match '^(https?://)([^\.]+)\.(.+)$') { + $protocol = $matches[1] # e.g., "https://" + $subdomain = $matches[2] # the subdomain part to mask + $domain = $matches[3] # e.g., "dynamics.crm.com" + + return "$protocol****.$domain" + } + + # For file paths containing Users\username, mask the username + if ($Value -match '([A-Za-z]:\\Users\\)([^\\]+)(\\.*)?') { + $prefix = $matches[1] # e.g., "C:\Users\" + $username = $matches[2] # the username part + $suffix = $matches[3] # the rest of the path (if any) + + if ([string]::IsNullOrEmpty($suffix)) { + $suffix = "" + } + + return "$prefix***$suffix" + } + + # For emails, show first few chars and domain + if ($Value -match '^[^@]+@[^@]+$') { + $parts = $Value.Split('@') + $username = $parts[0] + $domain = $parts[1] + + if ($username.Length -le $VisibleChars) { + return "***@$domain" + } else { + return "$($username.Substring(0, $VisibleChars))***@$domain" + } + } + # Check if it's a GUID format (8-4-4-4-12 characters separated by hyphens) + if ($Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + # For GUIDs, show the first section and mask the rest + $parts = $Value.Split('-') + $firstSection = $parts[0] + $maskedParts = @($firstSection) + + # Mask remaining sections by replacing each alphanumeric character with * + for ($i = 1; $i -lt $parts.Length; $i++) { + $maskedSection = $parts[$i] -replace '[0-9a-fA-F]', '*' + $maskedParts += $maskedSection + } + + return $maskedParts -join '-' + } + + # For other long values, show first few chars + if ($Value.Length -le $VisibleChars) { + return "***" + } else { + return "$($Value.Substring(0, $VisibleChars))***" + } +} + +# Helper function to format text tables +function Format-TextTable { + param ( + [Parameter(ValueFromPipeline = $true)] + [string[]]$Data, + [int]$ColumnCount = 3 + ) + + begin { + $rows = @() + $index = 0 + $headerShown = $false + } + + process { + foreach ($item in $Data) { + if ($index % $ColumnCount -eq 0) { + # Start a new row + $currentRow = @() + $rows += $currentRow + } + + $currentRow += $item + $index++ + } + } + + end { + # Calculate column widths + $columnWidths = @(0) * $ColumnCount + foreach ($row in $rows) { + for ($i = 0; $i -lt [Math]::Min($row.Count, $ColumnCount); $i++) { + if ($row[$i].Length -gt $columnWidths[$i]) { + $columnWidths[$i] = $row[$i].Length + } + } + } + + # Output header row + Write-Host "" + $headerRow = $rows[0] + $line = "| " + for ($i = 0; $i -lt [Math]::Min($headerRow.Count, $ColumnCount); $i++) { + $line += $headerRow[$i].PadRight($columnWidths[$i]) + " | " + } + Write-Host $line -ForegroundColor Cyan + + # Output header separator + $line = "|-" + for ($i = 0; $i -lt $ColumnCount; $i++) { + $line += "-" * $columnWidths[$i] + "-|-" + } + Write-Host $line -ForegroundColor Cyan + + # Output data rows + for ($rowIndex = 1; $rowIndex -lt $rows.Count; $rowIndex++) { + $dataRow = $rows[$rowIndex] + $line = "| " + for ($i = 0; $i -lt [Math]::Min($dataRow.Count, $ColumnCount); $i++) { + $line += $dataRow[$i].PadRight($columnWidths[$i]) + " | " + } + Write-Host $line -ForegroundColor White + } + Write-Host "" + } +} + +$requiredTools = @( + @{ + Name = ".NET SDK 8.0" + Command = "dotnet --list-sdks" + TestExpression = "8\." + InstallCommand = "winget install Microsoft.DotNet.SDK.8" + InstallNotes = "You can download .NET 8.0 SDK from https://dotnet.microsoft.com/download/dotnet/8.0" + }, + @{ + Name = "PowerShell" + Command = "pwsh --version" + TestExpression = "PowerShell" + InstallCommand = "winget install --id Microsoft.PowerShell --source winget" + InstallNotes = "You can download PowerShell from https://learn.microsoft.com/powershell/scripting/install/installing-powershell" + }, @{ + Name = "Power Platform CLI" + Command = "pac help" + TestExpression = "Microsoft PowerPlatform CLI" + MinVersion = "1.43.6" + InstallCommand = "dotnet tool install --global Microsoft.PowerApps.CLI.Tool" + InstallNotes = "You can install Power Platform CLI using: dotnet tool install --global Microsoft.PowerApps.CLI.Tool. To upgrade an existing installation, use: dotnet tool update --global Microsoft.PowerApps.CLI.Tool" + }, + @{ + Name = "Git" + Command = "git --version" + TestExpression = "git version" + InstallCommand = "winget install --id Git.Git -e --source winget" + InstallNotes = "You can download Git from https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + }, + @{ + Name = "Azure CLI" + Command = "az --version" + TestExpression = "azure-cli" + InstallCommand = "winget install -e --id Microsoft.AzureCLI" + InstallNotes = "You can download Azure CLI from https://learn.microsoft.com/cli/azure/install-azure-cli" + }, + @{ + Name = "Visual Studio Code (optional)" + Command = "code --version" + TestExpression = "[0-9]+\.[0-9]+" + InstallCommand = "winget install -e --id Microsoft.VisualStudioCode" + InstallNotes = "You can download VS Code from https://code.visualstudio.com/docs/setup/setup-overview" + Optional = $true + } +) + +function Test-Command { + param ( + [string]$Command + ) + + try { + # Extract just the command name (before any arguments) + $commandName = ($Command -split ' ')[0] + + # Check if the command exists + if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) { + if ($Verbose) { + Write-Host "Command not found: $commandName" -ForegroundColor Yellow + } + return @() + } + + # Execute the command and capture all output (both standard output and error output) + $outputLines = @() + + # Use Start-Process for better control over execution and output + $tempFile = [System.IO.Path]::GetTempFileName() + try { + # Construct the command with arguments + $commandArgs = $Command.Substring($commandName.Length).Trim() + + if ($Verbose) { + Write-Host "Executing: $commandName $commandArgs" -ForegroundColor DarkCyan + } + + # Execute the command and redirect output to a temp file + if ($commandArgs) { + $process = Start-Process -FilePath $commandName -ArgumentList $commandArgs -NoNewWindow -Wait -RedirectStandardOutput $tempFile -RedirectStandardError "${tempFile}.err" -PassThru + } else { + $process = Start-Process -FilePath $commandName -NoNewWindow -Wait -RedirectStandardOutput $tempFile -RedirectStandardError "${tempFile}.err" -PassThru + } + + # Read output from temp files + if (Test-Path $tempFile) { + $outputLines += Get-Content -Path $tempFile -ErrorAction SilentlyContinue + } + + if (Test-Path "${tempFile}.err") { + $errorContent = Get-Content -Path "${tempFile}.err" -ErrorAction SilentlyContinue + $outputLines += $errorContent + + if ($Verbose -and $errorContent) { + Write-Host "Command returned error output:" -ForegroundColor Yellow + $errorContent | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + } + } + + # Check if command executed successfully + if ($process.ExitCode -ne 0 -and $outputLines.Count -eq 0) { + if ($Verbose) { + Write-Host "Command failed with exit code: $($process.ExitCode)" -ForegroundColor Red + } + return @() + } + } + finally { + # Clean up temp files + if (Test-Path $tempFile) { Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue } + if (Test-Path "${tempFile}.err") { Remove-Item -Path "${tempFile}.err" -Force -ErrorAction SilentlyContinue } + } + + if ($outputLines.Count -gt 0) { + if ($Verbose) { + Write-Host "Command output ($($outputLines.Count) lines):" -ForegroundColor DarkCyan + $outputLines | Select-Object -First 5 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + if ($outputLines.Count -gt 5) { + Write-Host " ... and $($outputLines.Count - 5) more lines" -ForegroundColor DarkGray + } + } + return $outputLines + } + else { + if ($Verbose) { + Write-Host "Command produced no output but executed successfully" -ForegroundColor DarkCyan + } + # Return an array with a single "Command exists" string if no output + return @("Command exists") + } + } + catch { + if ($Verbose) { + Write-Host "Error executing command: $Command" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + } + return @() + } +} + +function Format-ValidationOutput { + param ( + [string]$Name, + [bool]$IsInstalled, + [bool]$IsOptional = $false, + [string]$Version = $null, + [string]$MinVersion = $null + ) + $status = if ($IsInstalled) { + "[OK] Installed" + } elseif ($IsOptional) { + "[!] Not installed (Optional)" + } elseif ($Version -and $MinVersion) { + "[X] Installed (v$Version) but requires v$MinVersion or higher" + } else { + "[X] Not installed" + } + + $color = if ($IsInstalled) { "Green" } else { if ($IsOptional) { "Yellow" } else { "Red" } } + + Write-Host "$Name : " -NoNewline + Write-Host $status -ForegroundColor $color +} + +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host "Copilot Studio Kit - Environment Validation Tool" -ForegroundColor Cyan +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host "" + +$allRequirementsMet = $true +$installCommands = @() + +foreach ($tool in $requiredTools) { + if ($Verbose) { + Write-Host "Checking for tool: $($tool.Name)" -ForegroundColor Cyan + } + + $resultLines = Test-Command -Command $tool.Command + $isInstalled = $false + $version = $null + + if ($resultLines.Count -gt 0) { + # Get the pattern + $pattern = $tool.TestExpression + + # Check each line of output for a match + foreach ($line in $resultLines) { + if ($line -match $pattern) { + $isInstalled = $true + + # Handle version checking for Power Platform CLI + if ($tool.Name -eq "Power Platform CLI" -and $tool.MinVersion) { + # Look for the version line in the output + foreach ($versionLine in $resultLines) { + if ($versionLine -match "Version:\s+([\d\.]+)") { + $version = $matches[1] + + # Compare versions + $minVersionRequired = [version]$tool.MinVersion + $currentVersion = [version]($version -replace '(\d+\.\d+\.\d+).*', '$1') # Extract just major.minor.patch + + if ($currentVersion -lt $minVersionRequired) { + $isInstalled = $false + if ($Verbose) { + Write-Host " Power Platform CLI version $currentVersion is less than required $minVersionRequired" -ForegroundColor Yellow + } + } + else { + if ($Verbose) { + Write-Host " Power Platform CLI version $currentVersion meets minimum required $minVersionRequired" -ForegroundColor Green + } + } + break + } + } + } + break + } + } + + # Debug output to help troubleshoot + if ($Verbose) { + Write-Host " Tool: $($tool.Name)" -ForegroundColor Cyan + Write-Host " Pattern: $pattern" -ForegroundColor Cyan + Write-Host " Lines found: $($resultLines.Count)" -ForegroundColor Cyan + Write-Host " First few lines:" -ForegroundColor Cyan + $resultLines | Select-Object -First 3 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + if ($version) { + Write-Host " Version detected: $version" -ForegroundColor Cyan + } + Write-Host " Installed: $isInstalled" -ForegroundColor $(if ($isInstalled) { "Green" } else { "Yellow" }) + } + } + + Format-ValidationOutput -Name $tool.Name -IsInstalled $isInstalled -IsOptional ($tool.Optional -eq $true) -Version $version -MinVersion $tool.MinVersion + + if (-not $isInstalled -and (-not $tool.Optional)) { + $allRequirementsMet = $false + $installCommands += @{ + Name = $tool.Name + Command = $tool.InstallCommand + Notes = $tool.InstallNotes + } + } +} + +Write-Host "" +if ($allRequirementsMet) { + Write-Host "All required tools are installed. You're ready to run the tests!" -ForegroundColor Green +} else { + Write-Host "Some required tools are missing. Please install them before running the tests." -ForegroundColor Red + Write-Host "" + Write-Host "Installation commands:" -ForegroundColor Yellow + + foreach ($cmd in $installCommands) { + Write-Host "" + Write-Host "$($cmd.Name):" -ForegroundColor Cyan + Write-Host " Command: $($cmd.Command)" + Write-Host " Notes: $($cmd.Notes)" + + if ($Fix) { + Write-Host "" + Write-Host "Attempting to install $($cmd.Name)..." -ForegroundColor Cyan + try { + Invoke-Expression $cmd.Command + Write-Host "Installation completed. Please restart the PowerShell session." -ForegroundColor Green + } + catch { + Write-Host "Installation failed. Please install manually." -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + } + } + } + + if (-not $Fix) { + Write-Host "" + Write-Host "You can run this script with -Fix parameter to attempt automatic installation of missing components:" -ForegroundColor Yellow + Write-Host " .\validate.ps1 -Fix" + } +} + +# Function to check if current directory is part of PowerApps-TestEngine +function Test-IsInTestEngineRepo { + try { + # Get the git root directory + $gitRootDir = git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -ne 0) { + # Not in a git repo + return $false + } + + # Check if the directory name indicates it's the PowerApps-TestEngine repo + $dirName = Split-Path -Path $gitRootDir -Leaf + if ($dirName -eq "PowerApps-TestEngine") { + return $true + } + + # Check remote URLs for PowerApps-TestEngine + $remotes = git remote -v 2>$null + foreach ($remote in $remotes) { + if ($remote -like "*PowerApps-TestEngine*") { + return $true + } + } + + return $false + } + catch { + return $false + } +} + +# Check for Power Platform Test Engine +Write-Host "" +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host "Power Platform Test Engine Status" -ForegroundColor Cyan +Write-Host "==================================================" -ForegroundColor Cyan + +# Check if we're already in the PowerApps-TestEngine repository +$isInTestEngineRepo = Test-IsInTestEngineRepo + +if ($isInTestEngineRepo) { + Write-Host "Detected current directory is part of PowerApps-TestEngine repository" -ForegroundColor Green + + # Get the root directory of the repository + $repoRootDir = git rev-parse --show-toplevel 2>$null + + # Check if it's properly built + $binPath = Join-Path -Path $repoRootDir -ChildPath "bin\Debug\PowerAppsTestEngine\PowerAppsTestEngine.dll" + if (Test-Path $binPath) { + $maskedBinPath = Hide-SensitiveValue -Value $binPath + Write-Host "PowerAppsTestEngine.dll is available at: $maskedBinPath" -ForegroundColor Green + } else { + Write-Host "PowerAppsTestEngine.dll is not built yet. Run 'dotnet build' in the src directory to build it." -ForegroundColor Yellow + } +} else { + # Check for local PowerApps-TestEngine subdirectory + $testEnginePath = Join-Path -Path $PSScriptRoot -ChildPath "PowerApps-TestEngine" + if (Test-Path $testEnginePath) { + $maskedTestEnginePath = Hide-SensitiveValue -Value $testEnginePath + Write-Host "Power Platform Test Engine is available at: $maskedTestEnginePath" -ForegroundColor Green + + # Check if it's properly built + $binPath = Join-Path -Path $testEnginePath -ChildPath "bin\Debug\PowerAppsTestEngine\PowerAppsTestEngine.dll" + if (Test-Path $binPath) { + Write-Host "PowerAppsTestEngine.dll is available." -ForegroundColor Green + } else { + Write-Host "PowerAppsTestEngine.dll is not built yet. Run RunTests.ps1 to clone and build it." -ForegroundColor Yellow + } + } else { + Write-Host "Power Platform Test Engine is not cloned yet. Run RunTests.ps1 to clone and build it." -ForegroundColor Yellow + } +} + +# Check for environment configuration +$configPath = Join-Path -Path $PSScriptRoot -ChildPath ".\config.json" +if (Test-Path $configPath) { + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host "Environment Configuration Status" -ForegroundColor Cyan + Write-Host "==================================================" -ForegroundColor Cyan + $maskedConfigPath = Hide-SensitiveValue -Value $configPath + Write-Host "Configuration file found at: $maskedConfigPath" -ForegroundColor Green + + try { + $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json + $envId = $config.environmentId + $envUrl = $config.environmentUrl + $tenantId = $config.tenantId + if ([string]::IsNullOrEmpty($envId) -or $envId -eq "00000000-0000-0000-0000-000000000000") { + Write-Host "Environment ID is not properly configured." -ForegroundColor Red + } else { + $maskedEnvId = Hide-SensitiveValue -Value $envId + Write-Host "Environment ID is configured: $maskedEnvId" -ForegroundColor Green + } + if ([string]::IsNullOrEmpty($tenantId) -or $tenantId -eq "00000000-0000-0000-0000-000000000000") { + Write-Host "Tenant ID is not properly configured." -ForegroundColor Red + } else { + $maskedTenantId = Hide-SensitiveValue -Value $tenantId + Write-Host "Tenant ID is configured: $maskedTenantId" -ForegroundColor Green + + # Check if Azure CLI is installed and check if tenant ID matches + $azCliResult = Test-Command -Command "az --version" + if ($azCliResult.Count -gt 0) { + Write-Host "Checking if tenant ID matches Azure account..." -ForegroundColor Yellow + try { + $azAccountOutput = Test-Command -Command "az account show --query tenantId -o tsv" + if ($azAccountOutput.Count -gt 0) { + # Join all output lines and trim whitespace + $azureTenantId = ($azAccountOutput -join "").Trim() + + if (-not [string]::IsNullOrEmpty($azureTenantId)) + { + if ($tenantId -eq $azureTenantId) + { + Write-Host "Tenant ID in config.json matches Azure account tenant ID." -ForegroundColor Green + + # Now validate the environment ID using Power Platform API + Write-Host "Validating Power Platform environment ID..." -ForegroundColor Yellow + + try { + # Get access token for Power Platform API + $environments = Test-Command -Command "pac env list --json" + if ($environments.Count -gt 0) { + $json = ($environments -join "").Trim() + if (-not [string]::IsNullOrEmpty($json)) { + $data = $json | ConvertFrom-Json + + # Trim the trailing slash from $envUrl for comparison + $normalizedEnvUrl = $envUrl.TrimEnd('/') + + # Add verbose output for debugging + Write-Verbose "Config Environment URL: $envUrl" + Write-Verbose "Normalized Environment URL for comparison: $normalizedEnvUrl" + + $environmentMatch = $data | Where-Object { + # Also normalize the environment URLs from pac command by trimming any possible trailing slashes + $pacEnvUrl = $_.EnvironmentUrl.TrimEnd('/') + Write-Verbose "Comparing with PAC Environment URL: $($_.EnvironmentUrl) (Normalized: $pacEnvUrl)" + $pacEnvUrl -eq $normalizedEnvUrl + } + if ($environmentMatch -and $environmentMatch.Count -eq 1 ) + { + $maskedEnvUrl = Hide-SensitiveValue -Value $envUrl + Write-Host "Environment Match found: $maskedEnvUrl $($environmentMatch[0].FriendlyName)" -ForegroundColor Green + if (-not ($environmentMatch[0].EnvironmentIdentifier.Id -eq $envId)) { + $maskedConfigEnvId = Hide-SensitiveValue -Value $envId + $maskedPacEnvId = Hide-SensitiveValue -Value $environmentMatch[0].EnvironmentIdentifier.Id + Write-Host "WARNING: Environment ID in config.json ($maskedConfigEnvId) does not match PAC environment ID ($maskedPacEnvId)!" -ForegroundColor Yellow + } + else { + $maskedEnvId = Hide-SensitiveValue -Value $envId + Write-Host "Environment ID matches: $maskedEnvId" -ForegroundColor Green + } + } + else + { + Write-Host "Error validating environment ID" -ForegroundColor Red + foreach ($env in $data) { + $maskedEnvironmentUrl = Hide-SensitiveValue -Value $env.EnvironmentUrl + $maskedEnvironmentId = Hide-SensitiveValue -Value $env.EnvironmentIdentifier.Id + Write-Host "Environment: $maskedEnvironmentUrl - $($env.FriendlyName) (ID: $maskedEnvironmentId)" + } + } + } + else + { + Write-Host "Could not parse environment data from PAC command." -ForegroundColor Yellow + } + } + else + { + Write-Host "Failed to retrieve environments from PAC command" -ForegroundColor Yellow + } + } + catch { + Write-Host "Error validating environment ID: $_" -ForegroundColor Red + } + } + else + { + $maskedConfigTenantId = Hide-SensitiveValue -Value $tenantId + $maskedAzureTenantId = Hide-SensitiveValue -Value $azureTenantId + Write-Host "WARNING: Tenant ID in config.json ($maskedConfigTenantId) does not match Azure account tenant ID ($maskedAzureTenantId)!" -ForegroundColor Red + Write-Host " You may need to run 'az login' with the correct account or update your config.json." -ForegroundColor Yellow + } + } else { + Write-Host "Could not retrieve Azure tenant ID. Are you logged in? Try running 'az login'." -ForegroundColor Yellow + } + } else { + Write-Host "Not logged in to Azure CLI. Run 'az login' to authenticate." -ForegroundColor Yellow + } + } + catch { + Write-Host "Error checking Azure tenant ID: $_" -ForegroundColor Red + Write-Host "Make sure you're logged in to Azure CLI with 'az login'" -ForegroundColor Yellow + } + } + } + } + catch { + Write-Host "Error parsing config.json. Please check the file format." -ForegroundColor Red + } +} else { + Write-Host "" + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host "Environment Configuration Status" -ForegroundColor Cyan + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host "Configuration file not found. Please create config.json with your environment settings." -ForegroundColor Red +} + +Write-Host "" +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host "Storage State Credential Validation" -ForegroundColor Cyan +Write-Host "==================================================" -ForegroundColor Cyan + +if (Test-Path $configPath) { + try { + # Check if user1Email is specified in config.json + if ($config.user1Email) { + $user1Email = $config.user1Email + $maskedUser1Email = Hide-SensitiveValue -Value $user1Email + Write-Host "User email found: $maskedUser1Email" -ForegroundColor Green + + # Extract username without domain for storage state file naming + $userNameOnly = $user1Email.Split('@')[0] + $maskedUserNameOnly = Hide-SensitiveValue -Value $userNameOnly + Write-Host "Username for storage state: $maskedUserNameOnly" -ForegroundColor Green + + # Define the path to TestEngine temp directory + $testEngineStorageDir = "$env:USERPROFILE\AppData\Local\Temp\Microsoft\TestEngine" + # Check if TestEngine directory exists + if (Test-Path -Path $testEngineStorageDir) { + $maskedTestEngineStorageDir = Hide-SensitiveValue -Value $testEngineStorageDir + Write-Host "TestEngine directory found: $maskedTestEngineStorageDir" -ForegroundColor Green + + # Look for storage state file matching the username + $storageFiles = Get-ChildItem -Path $testEngineStorageDir -Filter ".storage-state-$userNameOnly*" -ErrorAction SilentlyContinue + if ($storageFiles.Count -gt 0) { + Write-Host "Found $($storageFiles.Count) storage state file(s) for $($maskedUserNameOnly):" -ForegroundColor Green + + foreach ($file in $storageFiles) { + $maskedFilePath = Hide-SensitiveValue -Value $file.FullName + Write-Host " - $maskedFilePath" -ForegroundColor Green + + # Check state.json file + $stateJsonPath = Join-Path -Path $file.FullName -ChildPath "state.json" + if (Test-Path -Path $stateJsonPath) { + Write-Host " - state.json exists" -ForegroundColor Green + + # Try to read the file content + try { + $content = Get-Content -Path $stateJsonPath -Raw -ErrorAction Stop + + # Check if the content looks like base64 + $isBase64 = $content -match '^[a-zA-Z0-9+/]+={0,2}$' + + if ($isBase64) { + Write-Host " - state.json content appears to be Base64 encoded" -ForegroundColor Green + # Try to decode Base64 + try { + $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($content)) + Write-Host " - Base64 content successfully decoded" -ForegroundColor Green + } + catch { + Write-Host " - Failed to decode Base64 content: $_" -ForegroundColor Red + } + } + else { + Write-Host " - state.json content is not Base64 encoded" -ForegroundColor Red + } + } + catch { + Write-Host " - Failed to read state.json: $_" -ForegroundColor Red + } + } + else { + Write-Host " - state.json file not found" -ForegroundColor Red + } + } + } else { + Write-Host "No storage state files found for $maskedUserNameOnly" -ForegroundColor Yellow + Write-Host "Run RunTests.ps1 first to create credentials or check if user email is correct" -ForegroundColor Yellow + }} + else { + $maskedTestEngineStorageDir = Hide-SensitiveValue -Value $testEngineStorageDir + Write-Host "TestEngine directory not found: $maskedTestEngineStorageDir" -ForegroundColor Yellow + Write-Host "Run RunTests.ps1 first to create the directory and credentials" -ForegroundColor Yellow + } + } + else { + Write-Host "user1Email not specified in config.json" -ForegroundColor Yellow + } + } + catch { + Write-Host "Error checking storage state: $_" -ForegroundColor Red + } +} +else { + Write-Host "Configuration file not found. Cannot check storage state." -ForegroundColor Red +} + +Write-Host "" +Write-Host "==================================================" -ForegroundColor Cyan diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/ConsoleOutputTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/ConsoleOutputTests.cs index 4a149325..610a6533 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/ConsoleOutputTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/ConsoleOutputTests.cs @@ -2,9 +2,7 @@ // Licensed under the MIT license. using System; -using System.Collections.Generic; using System.IO; -using Microsoft.PowerApps.TestEngine.Config; using Microsoft.PowerApps.TestEngine.System; using Xunit; diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs index cf8840b0..cf682984 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs @@ -360,17 +360,17 @@ public static IEnumerable GetTableData() var dateUnixValue = new DateTimeOffset(dateValue).ToUnixTimeMilliseconds(); yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", StringType.String)), "{PropertyValue: 'A'}", "[{'Test': \"A\"}]", "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Number)), "{PropertyValue: 1}", "[{'Test': 1}]" , "mda"}; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Decimal)), "{PropertyValue: 1.1}", "[{'Test': 1.1}]" , "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Number)), "{PropertyValue: 1}", "[{'Test': 1}]", "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Decimal)), "{PropertyValue: 1.1}", "[{'Test': 1.1}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: true}", "[{'Test': true}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: 'true'}", "[{'Test': true}]", "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: false}", "[{'Test': false}]" , "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: 'false'}", "[{'Test': false}]" , "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: false}", "[{'Test': false}]", "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: 'false'}", "[{'Test': false}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", DateTimeType.DateTime)), $"{{PropertyValue: {dateTimeValue}}}", $"[{{'Test': \"{dateTime.ToString("o")}\"}}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", DateTimeType.Date)), $"{{PropertyValue: {dateUnixValue}}}", $"[{{'Test': \"{dateValue.ToString("o")}\"}}]", "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", StringType.String)), "{PropertyValue: 'A'}", "[{'Test': \"A\"}]", string.Empty}; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", StringType.String)), "{PropertyValue: 'A'}", "[{'Test': \"A\"}]", string.Empty }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Number)), "{PropertyValue: 1}", "[{'Test': 1}]", string.Empty }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Decimal)), "{PropertyValue: 1.1}", "[{'Test': 1.1}]", string.Empty }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: true}", "[{'Test': true}]", "canvas" }; diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/GroupTestsByRunTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/GroupTestsByRunTests.cs new file mode 100644 index 00000000..3dd4cf4a --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/GroupTestsByRunTests.cs @@ -0,0 +1,404 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.PowerApps.TestEngine.Reporting; +using Microsoft.PowerApps.TestEngine.Reporting.Format; +using Microsoft.PowerApps.TestEngine.System; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.Reporting +{ + /// + /// Tests for the GroupTestsByRun method in TestRunSummary + /// + public class GroupTestsByRunTests + { + private Mock MockFileSystem; + + public GroupTestsByRunTests() + { + MockFileSystem = new Mock(MockBehavior.Strict); + } /// + /// Helper method to create a test run with a custom app URL + /// + private TestRun CreateTestRunWithAppUrl(string testName, string appUrl, bool passed = true) + { + var testRun = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = testName, + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = testName, + Outcome = passed ? TestReporter.PassedResultOutcome : TestReporter.FailedResultOutcome, + StartTime = DateTime.Now, + EndTime = DateTime.Now.AddSeconds(10), + Duration = "00:00:10", + Output = new TestOutput + { + StdOut = "Test output" + } + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput + { + StdOut = $"{{ \"AppURL\": \"{appUrl}\" }}" + } + } + }; + return testRun; + } + + [Fact] + public void GroupTestsByRun_ModelDrivenAppEntityList_GroupsByEntityName() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var testRuns = new List + { + CreateTestRunWithAppUrl("Account List Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account"), + CreateTestRunWithAppUrl("Another Account List Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account"), + CreateTestRunWithAppUrl("Contact List Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=contact") + }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Equal(2, result.Count); // Should have 2 groups: account and contact + Assert.True(result.ContainsKey("account")); + Assert.True(result.ContainsKey("contact")); + Assert.Equal(2, result["account"].Count); // Two account tests + Assert.Single(result["contact"]); // One contact test + } + + [Fact] + public void GroupTestsByRun_ModelDrivenAppEntityRecords_GroupsByEntityName() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var testRuns = new List + { + CreateTestRunWithAppUrl("Account Record Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entity&etn=account&id=123"), + CreateTestRunWithAppUrl("Another Account Record Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entity&etn=account&id=456"), + CreateTestRunWithAppUrl("Contact Record Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entity&etn=contact&id=789") + }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Equal(2, result.Count); // Should have 2 groups: account and contact + Assert.True(result.ContainsKey("account")); + Assert.True(result.ContainsKey("contact")); + Assert.Equal(2, result["account"].Count); // Two account tests + Assert.Single(result["contact"]); // One contact test + } + + [Fact] + public void GroupTestsByRun_ModelDrivenAppCustomPageWithName_GroupsByPageName() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var testRuns = new List + { + CreateTestRunWithAppUrl("Dashboard Custom Page", "https://contoso.crm.dynamics.com/main.aspx?pagetype=custom&name=dashboard"), + CreateTestRunWithAppUrl("Another Dashboard Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=custom&name=dashboard"), + CreateTestRunWithAppUrl("Settings Custom Page", "https://contoso.crm.dynamics.com/main.aspx?pagetype=custom&name=settings") + }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Equal(2, result.Count); // Should have 2 groups: dashboard and settings + Assert.True(result.ContainsKey("dashboard")); + Assert.True(result.ContainsKey("settings")); + Assert.Equal(2, result["dashboard"].Count); // Two dashboard tests + Assert.Single(result["settings"]); // One settings test + } + + [Fact] + public void GroupTestsByRun_ModelDrivenAppCustomPageType_GroupsByPageType() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var testRuns = new List + { + CreateTestRunWithAppUrl("Dashboard Page Type", "https://contoso.crm.dynamics.com/main.aspx?pagetype=dashboard"), + CreateTestRunWithAppUrl("Another Dashboard Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=dashboard"), + CreateTestRunWithAppUrl("Settings Page Type", "https://contoso.crm.dynamics.com/main.aspx?pagetype=settings") + }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Equal(2, result.Count); // Should have 2 groups: dashboard and settings + Assert.True(result.ContainsKey("dashboard")); + Assert.True(result.ContainsKey("settings")); + Assert.Equal(2, result["dashboard"].Count); // Two dashboard tests + Assert.Single(result["settings"]); // One settings test + } + [Fact] + public void GroupTestsByRun_MixedTestTypes_GroupsCorrectly() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var testRuns = new List + { + // Entity list + CreateTestRunWithAppUrl("Account List Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account"), + // Entity record + CreateTestRunWithAppUrl("Account Record Test", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entity&etn=account&id=123"), + // Custom page with name + CreateTestRunWithAppUrl("Dashboard Custom Page", "https://contoso.crm.dynamics.com/main.aspx?pagetype=custom&name=test"), + // Custom page type + CreateTestRunWithAppUrl("Dashboard Page Type", "https://contoso.crm.dynamics.com/main.aspx?pagetype=dashboard") + }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Equal(3, result.Count); // Should have 3 groups: account, test, and dashboard + Assert.True(result.ContainsKey("account")); + Assert.True(result.ContainsKey("test")); + Assert.True(result.ContainsKey("dashboard")); + Assert.Equal(2, result["account"].Count); // Two account tests (list and record) + Assert.Single(result["test"]); // One dashboard custom page test + Assert.Single(result["dashboard"]); // One dashboard page type test + } + + [Fact] + public void GroupTestsByRun_NoAppUrl_GroupsByTestRunName() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + // Create test runs without app URLs + var testRun1 = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = "Account Tests", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test1", + Outcome = TestReporter.PassedResultOutcome, + Output = new TestOutput { StdOut = "Some output without AppURL" } + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput { StdOut = "No AppURL here" } + } + }; + + var testRun2 = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = "Contact Tests", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test2", + Outcome = TestReporter.PassedResultOutcome, + Output = new TestOutput { StdOut = "Some output without AppURL" } + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput { StdOut = "Different output without AppURL" } + } + }; + + var testRuns = new List { testRun1, testRun2 }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Equal(2, result.Count); // Should have 2 groups based on test run names + Assert.True(result.ContainsKey("Account Tests")); + Assert.True(result.ContainsKey("Contact Tests")); + Assert.Single(result["Account Tests"]); + Assert.Single(result["Contact Tests"]); + } + + [Fact] + public void GroupTestsByRun_WithNullResultSummary_GroupsByTestRunName() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Create test run with null ResultSummary + var testRun1 = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = "Test Run With Null ResultSummary", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test1", + Outcome = TestReporter.PassedResultOutcome + } + } + }, + ResultSummary = null + }; + + // Create test run with null Output in ResultSummary + var testRun2 = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = "Test Run With Null Output", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test2", + Outcome = TestReporter.PassedResultOutcome + } + } + }, + ResultSummary = new TestResultSummary + { + Output = null + } + }; + + var testRuns = new List { testRun1, testRun2 }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Equal(2, result.Count); + Assert.True(result.ContainsKey("Test Run With Null ResultSummary")); + Assert.True(result.ContainsKey("Test Run With Null Output")); + Assert.Single(result["Test Run With Null ResultSummary"]); + Assert.Single(result["Test Run With Null Output"]); + } + + [Fact] + public void GroupTestsByRun_JsonAppUrlInSummary_ParsesCorrectly() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Create test run with JSON AppURL in ResultSummary.Output.StdOut + var testRun = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = "JSON Test Run", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test1", + Outcome = TestReporter.PassedResultOutcome, + Output = new TestOutput { StdOut = "Some regular output" } + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput + { + StdOut = @"{ + ""AppURL"": ""https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=contact"", + ""VideoPath"": ""C:\\path\\to\\video.mp4"", + ""OtherProperty"": ""value"" + }" + } + } + }; + + var testRuns = new List { testRun }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Single(result); + Assert.True(result.ContainsKey("contact")); + Assert.Single(result["contact"]); + } + + [Fact] + public void GroupTestsByRun_NullOutput_HandlesGracefully() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Create test run with null Output in UnitTestResult + var testRun = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = "Test With Null Output", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test1", + Outcome = TestReporter.PassedResultOutcome, + Output = null + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput + { + StdOut = @"{ ""AppURL"": ""https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account"" }" + } + } + }; + + var testRuns = new List { testRun }; + + // Act + var result = testRunSummary.GroupTestsByRun(testRuns); + + // Assert + Assert.Single(result); + Assert.True(result.ContainsKey("account")); + Assert.Single(result["account"]); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestReporterTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestReporterTests.cs index 9c4de855..fd3138d6 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestReporterTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestReporterTests.cs @@ -380,6 +380,7 @@ public void GenerateTestReportTest() var resultDirectory = "C:\\results"; var testReporter = new TestReporter(MockFileSystem.Object); var testRunId = testReporter.CreateTestRun(testRunName, testUser); + testReporter.TestRunAppURL = "someAppURL"; testReporter.TestResultsDirectory = "someResultsDirectory"; diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryCommandTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryCommandTests.cs new file mode 100644 index 00000000..bfa7e3d3 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryCommandTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.IO; +using Microsoft.PowerApps.TestEngine.Reporting; +using Microsoft.PowerApps.TestEngine.System; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.Reporting +{ + public class TestRunSummaryCommandTests + { + private Mock MockFileSystem; + private Mock MockTestRunSummary; + + public TestRunSummaryCommandTests() + { + MockFileSystem = new Mock(MockBehavior.Strict); + MockTestRunSummary = new Mock(MockBehavior.Strict); + } + + [Theory] + [InlineData("")] + public void ThrowsOnInvalidResultsDirectoryTest(string resultsDirectory) + { + var command = new TestRunSummaryCommand(MockTestRunSummary.Object, MockFileSystem.Object); + Assert.Throws(() => command.GenerateSummaryReport(resultsDirectory)); + } + + [Fact] + public void ThrowsOnNullResultsDirectoryTest() + { + var command = new TestRunSummaryCommand(MockTestRunSummary.Object, MockFileSystem.Object); + Assert.Throws(() => command.GenerateSummaryReport(null)); + } + + [Fact] + public void ThrowsOnNonExistentResultsDirectoryTest() + { + var resultsDirectory = @"C:\NonExistentDirectory"; + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(false); + + var command = new TestRunSummaryCommand(MockTestRunSummary.Object, MockFileSystem.Object); + Assert.Throws(() => command.GenerateSummaryReport(resultsDirectory)); + } + + [Fact] + public void GenerateSummaryReportWithDefaultOutputPathTest() + { + var resultsDirectory = @"C:\TestResults"; + var generatedOutputPath = @"C:\TestResults\TestRunSummary_20250528_235959.html"; + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockTestRunSummary.Setup(x => x.GenerateSummaryReport(resultsDirectory, It.IsAny(), null)).Returns(generatedOutputPath); + + var command = new TestRunSummaryCommand(MockTestRunSummary.Object, MockFileSystem.Object); + var result = command.GenerateSummaryReport(resultsDirectory); + + Assert.Equal(generatedOutputPath, result); + MockTestRunSummary.Verify(x => x.GenerateSummaryReport(resultsDirectory, It.IsAny(), null), Times.Once); + } + + [Fact] + public void GenerateSummaryReportWithSpecifiedOutputPathTest() + { + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\Output\summary.html"; + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockTestRunSummary.Setup(x => x.GenerateSummaryReport(resultsDirectory, outputPath, null)).Returns(outputPath); + + var command = new TestRunSummaryCommand(MockTestRunSummary.Object, MockFileSystem.Object); + var result = command.GenerateSummaryReport(resultsDirectory, outputPath); + + Assert.Equal(outputPath, result); + MockTestRunSummary.Verify(x => x.GenerateSummaryReport(resultsDirectory, outputPath, null), Times.Once); + } + + [Fact] + public void GeneratesSummaryWithTimestampInFilename() + { + var resultsDirectory = @"C:\TestResults"; + var timestampPattern = @"TestRunSummary_\d{8}_\d{6}\.html"; + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockTestRunSummary.Setup(x => x.GenerateSummaryReport(resultsDirectory, It.IsRegex(timestampPattern), null)).Returns(@"C:\TestResults\TestRunSummary_20250528_123456.html"); + + var command = new TestRunSummaryCommand(MockTestRunSummary.Object, MockFileSystem.Object); + var result = command.GenerateSummaryReport(resultsDirectory); + + Assert.Matches(timestampPattern, Path.GetFileName(result)); + MockTestRunSummary.Verify(x => x.GenerateSummaryReport(resultsDirectory, It.IsRegex(timestampPattern), null), Times.Once); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryTests.cs new file mode 100644 index 00000000..e9fae8b7 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryTests.cs @@ -0,0 +1,905 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Xml; +using System.Xml.Serialization; +using Microsoft.PowerApps.TestEngine.Reporting; +using Microsoft.PowerApps.TestEngine.Reporting.Format; +using Microsoft.PowerApps.TestEngine.System; +using Moq; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.Reporting +{ + public class TestRunSummaryTests + { + private Mock MockFileSystem; + + public TestRunSummaryTests() + { + MockFileSystem = new Mock(MockBehavior.Strict); + } + + [Theory] + [InlineData("")] + public void ThrowsOnInvalidResultsDirectoryTest(string resultsDirectory) + { + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + Assert.Throws(() => testRunSummary.GenerateSummaryReport(resultsDirectory, "outputPath")); + } + + [Fact] + public void ThrowsOnNullResultsDirectoryTest() + { + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + Assert.Throws(() => testRunSummary.GenerateSummaryReport(null, "outputPath")); + } + + [Theory] + [InlineData("")] + public void ThrowsOnInvalidOutputPathTest(string outputPath) + { + var resultsDirectory = @"C:\TestResults"; + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + Assert.Throws(() => testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath)); + } + [Fact] + public void ThrowsOnNullOutputPathTest() + { + var resultsDirectory = @"C:\TestResults"; + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + Assert.Throws(() => testRunSummary.GenerateSummaryReport(resultsDirectory, null)); + } + + [Fact] + public void ThrowsOnNonExistentResultsDirectoryTest() + { + var resultsDirectory = @"C:\NonExistentDirectory"; + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(false); + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + Assert.Throws(() => testRunSummary.GenerateSummaryReport(resultsDirectory, "outputPath")); + } + [Fact] + public void GenerateSummaryReportTest() + { + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\output\summary.html"; + var outputDirectory = @"C:\output"; + var trxFile1 = @"C:\TestResults\Results_1.trx"; + var trxFile2 = @"C:\TestResults\Results_2.trx"; + + // Setup mock file system + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + + // Setup mock directory for output + MockFileSystem.Setup(x => x.Exists(outputDirectory)).Returns(false); + MockFileSystem.Setup(x => x.CreateDirectory(outputDirectory)); + + // Setup mock TRX file content + var testRun1 = CreateDummyTestRun("Test Run 1", true); + var testRun2 = CreateDummyTestRun("Test Run 2", false); + testRun1.Name = "Test Run 1"; + testRun2.Name = "Test Run 2"; + + // Serialize test runs to XML + string testRun1Xml = SerializeTestRun(testRun1); + string testRun2Xml = SerializeTestRun(testRun2); + + MockFileSystem.Setup(x => x.ReadAllText(trxFile1)).Returns(testRun1Xml); + MockFileSystem.Setup(x => x.ReadAllText(trxFile2)).Returns(testRun2Xml); + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)); + MockFileSystem.Setup(x => x.ReadAllText(It.IsAny())).Returns("{{TEMPLATE}}"); + + // Execute the method + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + testRunSummary.GetTrxFiles = (directory) => new[] { trxFile1, trxFile2 }; + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Verify the result + Assert.Equal(outputPath, result); + MockFileSystem.Verify(x => x.WriteTextToFile(outputPath, It.IsAny(), true), Times.Once); + } + [Fact] + public void GenerateSummaryReportHandlesInvalidTrxFilesTest() + { + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\output\summary.html"; + var outputDirectory = @"C:\output"; + var validTrxFile = @"C:\TestResults\Valid.trx"; + var invalidTrxFile = @"C:\TestResults\Invalid.trx"; + var emptyTrxFile = @"C:\TestResults\Empty.trx"; + + // Setup mock file system + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + + // Setup mock directory for output + MockFileSystem.Setup(x => x.Exists(outputDirectory)).Returns(false); + MockFileSystem.Setup(x => x.CreateDirectory(outputDirectory)); + + // Setup mock TRX file content + var validTestRun = CreateDummyTestRun("Valid Test Run", true); + validTestRun.Name = "Valid Test Run"; + var validTrxXml = SerializeTestRun(validTestRun); + + MockFileSystem.Setup(x => x.ReadAllText(validTrxFile)).Returns(validTrxXml); + MockFileSystem.Setup(x => x.ReadAllText(invalidTrxFile)).Returns("XML"); + MockFileSystem.Setup(x => x.ReadAllText(emptyTrxFile)).Returns(string.Empty); + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)); + MockFileSystem.Setup(x => x.ReadAllText(It.IsAny())).Returns("{{TEMPLATE}}"); + + // Execute the method + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + testRunSummary.GetTrxFiles = (directory) => + { + // Include all three files to test handling of invalid and empty files + return new[] { validTrxFile, invalidTrxFile, emptyTrxFile }; + }; + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Verify the result - it should still generate a report with the valid test run + Assert.Equal(outputPath, result); + MockFileSystem.Verify(x => x.WriteTextToFile(outputPath, It.IsAny(), true), Times.Once); + } + [Fact] + public void GenerateSummaryReportIncludesEnhancedStylesTest() + { + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\output\summary.html"; + var outputDirectory = @"C:\output"; + var trxFile = @"C:\TestResults\Results.trx"; + + // Setup mock file system + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + + // Setup mock directory for output + MockFileSystem.Setup(x => x.Exists(outputDirectory)).Returns(false); + MockFileSystem.Setup(x => x.CreateDirectory(outputDirectory)); + + // Create multiple test results with different outcomes + var testRun = new TestRun + { + Name = "Test Run With Mixed Results", + Id = "mixed-123", + Times = new TestTimes + { + Creation = DateTime.Now, + Queuing = DateTime.Now, + Start = DateTime.Now, + Finish = DateTime.Now.AddMinutes(5) + }, + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "PassingTest", + Outcome = TestReporter.PassedResultOutcome, + StartTime = DateTime.Now, + EndTime = DateTime.Now.AddSeconds(10), + Duration = "00:00:10" + }, + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "FailingTest", + Outcome = TestReporter.FailedResultOutcome, + StartTime = DateTime.Now.AddSeconds(15), + EndTime = DateTime.Now.AddSeconds(25), + Duration = "00:00:10", + Output = new TestOutput + { + ErrorInfo = new TestErrorInfo { Message = "Test failure with a very long error message that should get truncated in the display but still be available in the details section when the user clicks on Show More." } + } + }, + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "OtherTest", + Outcome = "NotRun", + StartTime = DateTime.Now.AddSeconds(30), + EndTime = DateTime.Now.AddSeconds(35), + Duration = "00:00:05" + } + } + }, + ResultSummary = new TestResultSummary + { + Outcome = "Completed", + Output = new TestOutput + { + StdOut = "{ \"AppURL\": \"https://example.com/app\", \"TestResults\": \"C:\\\\Results\" }" + } + } + }; + + var trxXml = SerializeTestRun(testRun); + + // Set up the mock file system to return our test run XML and HTML template + MockFileSystem.Setup(x => x.ReadAllText(trxFile)).Returns(trxXml); + MockFileSystem.Setup(x => x.ReadAllText(It.Is(s => !s.Equals(trxFile)))) + .Returns(@" + + +
+
+
+
+
Powered by PowerApps Test Engine
+ + "); + + string capturedHtml = null; + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)) + .Callback((path, content, append) => capturedHtml = content); + + // Execute the method + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + testRunSummary.GetTrxFiles = (directory) => + { + // Simulate that it returns the valid TRX file only + return new[] { trxFile }; + }; + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Verify the result + Assert.Equal(outputPath, result); + MockFileSystem.Verify(x => x.WriteTextToFile(outputPath, It.IsAny(), true), Times.Once); + + // Check that the HTML includes our enhanced styling elements + Assert.NotNull(capturedHtml); + Assert.Contains("=\"header\"", capturedHtml); + Assert.Contains("summary-stats", capturedHtml); + Assert.Contains("summary-card success", capturedHtml); + Assert.Contains("summary-card danger", capturedHtml); + Assert.Contains("Powered by PowerApps Test Engine", capturedHtml); + } + [Fact] + public void GenerateHtmlReportParsesAppUrlsCorrectly() + { + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\output\summary.html"; + var outputDirectory = @"C:\output"; + + // Setup mock file system + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockFileSystem.Setup(x => x.Exists(outputDirectory)).Returns(false); + MockFileSystem.Setup(x => x.CreateDirectory(outputDirectory)); + + // Setup a list of test runs with different app URL formats + var testRuns = new List + { + CreateTestRunWithUrl( + "Model-Driven App Standard Entity", + "1", + "https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account" + ), + CreateTestRunWithUrl( + "Model-Driven App Custom Page", + "2", + "https://contoso.crm.dynamics.com/main.aspx?pagetype=custom&name=custompage" + ), + CreateTestRunWithUrl( + "Canvas App", + "3", + "https://apps.powerapps.com/play/e/default-tenant/a/1234abcd-5678-efgh-ijkl-9012mnop3456" + ), + CreateTestRunWithUrl( + "Power Apps Portal", + "4", + "https://make.powerapps.com/environments/Default-tenant/apps" + ), + CreateTestRunWithUrl( + "Power Apps Preview Portal", + "5", + "https://make.preview.powerapps.com/environments/Default-tenant/apps/view" + ) + }; + + // Set up file system mocks to return our test run XMLs + for (int i = 0; i < testRuns.Count; i++) + { + var filePath = $@"C:\TestResults\Results_{i + 1}.trx"; + var xml = SerializeTestRun(testRuns[i]); + MockFileSystem.Setup(x => x.ReadAllText(filePath)).Returns(xml); + } + + string capturedHtml = null; + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)) + .Callback((path, content, append) => capturedHtml = content); + + // Execute the method + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + testRunSummary.GetTrxFiles = (directory) => + { + // Return our test file paths + var files = new string[testRuns.Count]; + for (int i = 0; i < testRuns.Count; i++) + { + files[i] = $@"C:\TestResults\Results_{i + 1}.trx"; + } + return files; + }; + + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Verify the result was generated + Assert.Equal(outputPath, result); + MockFileSystem.Verify(x => x.WriteTextToFile(outputPath, It.IsAny(), true), Times.Once); + + // Verify the HTML content contains our expected app types and page types + Assert.NotNull(capturedHtml); + Assert.Contains("Model-driven App", capturedHtml); + Assert.Contains("entitylist", capturedHtml); + Assert.Contains("account", capturedHtml); + Assert.Contains("Custom Page", capturedHtml); + Assert.Contains("custompage", capturedHtml); + Assert.Contains("Canvas App", capturedHtml); + Assert.Contains("Power Apps Portal", capturedHtml); + } + + [Fact] + public void GenerateHtmlReportContainsDifferentAppTypesTest() + { + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\output\summary.html"; + var outputDirectory = @"C:\output"; + + // Setup mock file system + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockFileSystem.Setup(x => x.Exists(outputDirectory)).Returns(false); + MockFileSystem.Setup(x => x.CreateDirectory(outputDirectory)); + + // Create the test runs with different app URLs + var testRun1 = CreateTestRunWithUrl("Model-Driven App", "1", "https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account"); + var testRun2 = CreateTestRunWithUrl("Canvas App", "2", "https://apps.powerapps.com/play/e/default-tenant/a/1234abcd"); + var testRun3 = CreateTestRunWithUrl("Portal", "3", "https://make.powerapps.com/environments/Default-tenant/apps"); + + // Set up mock file system to return the test run XML + var trxFile1 = @"C:\TestResults\Results_1.trx"; + var trxFile2 = @"C:\TestResults\Results_2.trx"; + var trxFile3 = @"C:\TestResults\Results_3.trx"; + + MockFileSystem.Setup(x => x.ReadAllText(trxFile1)).Returns(SerializeTestRun(testRun1)); + MockFileSystem.Setup(x => x.ReadAllText(trxFile2)).Returns(SerializeTestRun(testRun2)); + MockFileSystem.Setup(x => x.ReadAllText(trxFile3)).Returns(SerializeTestRun(testRun3)); + + string capturedHtml = null; + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)) + .Callback((path, content, append) => capturedHtml = content); + + // Create the test run summary instance and configure it to use our mock TRX files + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + testRunSummary.GetTrxFiles = (directory) => + { + return new[] { trxFile1, trxFile2, trxFile3 }; + }; + + // Generate the summary report + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Verify the result was generated + Assert.Equal(outputPath, result); + MockFileSystem.Verify(x => x.WriteTextToFile(outputPath, It.IsAny(), true), Times.Once); + + // Verify the HTML content contains our expected app types and page types + Assert.NotNull(capturedHtml); + + // Check for model-driven app details + Assert.Contains("Model-driven App", capturedHtml); // App type + Assert.Contains("entitylist", capturedHtml); // Page type + Assert.Contains("account", capturedHtml); // Entity name + + // Check for canvas app + Assert.Contains("Canvas App", capturedHtml); + + // Check for portal app + Assert.Contains("Power Apps Portal", capturedHtml); + } + + // Helper method to create test runs with specific app URLs + private TestRun CreateTestRunWithUrl(string name, string id, string appUrl) + { + var testRun = new TestRun + { + Name = name, + Id = id, + Times = new TestTimes + { + Creation = DateTime.Now, + Queuing = DateTime.Now, + Start = DateTime.Now, + Finish = DateTime.Now.AddMinutes(1) + }, + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = $"test-{id}", + TestName = $"Test {name}", + Outcome = TestReporter.PassedResultOutcome, + StartTime = DateTime.Now, + EndTime = DateTime.Now.AddSeconds(5), + Duration = "00:00:05" + } + } + }, + ResultSummary = new TestResultSummary + { + Outcome = "Completed", + Counters = new TestCounters + { + Total = 1, + Executed = 1, + Passed = 1, + Failed = 0 + }, + Output = new TestOutput + { + StdOut = $"{{ \"AppURL\": \"{appUrl}\", \"TestResults\": \"C:\\\\Results\\\\{id}\" }}" + } + } + }; + + return testRun; + } + + private TestRun CreateTestRun(string name, string id, bool success) + { + var testRun = new TestRun + { + Name = name, + Id = id, + Times = new TestTimes + { + Creation = DateTime.Now, + Queuing = DateTime.Now, + Start = DateTime.Now, + Finish = DateTime.Now.AddMinutes(1) + }, + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = $"Test_{id}", + Outcome = success ? TestReporter.PassedResultOutcome : TestReporter.FailedResultOutcome, + StartTime = DateTime.Now, + EndTime = DateTime.Now.AddSeconds(30), + Duration = "00:00:30", + Output = new TestOutput + { + StdOut = "Test output", + ErrorInfo = success ? null : new TestErrorInfo { Message = "Error message" } + } + } + } + }, + ResultSummary = new TestResultSummary + { + Outcome = "Completed", + Counters = new TestCounters + { + Total = 1, + Executed = 1, + Passed = success ? 1 : 0, + Failed = success ? 0 : 1 + }, + Output = new TestOutput + { + StdOut = $"{{ \"AppURL\": \"https://example.com/{id}\", \"TestResults\": \"C:\\\\Results\\\\{id}\" }}" + } + } + }; + + return testRun; + } + + // Helper method to create dummy test runs + private TestRun CreateDummyTestRun(string testName, bool passed, DateTime? started = null, DateTime? ended = null) + { + var testRun = new TestRun + { + Id = Guid.NewGuid().ToString(), + Name = "Test Run", + Times = new TestTimes + { + Creation = started ?? DateTime.Now, + Queuing = started ?? DateTime.Now, + Start = started ?? DateTime.Now, + Finish = ended ?? DateTime.Now.AddMinutes(1) + }, + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = testName, + Outcome = passed ? TestReporter.PassedResultOutcome : TestReporter.FailedResultOutcome, + StartTime = started ?? DateTime.Now, + EndTime = ended ?? DateTime.Now.AddSeconds(10), + Duration = "00:00:10" + } + } + }, + ResultSummary = new TestResultSummary + { + Outcome = "Completed", + Counters = new TestCounters + { + Total = 1, + Executed = 1, + Passed = passed ? 1 : 0, + Failed = passed ? 0 : 1 + } + } + }; + + return testRun; + } + + private string SerializeTestRun(TestRun testRun) + { + var serializer = new XmlSerializer(typeof(TestRun)); + using var writer = new StringWriter(); + serializer.Serialize(writer, testRun); + return writer.ToString(); + } + + [Theory] + [InlineData("https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account", "Model-driven App", "entitylist", "account")] + [InlineData("https://contoso.crm.dynamics.com/main.aspx?pagetype=custom&name=custompage", "Model-driven App", "Custom Page", "custompage")] + [InlineData("https://contoso.crm4.dynamics.com/main.aspx?pagetype=entity&etn=contact", "Model-driven App", "entity", "contact")] + [InlineData("https://apps.powerapps.com/play/e/default-tenant/a/1234abcd", "Canvas App", "Unknown", "Unknown")] + [InlineData("https://make.powerapps.com/environments/Default-tenant/apps", "Power Apps Portal", "environments", "apps")] + [InlineData("https://make.preview.powerapps.com/environments/Default-tenant/solutions", "Power Apps Portal", "environments", "solutions")] + [InlineData("https://invalid-url", "Unknown", "Unknown", "Unknown")] + public void TestAppUrlParsing(string url, string expectedAppType, string expectedPageType, string expectedEntityName) + { + // Arrange + var mockFileSystem = new Mock(MockBehavior.Strict); + var testRunSummary = new TestRunSummary(mockFileSystem.Object); + + // Act + var result = testRunSummary.GetAppTypeAndEntityFromUrl(url); + + // Use reflection to get the tuple values + var resultType = result.GetType(); + var appType = resultType.GetField("Item1").GetValue(result) as string; + var pageType = resultType.GetField("Item2").GetValue(result) as string; + var entityName = resultType.GetField("Item3").GetValue(result) as string; + + // Assert + Assert.Equal(expectedAppType, appType); + Assert.Equal(expectedPageType, pageType); + Assert.Equal(expectedEntityName, entityName); + } + + [Fact] + public void LoadTestRunFromFile_ValidFile_ReturnsTestRun() + { + // Arrange + var trxFilePath = @"C:\TestResults\valid.trx"; + var testRun = CreateDummyTestRun("test1", true); + var serializedTestRun = SerializeTestRun(testRun); + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(serializedTestRun); + MockFileSystem.Setup(x => x.GetFileSize(It.IsAny())).Returns(100000); // Mock file size for videos + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Act + var result = testRunSummary.LoadTestRunFromFile(trxFilePath); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + Assert.NotNull(result.Results.UnitTestResults); + Assert.Single(result.Results.UnitTestResults); + Assert.Equal("test1", result.Results.UnitTestResults[0].TestName); + Assert.Equal("Passed", result.Results.UnitTestResults[0].Outcome); + } + + [Fact] + public void LoadTestRunFromFile_EmptyFile_ReturnsNull() + { + // Arrange + var trxFilePath = @"C:\TestResults\empty.trx"; + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(string.Empty); + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Act + var result = testRunSummary.LoadTestRunFromFile(trxFilePath); + + // Assert + Assert.Null(result); + } + + [Fact] + public void LoadTestRunFromFile_InvalidXml_ReturnsNull() + { + // Arrange + var trxFilePath = @"C:\TestResults\invalid.trx"; + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(""); + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Act + var result = testRunSummary.LoadTestRunFromFile(trxFilePath); + + // Assert + Assert.Null(result); + } + + [Fact] + public void LoadTestRunFromFile_ValidFileWithVideos_EnhancesTestRunWithVideos() + { + // Arrange + var trxFilePath = @"C:\TestResults\with-videos.trx"; + var testRun = CreateDummyTestRun("test-with-video", true); + var serializedTestRun = SerializeTestRun(testRun); + var videoFile = @"C:\TestResults\recording.webm"; + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(serializedTestRun); + MockFileSystem.Setup(x => x.GetFileSize(videoFile)).Returns(100000); // Mock file size for videos + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Override the video file discovery function with a mock + testRunSummary.GetVideoFiles = (path) => new string[] { videoFile }; + + // Act + var result = testRunSummary.LoadTestRunFromFile(trxFilePath); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.ResultSummary); + Assert.NotNull(result.ResultSummary.Output); + Assert.Contains("VideoPath", result.ResultSummary.Output.StdOut); + Assert.Contains("Videos", result.ResultSummary.Output.StdOut); + } + + [Fact] + public void GetTemplateContext_EmptyTestRuns_ReturnsDefaultTemplateData() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var testRuns = new List(); + + // Act + var result = testRunSummary.GetTemplateContext(testRuns); + + // Assert + Assert.NotNull(result); + Assert.Equal(0, result.TotalTests); + Assert.Equal(0, result.PassedTests); + Assert.Equal(0, result.FailedTests); + Assert.Equal(0, result.HealthScore); + Assert.Equal("N/A", result.TotalDuration); + Assert.Equal("N/A", result.StartTime); + Assert.Equal("N/A", result.EndTime); + Assert.NotNull(result.TestsData); + Assert.NotEmpty(result.TestsData); + } + + [Fact] + public void GetTemplateContext_WithTestRuns_CalculatesCorrectSummary() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Create test runs with different outcomes + var testRun1 = CreateDummyTestRun("test1", true); + var testRun2 = CreateDummyTestRun("test2", false); + + // Set start and end times for test runs + testRun1.Results.UnitTestResults[0].StartTime = DateTime.Now.AddMinutes(-10); + testRun1.Results.UnitTestResults[0].EndTime = DateTime.Now.AddMinutes(-5); + testRun1.Results.UnitTestResults[0].Duration = "00:05:00"; + + testRun2.Results.UnitTestResults[0].StartTime = DateTime.Now.AddMinutes(-5); + testRun2.Results.UnitTestResults[0].EndTime = DateTime.Now; + testRun2.Results.UnitTestResults[0].Duration = "00:05:00"; + + // Create a list with the test runs + var testRuns = new List { testRun1, testRun2 }; + + // Act + var result = testRunSummary.GetTemplateContext(testRuns); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.TotalTests); + Assert.Equal(1, result.PassedTests); + Assert.Equal(1, result.FailedTests); + Assert.Equal(50, result.PassPercentage); + Assert.NotEmpty(result.TotalDuration); // Should be calculated + Assert.NotEqual("N/A", result.StartTime); + Assert.NotEqual("N/A", result.EndTime); + Assert.NotNull(result.TestsData); + Assert.NotEmpty(result.TestsData); + } + + [Fact] + public void GenerateHtmlReport_EmptyTestRuns_GeneratesBaselineReport() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var testRuns = new List(); + + // Act + var result = testRunSummary.GenerateHtmlReport(testRuns); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Contains(" x.ReadAllText(It.IsAny())).Returns("{{SUMMARY_TOTAL}}{{SUMMARY_PASSED}}{{SUMMARY_FAILED}}{{TEST_RESULTS_ROWS}}"); + + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Create test runs with different outcomes + var testRun1 = CreateDummyTestRun("test1", true); + var testRun2 = CreateDummyTestRun("test2", false); + + testRun1.Results.UnitTestResults[0].StartTime = DateTime.Now.AddMinutes(-10); + testRun1.Results.UnitTestResults[0].EndTime = DateTime.Now.AddMinutes(-5); + testRun1.Results.UnitTestResults[0].Duration = "00:05:00"; + + testRun2.Results.UnitTestResults[0].StartTime = DateTime.Now.AddMinutes(-5); + testRun2.Results.UnitTestResults[0].EndTime = DateTime.Now; + testRun2.Results.UnitTestResults[0].Duration = "00:05:00"; + + // Add app URL info to test results + testRun1.Results.UnitTestResults[0].Output = new TestOutput + { + StdOut = @"{""AppURL"": ""https://make.powerapps.com/environments/Default-tenant/apps""}" + }; + + testRun2.Results.UnitTestResults[0].Output = new TestOutput + { + StdOut = @"{""AppURL"": ""https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account""}" + }; + + // Create a list with the test runs + var testRuns = new List { testRun1, testRun2 }; + + // Act + var result = testRunSummary.GenerateHtmlReport(testRuns); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Contains("2", result); // Total tests + Assert.Contains("1", result); // Passed tests + Assert.Contains("1", result); // Failed tests + } + + [Fact] + public void TestRunSummary_ImplementsITestRunSummaryInterface() + { + // Arrange + var mockFileSystem = new Mock(); + var testRunSummary = new TestRunSummary(mockFileSystem.Object); + + // Act & Assert + Assert.IsAssignableFrom(testRunSummary); + } + + [Fact] + public void TestRunSummary_RefactoredMethodsTest() + { + // This test verifies that the TestRunSummary class has all the public methods + // that were added during refactoring + + // Arrange + var mockFileSystem = new Mock(); + var testRunSummary = new TestRunSummary(mockFileSystem.Object); + var type = typeof(TestRunSummary); + + // Act + var loadTestRunMethod = type.GetMethod("LoadTestRunFromFile"); + var getTemplateContextMethod = type.GetMethod("GetTemplateContext"); + var generateHtmlReportMethod = type.GetMethod("GenerateHtmlReport"); + var getAppTypeAndEntityFromUrlMethod = type.GetMethod("GetAppTypeAndEntityFromUrl"); + + // Assert + Assert.NotNull(loadTestRunMethod); + Assert.Equal("LoadTestRunFromFile", loadTestRunMethod.Name); + Assert.True(loadTestRunMethod.IsPublic); + + Assert.NotNull(getTemplateContextMethod); + Assert.Equal("GetTemplateContext", getTemplateContextMethod.Name); + Assert.True(getTemplateContextMethod.IsPublic); + + Assert.NotNull(generateHtmlReportMethod); + Assert.Equal("GenerateHtmlReport", generateHtmlReportMethod.Name); + Assert.True(generateHtmlReportMethod.IsPublic); + + Assert.NotNull(getAppTypeAndEntityFromUrlMethod); + Assert.Equal("GetAppTypeAndEntityFromUrl", getAppTypeAndEntityFromUrlMethod.Name); + Assert.True(getAppTypeAndEntityFromUrlMethod.IsPublic); + } + + [Fact] + public void GetTemplateContext_MultipleTestRuns_CalculatesCorrectTimespan() + { + // Arrange + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + var earliestStartTime = new DateTime(2023, 1, 1, 10, 0, 0); + var middleStartTime = new DateTime(2023, 1, 1, 11, 0, 0); + var latestEndTime = new DateTime(2023, 1, 1, 12, 15, 0); + + // Create test runs with chronologically spread timestamps + var testRun1 = CreateDummyTestRun("EarlyTest", true, earliestStartTime, earliestStartTime.AddMinutes(5)); + var testRun2 = CreateDummyTestRun("MiddleTest", true, middleStartTime, middleStartTime.AddMinutes(10)); + var testRun3 = CreateDummyTestRun("LateTest", true, latestEndTime, latestEndTime.AddMinutes(15)); + + // Set earliest start time on first test + + testRun1.Results.UnitTestResults[0].StartTime = earliestStartTime; + testRun1.Results.UnitTestResults[0].EndTime = earliestStartTime.AddMinutes(5); + testRun1.Results.UnitTestResults[0].Duration = "00:05:00"; + + // Set middle timestamps on second test + + testRun2.Results.UnitTestResults[0].StartTime = middleStartTime; + testRun2.Results.UnitTestResults[0].EndTime = middleStartTime.AddMinutes(10); + testRun2.Results.UnitTestResults[0].Duration = "00:10:00"; + + // Set latest end time on third test + + testRun3.Results.UnitTestResults[0].StartTime = latestEndTime; + testRun3.Results.UnitTestResults[0].EndTime = latestEndTime.AddMinutes(15); + testRun3.Results.UnitTestResults[0].Duration = "00:15:00"; + + // Create a list with the test runs (deliberately not in chronological order) + var testRuns = new List { testRun2, testRun3, testRun1 }; + + // Act + var result = testRunSummary.GetTemplateContext(testRuns); + + // Assert + Assert.NotNull(result); + + var expectedEndTime = latestEndTime.AddMinutes(15); + + // Verify start time is the earliest time across all test runs + Assert.Equal(earliestStartTime.ToString("g"), result.StartTime); + + // Verify end time is the latest time across all test runs + Assert.Equal(expectedEndTime.ToString("g"), result.EndTime); + + // Verify total duration is calculated from earliest start to latest end + TimeSpan totalDuration = expectedEndTime - earliestStartTime; + Assert.Equal(totalDuration.TotalMinutes.ToString("0.00") + " minutes", result.TotalDuration); + + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryVideoTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryVideoTests.cs new file mode 100644 index 00000000..c3253bdd --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunSummaryVideoTests.cs @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml.Serialization; +using Microsoft.PowerApps.TestEngine.Reporting; +using Microsoft.PowerApps.TestEngine.Reporting.Format; +using Microsoft.PowerApps.TestEngine.System; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.Reporting +{ + public class TestRunSummaryVideoTests + { + private Mock MockFileSystem; + + public TestRunSummaryVideoTests() + { + MockFileSystem = new Mock(MockBehavior.Strict); + } + + [Fact] + public void TestRunSummary_FindsAndIncludesVideoFiles() + { + // Arrange + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\Output\summary.html"; + var outputDirectory = Path.GetDirectoryName(outputPath); + var trxFilePath = @"C:\TestResults\TestResults.trx"; + var trxDirectory = Path.GetDirectoryName(trxFilePath); + var videoPath = @"C:\TestResults\test_recording.webm"; + + // Setup mock file system + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockFileSystem.Setup(x => x.Exists(outputDirectory)).Returns(false); + MockFileSystem.Setup(x => x.GetFiles(resultsDirectory)).Returns(new string[] { videoPath }); + MockFileSystem.Setup(x => x.CreateDirectory(outputDirectory)); + MockFileSystem.Setup(x => x.GetFileSize(videoPath)).Returns(500 * 1024); // 500KB - valid size + MockFileSystem.Setup(x => x.Exists(videoPath)).Returns(true); + + // Create test run + var testRun = new TestRun + { + Name = "Test Run with Video", + Id = "video-test-run", + Times = new TestTimes + { + Creation = DateTime.Now, + Queuing = DateTime.Now, + Start = DateTime.Now, + Finish = DateTime.Now.AddMinutes(5) + }, + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test1", + Outcome = TestReporter.PassedResultOutcome, + StartTime = DateTime.Now, + EndTime = DateTime.Now.AddSeconds(30), + Duration = "00:00:30", + Output = new TestOutput { StdOut = "Test output" } + }, + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test2", + Outcome = TestReporter.PassedResultOutcome, + StartTime = DateTime.Now.AddSeconds(35), + EndTime = DateTime.Now.AddSeconds(65), + Duration = "00:00:30", + Output = new TestOutput { StdOut = "Test output" } + } + } + }, + ResultSummary = new TestResultSummary + { + Outcome = "Completed", + Counters = new TestCounters + { + Total = 2, + Executed = 2, + Passed = 2, + Failed = 0 + }, + Output = new TestOutput + { + StdOut = "{ \"AppURL\": \"https://example.com\", \"TestResults\": \"C:\\\\Results\\\" }" + } + } + }; + + // Serialize the test run + var serializer = new XmlSerializer(typeof(TestRun)); + string trxContent; + using (var writer = new StringWriter()) + { + serializer.Serialize(writer, testRun); + trxContent = writer.ToString(); + } + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(trxContent); + + string capturedHtml = null; + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)) + .Callback((path, content, append) => capturedHtml = content); + + // Act + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Configure the test run summary to return our TRX file + testRunSummary.GetTrxFiles = (directory) => new[] { trxFilePath }; + + // Configure the test run summary to return our video file + testRunSummary.GetVideoFiles = (directory) => new[] { videoPath }; + + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Assert + Assert.Equal(outputPath, result); + MockFileSystem.Verify(x => x.WriteTextToFile(outputPath, It.IsAny(), true), Times.Once); + + // Verify the HTML content contains our video elements + Assert.NotNull(capturedHtml); + Assert.Contains("video-container", capturedHtml); + Assert.Contains(videoPath.Replace("\\", "//"), capturedHtml); + Assert.Contains("Play Video", capturedHtml); + Assert.Contains("video-controls", capturedHtml); + Assert.Contains("Copy Timecode", capturedHtml); + + // Verify timecode functionality + Assert.Contains("data-timecode=", capturedHtml); + Assert.Contains("video-timestamp", capturedHtml); + Assert.Contains("00:00:00", capturedHtml); + } + + [Fact] + public void TestRunSummary_HandlesMultipleVideoFiles() + { + // Arrange + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\Output\summary.html"; + var outputDirectory = Path.GetDirectoryName(outputPath); + var trxFilePath = @"C:\TestResults\TestResults.trx"; + var trxDirectory = Path.GetDirectoryName(trxFilePath); + var videoPath1 = @"C:\TestResults\test_recording1.webm"; + var videoPath2 = @"C:\TestResults\test_recording2.webm"; + var smallVideoPath = @"C:\TestResults\incomplete_recording.webm"; + // Setup mock file system + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockFileSystem.Setup(x => x.Exists(outputDirectory)).Returns(false); + MockFileSystem.Setup(x => x.CreateDirectory(outputDirectory)); + MockFileSystem.Setup(x => x.GetFiles(resultsDirectory)).Returns(new string[] { videoPath1, videoPath2, smallVideoPath }); + MockFileSystem.Setup(x => x.GetFileSize(videoPath1)).Returns(500 * 1024); // 500KB - valid size + MockFileSystem.Setup(x => x.GetFileSize(videoPath2)).Returns(200 * 1024); // 200KB - valid size + MockFileSystem.Setup(x => x.GetFileSize(smallVideoPath)).Returns(5 * 1024); // 5KB - too small, should be filtered out + MockFileSystem.Setup(x => x.Exists(videoPath1)).Returns(true); + MockFileSystem.Setup(x => x.Exists(videoPath2)).Returns(true); + MockFileSystem.Setup(x => x.Exists(smallVideoPath)).Returns(true); + + // Create test run + var testRun = new TestRun + { + Name = "Test Run with Multiple Videos", + Id = "multiple-videos-test-run", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test1", + Outcome = TestReporter.PassedResultOutcome, + Duration = "00:00:30" + }, + new UnitTestResult + { + TestId = Guid.NewGuid().ToString(), + TestName = "Test2", + Outcome = TestReporter.PassedResultOutcome, + Duration = "00:00:45" + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput + { + StdOut = "{ \"TestResults\": \"C:\\\\Results\" }" + } + } + }; + + // Serialize the test run + var serializer = new XmlSerializer(typeof(TestRun)); + string trxContent; + using (var writer = new StringWriter()) + { + serializer.Serialize(writer, testRun); + trxContent = writer.ToString(); + } + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(trxContent); + + string capturedHtml = null; + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)) + .Callback((path, content, append) => capturedHtml = content); + + // Act + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + + // Configure the test run summary to return our TRX file + testRunSummary.GetTrxFiles = (directory) => new[] { trxFilePath }; + + // Configure the test run summary to return our video files including the small one + testRunSummary.GetVideoFiles = (directory) => new[] { videoPath1, videoPath2, smallVideoPath }; + + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Assert + Assert.Equal(outputPath, result); + + // Verify the HTML content contains our valid video elements but not the small one + Assert.NotNull(capturedHtml); + Assert.Contains(videoPath1.Replace("\\", "\\\\"), capturedHtml); + Assert.Contains(videoPath2.Replace("\\", "\\\\"), capturedHtml); + Assert.DoesNotContain(smallVideoPath.Replace("\\", "\\\\"), capturedHtml); + + // Verify it contains the "Videos" array for multiple videos + Assert.Contains(""videos":", capturedHtml); + } + + [Fact] + public void TestRunSummary_CalculatesTimecodes() + { + // Arrange + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\Output\summary.html"; + var trxFilePath = @"C:\TestResults\TestResults.trx"; + var videoPath = @"C:\TestResults\test_recording.webm"; + + // Setup basic mocks + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockFileSystem.Setup(x => x.Exists(Path.GetDirectoryName(outputPath))).Returns(true); + MockFileSystem.Setup(x => x.GetFileSize(videoPath)).Returns(500 * 1024); + MockFileSystem.Setup(x => x.Exists(videoPath)).Returns(true); + + // Create test run with specific durations for deterministic testing + var testRun = new TestRun + { + Name = "Timecode Test Run", + Id = "timecode-test", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = "test-1", + TestName = "First Test - 30s", + Outcome = TestReporter.PassedResultOutcome, + Duration = "00:00:30", + Output = new TestOutput() + }, + new UnitTestResult + { + TestId = "test-2", + TestName = "Second Test - 45s", + Outcome = TestReporter.PassedResultOutcome, + Duration = "00:00:45", + Output = new TestOutput() + }, + new UnitTestResult + { + TestId = "test-3", + TestName = "Third Test - 60s", + Outcome = TestReporter.PassedResultOutcome, + Duration = "00:01:00", + Output = new TestOutput() + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput + { + StdOut = "{ }" + } + } + }; + + // Serialize the test run + var serializer = new XmlSerializer(typeof(TestRun)); + string trxContent; + using (var writer = new StringWriter()) + { + serializer.Serialize(writer, testRun); + trxContent = writer.ToString(); + } + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(trxContent); + + string capturedHtml = null; + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)) + .Callback((path, content, append) => capturedHtml = content); + + // Act + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + testRunSummary.GetTrxFiles = (directory) => new[] { trxFilePath }; + testRunSummary.GetVideoFiles = (directory) => new[] { videoPath }; + + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Assert + Assert.Equal(outputPath, result); + Assert.NotNull(capturedHtml); + + // Verify the video controls include these timecodes + Assert.Contains("data-timecode=\"00:00:00\"", capturedHtml); + Assert.Contains("data-timecode=\"00:00:30\"", capturedHtml); + Assert.Contains("data-timecode=\"00:01:00\"", capturedHtml); + } + + [Fact] + public void TestRunSummary_HandlesNoVideos() + { + // Arrange + var resultsDirectory = @"C:\TestResults"; + var outputPath = @"C:\Output\summary.html"; + var trxFilePath = @"C:\TestResults\TestResults.trx"; + + // Setup basic mocks + MockFileSystem.Setup(x => x.Exists(resultsDirectory)).Returns(true); + MockFileSystem.Setup(x => x.Exists(Path.GetDirectoryName(outputPath))).Returns(true); + + // Create a basic test run + var testRun = new TestRun + { + Name = "No Video Test Run", + Id = "no-video-test", + Results = new TestResults + { + UnitTestResults = new List + { + new UnitTestResult + { + TestId = "test-1", + TestName = "Simple Test", + Outcome = TestReporter.PassedResultOutcome, + Duration = "00:00:30" + } + } + }, + ResultSummary = new TestResultSummary + { + Output = new TestOutput + { + StdOut = "{ }" + } + } + }; + + // Serialize the test run + var serializer = new XmlSerializer(typeof(TestRun)); + string trxContent; + using (var writer = new StringWriter()) + { + serializer.Serialize(writer, testRun); + trxContent = writer.ToString(); + } + + MockFileSystem.Setup(x => x.ReadAllText(trxFilePath)).Returns(trxContent); + + string capturedHtml = null; + MockFileSystem.Setup(x => x.WriteTextToFile(outputPath, It.IsAny(), true)) + .Callback((path, content, append) => capturedHtml = content); + + // Act + var testRunSummary = new TestRunSummary(MockFileSystem.Object); + testRunSummary.GetTrxFiles = (directory) => new[] { trxFilePath }; + testRunSummary.GetVideoFiles = (directory) => Array.Empty(); // Return no video files + + var result = testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath); + + // Assert + Assert.Equal(outputPath, result); + Assert.NotNull(capturedHtml); + + // Report should still be generated without video-related elements + Assert.DoesNotContain("videos"", capturedHtml); // No video container elements + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunUrlParsingTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunUrlParsingTests.cs new file mode 100644 index 00000000..72ec5d97 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestRunUrlParsingTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.PowerApps.TestEngine.Reporting; +using Microsoft.PowerApps.TestEngine.System; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.Reporting +{ + /// + /// Tests for the URL parsing functionality in TestRunSummary + /// + public class TestRunUrlParsingTests + { + [Theory] + [InlineData("https://contoso.crm.dynamics.com/main.aspx?pagetype=entitylist&etn=account", "Model-driven App", "entitylist", "account")] + [InlineData("https://contoso.crm.dynamics.com/main.aspx?pagetype=custom&name=custompage", "Model-driven App", "Custom Page", "custompage")] + [InlineData("https://contoso.crm4.dynamics.com/main.aspx?pagetype=entity&etn=contact", "Model-driven App", "entity", "contact")] + [InlineData("https://apps.powerapps.com/play/e/default-tenant/a/1234abcd", "Canvas App", "Unknown", "Unknown")] + [InlineData("https://make.powerapps.com/environments/Default-tenant/apps", "Power Apps Portal", "environments", "apps")] + [InlineData("https://make.powerapps.com/environments/Default-tenant/solutions", "Power Apps Portal", "environments", "solutions")] + [InlineData("https://invalid-url", "Unknown", "Unknown", "Unknown")] + [InlineData("", "Unknown", "Unknown", "Unknown")] + public void TestAppUrlParsing(string url, string expectedAppType, string expectedPageType, string expectedEntityName) + { + // Arrange + var mockFileSystem = new Mock(); + var testRunSummary = new TestRunSummary(mockFileSystem.Object); + + // Act + var result = testRunSummary.GetAppTypeAndEntityFromUrl(url); + + // Get tuple values using reflection + var resultType = result.GetType(); + var appType = resultType.GetField("Item1").GetValue(result) as string; + var pageType = resultType.GetField("Item2").GetValue(result) as string; + var entityName = resultType.GetField("Item3").GetValue(result) as string; + + // Assert + Assert.Equal(expectedAppType, appType); + Assert.Equal(expectedPageType, pageType); + Assert.Equal(expectedEntityName, entityName); + } + + [Fact] + public void TestAppUrlParsingWithNull() + { + // Arrange + var mockFileSystem = new Mock(); + var testRunSummary = new TestRunSummary(mockFileSystem.Object); + + // Act + var result = testRunSummary.GetAppTypeAndEntityFromUrl(null); + + // Get tuple values using reflection + var resultType = result.GetType(); + var appType = resultType.GetField("Item1").GetValue(result) as string; + var pageType = resultType.GetField("Item2").GetValue(result) as string; + var entityName = resultType.GetField("Item3").GetValue(result) as string; + + // Assert + Assert.Equal("Unknown", appType); + Assert.Equal("Unknown", pageType); + Assert.Equal("Unknown", entityName); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs index ac1b400a..562f8c80 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs @@ -188,7 +188,6 @@ private void VerifySuccessfulTestExecution(string testResultDirectory, TestSuite MockSingleTestInstanceState.Verify(x => x.SetTestResultsDirectory(testResultDirectory), Times.Once()); MockFileSystem.Verify(x => x.CreateDirectory(testResultDirectory), Times.Once()); MockTestLogger.Verify(x => x.WriteToLogsFile(testResultDirectory, testId), Times.Once()); - MockFileSystem.Verify(x => x.GetFiles(testResultDirectory), Times.Once()); MockTestInfraFunctions.Verify(x => x.DisposeAsync(), Times.Once()); var additionalFilesList = new List(); if (additionalFiles != null) @@ -228,6 +227,11 @@ public async Task SingleTestRunnerSuccessWithTestDataOneTest(string[]? additiona MockTestWebProvider.Object, MockUserManagerLogin.Object); + singleTestRunner.GetFiles = (string directory) => + { + return additionalFiles; + }; + var testData = new TestDataOne(); SetupMocks(testData.testRunId, testData.testSuiteId, testData.testId, testData.appUrl, testData.testSuiteDefinition, true, additionalFiles, testData.testSuiteLocale); @@ -265,6 +269,8 @@ public async Task SingleTestRunnerSuccessWithTestDataTwoTest(string[]? additiona MockTestWebProvider.Object, MockUserManagerLogin.Object); + singleTestRunner.GetFiles = (string directory) => additionalFiles; + var testData = new TestDataTwo(); SetupMocks(testData.testRunId, testData.testSuiteId, testData.testId, testData.appUrl, testData.testSuiteDefinition, true, additionalFiles, testData.testSuiteLocale); diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs index 6d98b74d..2f9cc87f 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs @@ -49,6 +49,8 @@ public TestEngineTests() [Fact] public async Task TestEngineWithDefaultParamsTest() { + var testRunName = "My Test"; + var testSettings = new TestSettings() { Locale = "en-US", @@ -93,11 +95,11 @@ public async Task TestEngineWithDefaultParamsTest() var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); testEngine.Timestamper = () => new DateTime(2024, 11, 20); - var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", runName: testRunName); Assert.Equal(expectedTestReportPath, testReportPath); - Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory, testRunId, testRunDirectory, testSuiteDefinition, testSettings); + Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory, testRunName, testRunId, testRunDirectory, testSuiteDefinition, testSettings); } [Fact] @@ -134,7 +136,7 @@ public async Task TestEngineWithInvalidLocaleTest() MockTestEngineEventHandler.Setup(x => x.EncounteredException(It.IsAny())); var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); - var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", runName: "My TestRun"); // UserInput Exception is handled within TestEngineEventHandler, and then returns the test results directory path MockTestEngineEventHandler.Verify(x => x.EncounteredException(exceptionToThrow), Times.Once()); Assert.NotNull(testResultsDirectory); @@ -143,6 +145,8 @@ public async Task TestEngineWithInvalidLocaleTest() [Fact] public async Task TestEngineWithUnspecifiedLocaleShowsWarning() { + var testRunName = "My Test"; + var testSettings = new TestSettings() { BrowserConfigurations = new List() @@ -171,12 +175,12 @@ public async Task TestEngineWithUnspecifiedLocaleShowsWarning() var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); testEngine.Timestamper = () => new DateTime(2024, 11, 20); - var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", runName: testRunName); Assert.Equal(expectedTestReportPath, testReportPath); LoggingTestHelper.VerifyLogging(MockLogger, $"Locale property not specified in testSettings. Using current system locale: {CultureInfo.CurrentCulture.Name}", LogLevel.Debug, Times.Once()); - Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory, testRunId, testRunDirectory, testSuiteDefinition, testSettings); + Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory, testRunName, testRunId, testRunDirectory, testSuiteDefinition, testSettings); } [Fact] @@ -215,11 +219,11 @@ public async Task TestEngineWithMultipleBrowserConfigTest() var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); testEngine.Timestamper = () => new DateTime(2024, 11, 20); - var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", "My Test"); Assert.Equal(expectedTestReportPath, testReportPath); - Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory, testRunId, testRunDirectory, testSuiteDefinition, testSettings); + Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory, "My Test", testRunId, testRunDirectory, testSuiteDefinition, testSettings); } private TestSuiteDefinition GetDefaultTestSuiteDefinition() @@ -249,6 +253,7 @@ public async Task TestEngineTest(DirectoryInfo outputDirectory, string domain, T var testConfigFile = new FileInfo("C:\\testPlan.fx.yaml"); var environmentId = "defaultEnviroment"; var tenantId = new Guid("a01af035-a529-4aaf-aded-011ad676f976"); + var testRunName = "My Test"; var testRunId = Guid.NewGuid().ToString(); MockFileSystem.Setup(x => x.GetDefaultRootTestEngine()).Returns(outputDirectory.FullName); @@ -271,11 +276,11 @@ public async Task TestEngineTest(DirectoryInfo outputDirectory, string domain, T var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); testEngine.Timestamper = () => new DateTime(2024, 11, 20); - var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", testRunName); Assert.Equal(expectedTestReportPath, testReportPath); - Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory.FullName, testRunId, testRunDirectory, testSuiteDefinition, testSettings); + Verify(testConfigFile.FullName, environmentId, tenantId.ToString(), domain, "", expectedOutputDirectory.FullName, testRunName, testRunId, testRunDirectory, testSuiteDefinition, testSettings); } private void SetupMocks(string outputDirectory, TestSettings testSettings, TestSuiteDefinition testSuiteDefinition, string testRunId, string testReportPath) @@ -306,7 +311,7 @@ private void SetupMocks(string outputDirectory, TestSettings testSettings, TestS private void Verify(string testConfigFile, string environmentId, string tenantId, string domain, string queryParams, - string outputDirectory, string testRunId, string testRunDirectory, TestSuiteDefinition testSuiteDefinition, TestSettings testSettings) + string outputDirectory, string testRunName, string testRunId, string testRunDirectory, TestSuiteDefinition testSuiteDefinition, TestSettings testSettings) { MockState.Verify(x => x.ParseAndSetTestState(testConfigFile, MockLogger.Object), Times.Once()); MockState.Verify(x => x.SetEnvironment(environmentId), Times.Once()); @@ -314,7 +319,7 @@ private void Verify(string testConfigFile, string environmentId, string tenantId MockState.Verify(x => x.SetDomain(domain), Times.Once()); MockState.Verify(x => x.SetOutputDirectory(outputDirectory), Times.Once()); - MockTestReporter.Verify(x => x.CreateTestRun("Power Fx Test Runner", "User"), Times.Once()); + MockTestReporter.Verify(x => x.CreateTestRun(testRunName, "User"), Times.Once()); MockTestReporter.Verify(x => x.StartTestRun(testRunId), Times.Once()); MockFileSystem.Verify(x => x.CreateDirectory(testRunDirectory), Times.Once()); @@ -367,7 +372,7 @@ public async Task TestEngineThrowsOnNullArguments(string? testConfigFilePath, st } var outputDirectory = new DirectoryInfo("TestOutput"); #if RELEASE - var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", "My Test"); MockTestEngineEventHandler.Verify(x => x.EncounteredException(It.IsAny()), Times.Once()); Assert.Equal("InvalidOutputDirectory", testResultsDirectory); //adding just to have usage in release configuration @@ -406,7 +411,7 @@ public async Task TestEngineExceptionOnNotPermittedOutputPath(string outputDirLo var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); var outputDirectory = new DirectoryInfo(outputDirLoc); MockFileSystem.Setup(x => x.GetDefaultRootTestEngine()).Returns("C:\\testPath" + Path.DirectorySeparatorChar); - var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", "My Test"); // UserInput Exception is handled within TestEngineEventHandler, and then returns the test results directory path MockTestEngineEventHandler.Verify(x => x.EncounteredException(It.IsAny()), Times.Once()); } @@ -437,7 +442,7 @@ public async Task TestEngineReturnsPathOnUserInputErrors() var outputDirectory = new DirectoryInfo("TestOutput"); MockFileSystem.Setup(x => x.GetDefaultRootTestEngine()).Returns(outputDirectory.FullName); - var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); + var testResultsDirectory = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, "", "My Test"); // UserInput Exception is handled within TestEngineEventHandler, and then returns the test results directory path MockTestEngineEventHandler.Verify(x => x.EncounteredException(exceptionToThrow), Times.Once()); Assert.NotNull(testResultsDirectory); diff --git a/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs b/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs index bbd962ec..ca5152eb 100644 --- a/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs +++ b/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs @@ -25,6 +25,7 @@ public class TestState : ITestState private TestPlanDefinition TestPlanDefinition { get; set; } private List TestCases { get; set; } = new List(); + private string EnvironmentId { get; set; } private string Domain { get; set; } diff --git a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj index 0928e8cf..e1ed5896 100644 --- a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj +++ b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj @@ -39,12 +39,16 @@ false + + + + @@ -54,9 +58,7 @@ - - - + @@ -78,4 +80,7 @@ NamespaceResource.Designer.cs + + + diff --git a/src/Microsoft.PowerApps.TestEngine/Modules/NamespaceResource.Designer.cs b/src/Microsoft.PowerApps.TestEngine/Modules/NamespaceResource.Designer.cs index 0163e2d8..5f596aa4 100644 --- a/src/Microsoft.PowerApps.TestEngine/Modules/NamespaceResource.Designer.cs +++ b/src/Microsoft.PowerApps.TestEngine/Modules/NamespaceResource.Designer.cs @@ -232,7 +232,7 @@ internal static string System_ComponentModel_Composition { } /// - /// Looks up a localized string similar to System.Console. + /// Looks up a localized string similar to Console. /// internal static string System_Console { get { diff --git a/src/Microsoft.PowerApps.TestEngine/Reporting/ITestRunSummary.cs b/src/Microsoft.PowerApps.TestEngine/Reporting/ITestRunSummary.cs new file mode 100644 index 00000000..5be2cdf3 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/Reporting/ITestRunSummary.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.PowerApps.TestEngine.Reporting +{ + /// + /// Interface for generating test run summary reports + /// + public interface ITestRunSummary + { + /// + /// Generates a summary report from test result files + /// + /// Directory containing the .trx files + /// Path where the summary report will be saved + /// Optional name to filter test runs by. If specified, only includes test runs with matching names + /// Path to the generated summary report + string GenerateSummaryReport(string resultsDirectory, string outputPath, string runName = null); + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/Reporting/Templates/TestRunSummaryTemplate.html b/src/Microsoft.PowerApps.TestEngine/Reporting/Templates/TestRunSummaryTemplate.html new file mode 100644 index 00000000..5a610fae --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/Reporting/Templates/TestRunSummaryTemplate.html @@ -0,0 +1,1546 @@ + + + + + + {{TITLE}} + + + + + + + + +
+
+
+
+

PowerApps Test Engine Report

+ Report generated on: {{REPORT_DATE}} +
+
+
+
+ +
+ + + +
+ +
+
+
+

Tests Passed

+

{{PASS_COUNT}}

+
+
+

Tests Failed

+

{{FAIL_COUNT}}

+
+
+

Health Score

+

{{HEALTH_PERCENT}}%

+
+
+

Total Tests

+

{{TOTAL_COUNT}}

+
+
+
+
+
+
+ +
+
Test Results Overview
+
+
+ +
+
+
+ +
+
Environment Information
+
+ {{ENVIRONMENT_INFO}} +
+
+ +
+
Test Summary
+
+ {{SUMMARY_TABLE}} +
+
+ +
+
Health Score Summary
+
+
+ {{HEALTH_CALCULATION}} +
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
Coverage by Page Type
+
+
+ +
+
+
+ +
+
Coverage Details
+
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + {{TEST_RESULTS_CARDS}} +
+
+
+
+ + + + +
+

Powered by PowerApps Test Engine - © Microsoft Corporation. All rights reserved.

+
+ + + + + + + + + + + + + + + diff --git a/src/Microsoft.PowerApps.TestEngine/Reporting/TestRunSummary.cs b/src/Microsoft.PowerApps.TestEngine/Reporting/TestRunSummary.cs new file mode 100644 index 00000000..20193a1f --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/Reporting/TestRunSummary.cs @@ -0,0 +1,2059 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Web; +using System.Xml; +using System.Xml.Serialization; +using Microsoft.PowerApps.TestEngine.Reporting.Format; +using Microsoft.PowerApps.TestEngine.System; +using Path = System.IO.Path; + +namespace Microsoft.PowerApps.TestEngine.Reporting +{ + /// + /// Represents environment information for the test run report + /// + public class EnvironmentInfo + { + /// + /// Machine name where the tests were run + /// + public string MachineName { get; set; } + + /// + /// Operating system information + /// + public string OperatingSystem { get; set; } + + /// + /// When the report was generated + /// + public string ReportTimestamp { get; set; } + + /// + /// Version of the PowerApps Test Engine + /// + public string TestEngineVersion { get; set; } + + /// + /// Test run start time + /// + public string StartTime { get; set; } + + /// + /// Test run end time + /// + public string EndTime { get; set; } + + /// + /// Total duration of the test run + /// + public string TotalDuration { get; set; } + + /// + /// Number of test files processed + /// + public int TestFileCount { get; set; } + } + + /// + /// Class to encapsulate all data needed for the HTML report template + /// + public class TemplateData + { + // Summary data + public int TotalTests { get; set; } + public int PassedTests { get; set; } + public int FailedTests { get; set; } + public double PassPercentage { get; set; } + public int HealthScore { get; set; } + public string TotalDuration { get; set; } + public string StartTime { get; set; } + public string EndTime { get; set; } + public int TestFileCount { get; set; } // Environment information + public EnvironmentInfo Environment { get; set; } + + // Test data + public List> TestsData { get; set; } + public string TestResultsRows { get; set; } + public string TestResultsCards { get; set; } + + // Structured test results data - will eventually replace TestResultsRows and TestResultsCards + public Dictionary>> GroupedTests { get; set; } + + // Coverage data + public List> CoverageData { get; set; } + public List CoverageLabels { get; set; } + public List CoveragePassData { get; set; } + public List CoverageFailData { get; set; } + + // Application metadata + public Dictionary> AppTypes { get; set; } + public Dictionary> EntityTypes { get; set; } + + // Video information + public List> Videos { get; set; } + public TemplateData() + { + TestsData = new List>(); + GroupedTests = new Dictionary>>(); + CoverageData = new List>(); + CoverageLabels = new List(); + CoveragePassData = new List(); + CoverageFailData = new List(); + AppTypes = new Dictionary>(); + EntityTypes = new Dictionary>(); + Videos = new List>(); + TestResultsRows = string.Empty; + TestResultsCards = string.Empty; + TotalDuration = "N/A"; + StartTime = "N/A"; + EndTime = "N/A"; + TestFileCount = 0; + HealthScore = 0; + Environment = new EnvironmentInfo + { + MachineName = global::System.Environment.MachineName, + OperatingSystem = global::System.Environment.OSVersion.ToString(), + ReportTimestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + TotalDuration = "N/A", + StartTime = "N/A", + EndTime = "N/A", + TestFileCount = 0 + }; + } + } + + /// + /// Generates a summary report from test run results + /// + public class TestRunSummary : ITestRunSummary + { + private readonly IFileSystem _fileSystem; + + public Func GetTrxFiles = path => Directory.GetFiles(path, "*.trx", SearchOption.AllDirectories); + + // Function to find video files in a directory and its subdirectories + public Func GetVideoFiles = path => Directory.GetFiles(path, "*.webm", SearchOption.AllDirectories); + + /// + /// Initializes a new instance of the class. + /// + /// The file system implementation + public TestRunSummary(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + + // Ensure template files exist + EnsureTemplatesExist(); + } + + /// + /// Generates a summary report from test result files + /// + /// Directory containing the .trx files + /// Path where the summary report will be saved + /// Optional name to filter test runs by. If specified, only includes test runs with matching names + /// Path to the generated summary report + public string GenerateSummaryReport(string resultsDirectory, string outputPath, string runName = null) + { + if (string.IsNullOrEmpty(resultsDirectory)) + { + throw new ArgumentException("Results directory cannot be null or empty", nameof(resultsDirectory)); + } + + if (!_fileSystem.Exists(resultsDirectory)) + { + throw new ArgumentException($"Results directory does not exist: {resultsDirectory}", nameof(resultsDirectory)); + } + + if (string.IsNullOrEmpty(outputPath)) + { + throw new ArgumentException("Output path cannot be null or empty", nameof(outputPath)); + } + + // Find all .trx files in the directory + var trxFiles = GetTrxFiles(resultsDirectory); + if (trxFiles.Length == 0) + { + throw new InvalidOperationException($"No .trx files found in directory: {resultsDirectory}"); + } + + var testRuns = new List(); + foreach (var trxFile in trxFiles) + { + var testRun = LoadTestRunFromFile(trxFile); + if (testRun != null) + { + // Filter by runName if specified + if (string.IsNullOrEmpty(runName) || + (testRun.Name != null && + testRun.Name.IndexOf(runName, StringComparison.OrdinalIgnoreCase) >= 0)) + { + testRuns.Add(testRun); + } + } + } + + // Generate the HTML report + var reportHtml = GenerateHtmlReport(testRuns); + // Make sure output directory exists + var outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDirectory) && !_fileSystem.Exists(outputDirectory)) + { + _fileSystem.CreateDirectory(outputDirectory); + } + + // Write the HTML report to the output file + _fileSystem.WriteTextToFile(outputPath, reportHtml, overwrite: true); + + return outputPath; + } + + /// + /// Loads a TestRun object from a .trx file and enhances it with video information + /// + /// Path to the .trx file + /// A TestRun object representing the test run + public TestRun LoadTestRunFromFile(string trxFilePath) + { + try + { + var fileContent = _fileSystem.ReadAllText(trxFilePath); + + if (string.IsNullOrWhiteSpace(fileContent)) + { + // Log warning about empty file + Debug.WriteLine($"Warning: File {trxFilePath} is empty."); + return null; + } + + var serializer = new XmlSerializer(typeof(TestRun)); + + using var stringReader = new StringReader(fileContent); + using var reader = XmlReader.Create(stringReader); + + var testRun = (TestRun)serializer.Deserialize(reader); + // Validate the deserialized object + if (testRun.Results == null || testRun.Results.UnitTestResults == null) + { + // Log warning about invalid format + Debug.WriteLine($"Warning: File {trxFilePath} has invalid format - missing test results."); + return null; + } + + // Check if the test run has videos associated with it + // Look for video files in the same directory as the trx file and its parent directory + var trxDirectory = Path.GetDirectoryName(trxFilePath); + if (!string.IsNullOrEmpty(trxDirectory)) + { + try + { + // Search for video files recursively in the directory and parent directory + var videoFiles = GetVideoFiles(trxDirectory); + + // If there are video files, add them to the test run output + if (videoFiles.Length > 0) + { + // Find the valid video files (filter out incomplete recordings) + long minValidSize = 10 * 1024; // 10KB minimum to consider a video valid + var validVideos = videoFiles + .Where(v => _fileSystem.GetFileSize(v) >= minValidSize) + .ToArray(); + + if (validVideos.Length > 0) + { + // In PowerApps TestEngine, videos are associated with the entire test session, + // not with individual tests. Store video paths in the ResultSummary. + if (testRun.ResultSummary == null) + { + testRun.ResultSummary = new TestResultSummary(); + } + + if (testRun.ResultSummary.Output == null) + { + testRun.ResultSummary.Output = new TestOutput(); + } + + // Initialize or format StdOut + if (testRun.ResultSummary.Output.StdOut == null) + { + testRun.ResultSummary.Output.StdOut = "{ "; + } + else if (!testRun.ResultSummary.Output.StdOut.Contains("{")) + { + testRun.ResultSummary.Output.StdOut = "{ " + testRun.ResultSummary.Output.StdOut; + } + + if (testRun.ResultSummary.Output.StdOut.EndsWith("}")) + { + testRun.ResultSummary.Output.StdOut = testRun.ResultSummary.Output.StdOut.Substring(0, testRun.ResultSummary.Output.StdOut.Length - 1); + } + + // Add video paths as JSON - only if they don't already exist + if (!testRun.ResultSummary.Output.StdOut.Contains("\"VideoPath\"") && + !testRun.ResultSummary.Output.StdOut.Contains("\"Videos\"")) + { + // Always sort videos by size (larger files first - more likely to be complete) + validVideos = validVideos + .OrderByDescending(v => _fileSystem.GetFileSize(v)) + .ToArray(); + + if (testRun.ResultSummary.Output.StdOut.Length > 0) + { + testRun.ResultSummary.Output.StdOut += ", "; + } + + // For backward compatibility, include VideoPath for the first video + if (validVideos.Length >= 1) + { + testRun.ResultSummary.Output.StdOut += $"\"VideoPath\": \"{validVideos[0].Replace("\\", "\\\\")}\", "; + } + + // Always add Videos array regardless of the number of videos + // This ensures the test has consistent access to the Videos property + testRun.ResultSummary.Output.StdOut += "\"Videos\": ["; + for (int i = 0; i < validVideos.Length; i++) + { + if (i > 0) + testRun.ResultSummary.Output.StdOut += ", "; + testRun.ResultSummary.Output.StdOut += $"\"{validVideos[i].Replace("\\", "\\\\")}\""; + } + testRun.ResultSummary.Output.StdOut += "], "; + } + + testRun.ResultSummary.Output.StdOut = testRun.ResultSummary.Output.StdOut.Trim(); + if (testRun.ResultSummary.Output.StdOut.EndsWith(",")) + { + testRun.ResultSummary.Output.StdOut = testRun.ResultSummary.Output.StdOut.Substring(0, testRun.ResultSummary.Output.StdOut.Length - 1) + "}"; + } + + // Calculate estimated timecodes for each test based on its position + // and duration in the test run sequence + TimeSpan cumulativeTime = TimeSpan.Zero; + foreach (var testResult in testRun.Results.UnitTestResults) + { + string timecodeStart = cumulativeTime.ToString(@"hh\:mm\:ss"); + + // Parse duration and add to cumulative time + if (!string.IsNullOrEmpty(testResult.Duration)) + { + TimeSpan durationTimeSpan; + if (TimeSpan.TryParse(testResult.Duration, out durationTimeSpan)) + { + // Add the timecode to the test result for use in the report + if (testResult.Output == null) + { + testResult.Output = new TestOutput(); + } + + if (testResult.Output.StdOut == null) + { + testResult.Output.StdOut = ""; + } + + // Add the timecode only if it doesn't already exist + if (!testResult.Output.StdOut.Contains("TimecodeStart")) + { + if (testResult.Output.StdOut.Length > 0) + { + testResult.Output.StdOut += ", "; + } + testResult.Output.StdOut += $" TimecodeStart: \"{timecodeStart}\""; + } + + cumulativeTime = cumulativeTime.Add(durationTimeSpan); + + // Add the timecode end as well for completeness + string timecodeEnd = cumulativeTime.ToString(@"hh\:mm\:ss"); + if (!testResult.Output.StdOut.Contains("TimecodeEnd")) + { + if (testResult.Output.StdOut.Length > 0) + { + testResult.Output.StdOut += ", "; + } + testResult.Output.StdOut += $" TimecodeEnd: \"{timecodeEnd}\""; + } + } + } + } + } + } + } + catch (Exception ex) + { + // Log any errors with finding video files + Debug.WriteLine($"Error finding video files for {trxFilePath}: {ex.Message}"); + } + } + + return testRun; + } + catch (XmlException xmlEx) + { + // Using Debug.WriteLine instead of Console to avoid namespace conflicts + Debug.WriteLine($"XML parsing error in file {trxFilePath}: {xmlEx.Message}"); + return null; + } + catch (InvalidOperationException invOpEx) + { + // Using Debug.WriteLine instead of Console to avoid namespace conflicts + Debug.WriteLine($"Serialization error in file {trxFilePath}: {invOpEx.Message}"); + return null; + } + catch (Exception ex) + { + // Log or handle the exception as needed + // Using Debug.WriteLine instead of Console to avoid namespace conflicts + Debug.WriteLine($"Error loading test run from file {trxFilePath}: {ex.Message}"); + return null; + } + } + + /// + /// Generates template context required by an HTML report from test runs + /// + /// List of test runs + /// Template data from the runs + public TemplateData GetTemplateContext(List testRuns) + { + // Create template data container + var templateData = new TemplateData(); // Prepare summary data + int totalTests = 0; + int passedTests = 0; + int failedTests = 0; + int otherTests = 0; // Not passed or failed (e.g., skipped, inconclusive) + DateTime? minStartTime = null; + DateTime? maxEndTime = null; + + // Track overall test run times from TestRun.Times properties + DateTime? runStartTime = null; + DateTime? runEndTime = null; + + // Build the test results rows for both table format and card format + var testResultsRows = new StringBuilder(); + var testResultsCards = new StringBuilder(); + + // Group tests by test run + var groupedTests = GroupTestsByRun(testRuns); // Process all test results for statistics + foreach (var testRun in testRuns) + { + // Track overall test run times from TestRun.Times + if (testRun.Times != null) + { + if (testRun.Times.Start != default) + { + if (runStartTime == null || testRun.Times.Start < runStartTime) + { + runStartTime = testRun.Times.Start; + } + } + + if (testRun.Times.Finish != default) + { + if (runEndTime == null || testRun.Times.Finish > runEndTime) + { + runEndTime = testRun.Times.Finish; + } + } + } + + foreach (var testResult in testRun.Results.UnitTestResults) + { + totalTests++; + + if (testResult.Outcome == TestReporter.PassedResultOutcome) + { + passedTests++; + } + else if (testResult.Outcome == TestReporter.FailedResultOutcome) + { + failedTests++; + } + else + { + otherTests++; // Count other outcomes (not passed or failed) + } + + // Track earliest start time and latest end time for individual tests + if (testResult.StartTime != default) + { + if (minStartTime == null || testResult.StartTime < minStartTime) + { + minStartTime = testResult.StartTime; + } + } + + if (testResult.EndTime != default) + { + if (maxEndTime == null || testResult.EndTime > maxEndTime) + { + maxEndTime = testResult.EndTime; + } + } + } + } + // Create JSON data for the Tabulator tables and charts + var testsData = new List>(); + + // Collect coverage data grouped by page type + var entityGroups = CollectCoverageData(testRuns); + + // Process grouped test results to build the HTML + foreach (var group in groupedTests) + { + // Create a list to hold this group's test data + var groupTestData = new List>(); + templateData.GroupedTests[group.Key] = groupTestData; + + // Add a group header + testResultsRows.AppendLine($@" + + {group.Key} ({group.Value.Count} tests) + "); + + // We'll build a list of test data for this group + // The actual card HTML generation will happen at the end of this group's processing + // Add test results for this group + foreach (var (testResult, testRun) in group.Value) + { + // Use the helper method to create test data with all the necessary information + var testData = CreateTestResultData(testResult, testRun, group.Key); + // Add the test data to this group's list + groupTestData.Add(testData); + + // Extract error message for UI formatting + string errorMessage = (string)testData["errorMessage"]; + bool hasError = (bool)testData["hasError"]; + + // Convert app URL to a hyperlink if available + string appUrl = (string)testData["appUrl"]; + string appUrlLink = string.IsNullOrEmpty(appUrl) ? + "" : + $@"Open App"; + + // Format error message with a details toggle if it's too long + string errorDisplay = hasError && errorMessage.Length > 100 ? + $@"
+
{errorMessage.Substring(0, 100)}...
+ +
{errorMessage}
+
" : + errorMessage; + + // Build the row with appropriate styling based on outcome - now handled by GenerateTestResultRowHtml + + // Use helper methods to generate HTML for test row and video section + testResultsRows.AppendLine(GenerateTestResultRowHtml(testData)); + string videoSection = GenerateVideoSectionHtml(testData); + + // Get video information for template + Dictionary videoInfo = null; + if (testData["videoInfo"] != null) + { + videoInfo = testData["videoInfo"] as Dictionary; + } + // We no longer need to manually add card rows here since we're using the + // GenerateTestCardHtml method at the end of group processing + + // Add the test data to template data list + templateData.TestsData.Add(testData); + + // Add video information to the videos collection if available + if (testData["hasVideo"] as bool? == true && videoInfo != null) + { + templateData.Videos.Add(new Dictionary { + { "id", videoInfo["id"] }, + { "path", videoInfo["path"] }, + { "timecodeStart", videoInfo["timecodeStart"] }, + { "timecode30s", videoInfo["timecode30s"] }, + { "timecode60s", videoInfo["timecode60s"] }, + { "testId", testData["id"] }, + { "testName", testData["testName"] } + }); + } + + // Track app types statistics + string appType = (string)testData["appType"]; + if (!templateData.AppTypes.ContainsKey(appType)) + { + templateData.AppTypes[appType] = new Dictionary { + { "passed", 0 }, + { "failed", 0 }, + { "total", 0 } + }; + } + + templateData.AppTypes[appType]["total"]++; + string outcome = (string)testData["outcome"]; + if (outcome == TestReporter.PassedResultOutcome) + { + templateData.AppTypes[appType]["passed"]++; + } + else if (outcome == TestReporter.FailedResultOutcome) + { + templateData.AppTypes[appType]["failed"]++; + } + } + // Generate and add the card using the template method + string cardHtml = GenerateTestCardHtml(group.Key, templateData.GroupedTests[group.Key]); + testResultsCards.Append(cardHtml); + } // Calculate pass percentage and total duration + templateData.PassPercentage = totalTests > 0 ? (double)passedTests / totalTests * 100 : 0; + templateData.TotalDuration = "N/A"; + + // Prefer TestRun.Times data for start/end times if available + if (runStartTime.HasValue && runEndTime.HasValue) + { + TimeSpan runDuration = runEndTime.Value - runStartTime.Value; + templateData.TotalDuration = $"{runDuration.TotalMinutes:F2} minutes"; + templateData.StartTime = runStartTime.Value.ToString("g"); + templateData.EndTime = runEndTime.Value.ToString("g"); + } + // Fall back to individual test times if run times are not available + else if (minStartTime.HasValue && maxEndTime.HasValue) + { + TimeSpan duration = maxEndTime.Value - minStartTime.Value; + templateData.TotalDuration = $"{duration.TotalMinutes:F2} minutes"; + templateData.StartTime = minStartTime.Value.ToString("g"); + templateData.EndTime = maxEndTime.Value.ToString("g"); + } + + // Calculate health score - currently using pass percentage + templateData.HealthScore = (int)Math.Round(templateData.PassPercentage); + + // Set summary data + templateData.TotalTests = totalTests; + templateData.PassedTests = passedTests; + templateData.FailedTests = failedTests; + templateData.TestFileCount = testRuns.Count; + + // Add default values in case there are no entity groups + if (entityGroups.Count == 0) + { + templateData.CoverageLabels.Add("No Data"); + templateData.CoveragePassData.Add(0); + templateData.CoverageFailData.Add(0); + } + foreach (var entity in entityGroups) + { + var name = entity.Key; + var type = entity.Value.entityType; + var passes = entity.Value.passes; + var failures = entity.Value.failures; + var total = passes + failures; + var status = (passes > 0 && failures == 0) ? "Healthy" : + (passes == 0 && failures > 0) ? "Failed" : + "Mixed Results"; + templateData.CoverageData.Add(new Dictionary { + { "entityName", type != "Unknown" ? type : name }, // Use entityType if known, otherwise use page type + { "entityType", type }, // Keep entityType for consistency + { "pageType", name }, // Page type from the key used in the grouping + { "status", status }, + { "passes", passes }, + { "failures", failures }, + { "totalTests", total } // Add total tests count for convenience + }); + // Use the key (page type) for labels to match the coverage chart + templateData.CoverageLabels.Add(name); + templateData.CoveragePassData.Add(passes); + templateData.CoverageFailData.Add(failures); + } + + // Set final template data + templateData.TestResultsRows = testResultsRows.ToString(); + templateData.TestResultsCards = testResultsCards.ToString(); + + // Ensure test data isn't empty + if (templateData.TestsData.Count == 0) + { + templateData.TestsData.Add(new Dictionary { + { "id", Guid.NewGuid().ToString() }, + { "testName", "No tests found" }, + { "outcome", "N/A" }, + { "duration", "N/A" }, + { "startTime", "N/A" }, + { "endTime", "N/A" }, + { "entityName", "N/A" }, + { "pageType", "N/A" }, + { "entityType", "N/A" }, + { "appType", "N/A" }, + { "errorMessage", "" }, + { "appUrl", "" }, + { "resultsPath", "" }, + { "videoPath", "" }, + { "timecode", "00:00:00" }, + { "folderPath", "" }, + { "logFiles", new List>() } + }); + } // Set environment information in TemplateData + templateData.Environment.TestEngineVersion = GetAssemblyVersion(); + templateData.Environment.StartTime = templateData.StartTime; + templateData.Environment.EndTime = templateData.EndTime; + templateData.Environment.TotalDuration = templateData.TotalDuration; + templateData.Environment.TestFileCount = templateData.TestFileCount; + + return templateData; + } + + /// + /// Generates an HTML report from test runs + /// + /// List of test runs + /// HTML content for the report + public string GenerateHtmlReport(List testRuns) + { + // Get the HTML template from embedded resources + var htmlTemplate = GetEmbeddedHtmlTemplate(); + + var templateData = GetTemplateContext(testRuns); + + // Serialize the JSON data for Tabulator and charts + Func serializeObject = (object obj) => + { + try + { + // Use Newtonsoft.Json instead of System.Text.Json + // Escape quotes to ensure it's valid within HTML data attributes + // This prevents JavaScript errors with direct JSON injection + string serialized = Newtonsoft.Json.JsonConvert.SerializeObject(obj, Newtonsoft.Json.Formatting.None); + return serialized.Replace("\"", """); + } + catch (Exception) + { + // Fallback for serialization errors + return "[]"; + } + }; + + // Replace placeholders in the new template + var report = htmlTemplate + .Replace("{{TITLE}}", $"PowerApps Test Engine Results - {templateData.HealthScore}% Health Score") + .Replace("{{REPORT_DATE}}", templateData.Environment.ReportTimestamp) + .Replace("{{PASS_COUNT}}", templateData.PassedTests.ToString()) + .Replace("{{FAIL_COUNT}}", templateData.FailedTests.ToString()) + .Replace("{{TOTAL_COUNT}}", templateData.TotalTests.ToString()) + .Replace("{{HEALTH_PERCENT}}", templateData.HealthScore.ToString()) + .Replace("{{TEST_RESULTS_CARDS}}", templateData.TestResultsCards) + // Environment information - generate from template data using helper method + .Replace("{{ENVIRONMENT_INFO}}", GenerateEnvironmentInfoHtml(templateData.Environment)) + // Health calculation - generate from template data using helper method + .Replace("{{HEALTH_CALCULATION}}", GenerateHealthCalculationHtml(templateData)) + + // Videos section - generate from structured data in template + .Replace("{{VIDEOS_DATA}}", serializeObject(templateData.Videos)) + .Replace("{{SUMMARY_TABLE}}", GenerateSummaryTableHtml(templateData)) + // JSON data + .Replace("{{TESTS_DATA}}", serializeObject(templateData.TestsData)) + .Replace("{{GROUPED_TESTS_DATA}}", serializeObject(templateData.GroupedTests)) + .Replace("{{COVERAGE_DATA}}", serializeObject(templateData.CoverageData)) + .Replace("{{COVERAGE_CHART_LABELS}}", serializeObject(templateData.CoverageLabels)) + .Replace("{{COVERAGE_CHART_PASS_DATA}}", serializeObject(templateData.CoveragePassData)) + .Replace("{{COVERAGE_CHART_FAIL_DATA}}", serializeObject(templateData.CoverageFailData)) + .Replace("{{APP_TYPES_DATA}}", serializeObject(templateData.AppTypes)); + + return report; + } + + /// + /// Renders a template with placeholder values + /// + /// Template string with placeholders + /// Dictionary of placeholder keys and their replacement values + /// Template with placeholders replaced with values + private string RenderTemplate(string template, Dictionary values) + { + if (string.IsNullOrEmpty(template) || values == null) + { + return template; + } + + string result = template; + foreach (var kvp in values) + { + // Replace placeholders in format {{KEY}} with values + result = result.Replace("{{" + kvp.Key + "}}", kvp.Value); + } + + return result; + } + + /// + /// Gets a template from the embedded resources or a file + /// + /// Name of the template to load + /// Template content as string + private string GetTemplate(string templateName) + { + try + { + // First try to load from embedded resources + string resourcePath = $"Microsoft.PowerApps.TestEngine.Reporting.Templates.{templateName}.html"; + + using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourcePath)) + { + if (stream != null) + { + using (StreamReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + } + + // If not found in embedded resources, try to load from file + string filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Templates", $"{templateName}.html"); + if (_fileSystem.Exists(filePath)) + { + return _fileSystem.ReadAllText(filePath); + } + + // If neither found, return empty string + Debug.WriteLine($"Template not found: {templateName}"); + return string.Empty; + } + catch (Exception ex) + { + Debug.WriteLine($"Error loading template {templateName}: {ex.Message}"); + return string.Empty; + } + } + + /// + /// Gets the HTML template from embedded resources + /// + /// The HTML template string + private string GetEmbeddedHtmlTemplate() + { + return GetTemplate("TestRunSummaryTemplate"); + } /// + /// Groups test results by entity name and type for better organization in the report. + /// This method parses the AppURL from test results to extract entity information and uses + /// that for grouping instead of just using test run names. This makes test results more meaningful + /// to users by organizing tests by the entities they act upon rather than arbitrary test run names. /// + /// List of test runs + /// When true, group by page type; when false, group by entity name + /// Dictionary mapping entity names or page types with their results + public Dictionary> GroupTestsByRun(List testRuns, bool groupByPageType = false) + { + var grouped = new Dictionary>(); + + foreach (var testRun in testRuns) + { + foreach (var result in testRun.Results.UnitTestResults) + { // Extract the AppURL from the test result stdout if it exists + string appUrl = string.Empty; + string entityName = "Unknown"; + string pageType = "Unknown"; + + if (!string.IsNullOrEmpty(testRun.ResultSummary?.Output?.StdOut)) + { + // Try to parse as JSON first using the existing ExtractValueBetween method + appUrl = ExtractValueBetween(testRun.ResultSummary.Output.StdOut, "AppURL\": \"", "\""); + + // If still empty and StdOut looks like JSON, try using JSON parsing + if (string.IsNullOrEmpty(appUrl) && + testRun.ResultSummary.Output.StdOut.TrimStart().StartsWith("{") && + testRun.ResultSummary.Output.StdOut.TrimEnd().EndsWith("}")) + { + try + { + using (JsonDocument doc = JsonDocument.Parse(testRun.ResultSummary.Output.StdOut)) + { + if (doc.RootElement.TryGetProperty("AppURL", out JsonElement appUrlElement) && + appUrlElement.ValueKind == JsonValueKind.String) + { + appUrl = appUrlElement.GetString() ?? string.Empty; + } + } + } + catch + { + // Ignore JSON parsing errors - we'll use default values + } + } + } + + // Use GetAppTypeAndEntityFromUrl to extract entity information from AppURL + if (!string.IsNullOrEmpty(appUrl)) + { + var appInfo = GetAppTypeAndEntityFromUrl(appUrl); + entityName = appInfo.entityName; + pageType = appInfo.pageType; + // If entityName is Unknown but pageType is not, use pageType for grouping + if (entityName == "Unknown" && pageType != "Unknown") + { + entityName = pageType; + } + } + + // Select grouping key based on strategy + string groupKey; + if (groupByPageType && pageType != "Unknown") + { + // Group by page type when requested and page type is known + groupKey = pageType; + } + else + { + // Otherwise group by entity name + groupKey = entityName; + } + + // If we couldn't determine either page type or entity name, use the test run name as fallback + if (groupKey == "Unknown") + { + groupKey = !string.IsNullOrEmpty(testRun.Name) ? testRun.Name : $"Run ID: {testRun.Id}"; + } + + // Create the group if it doesn't exist + if (!grouped.ContainsKey(groupKey)) + { + grouped[groupKey] = new List<(UnitTestResult, TestRun)>(); + } + + // Add the result to the group + grouped[groupKey].Add((result, testRun)); + } + } + + return grouped; + } + + /// + /// Gets the current assembly version + /// + /// The assembly version string + private string GetAssemblyVersion() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetName().Version; + return version?.ToString() ?? "Unknown"; + } + catch + { + return "Unknown"; + } + } + + /// + /// Extracts a value between start and end markers in a source string. + /// Enhanced to handle complex nested delimiters often found in JSON-like strings. + /// + /// Source string + /// Start marker + /// End marker + /// String between markers or empty string if not found + private string ExtractValueBetween(string source, string start, string end) + { + if (string.IsNullOrEmpty(source)) + return string.Empty; + + // Find the start marker position + int startIndex = source.IndexOf(start, StringComparison.OrdinalIgnoreCase); + if (startIndex < 0) + return string.Empty; + + startIndex += start.Length; + + // Special handling for file paths and JSON strings + if (end == "\"" && start.EndsWith("\"")) + { + // We're extracting a quoted string value, likely from JSON + // Need to handle escaped quotes within the string + int endIndex = startIndex; + bool foundEnd = false; + + while (endIndex < source.Length && !foundEnd) + { + endIndex = source.IndexOf(end, endIndex); + + if (endIndex < 0) + { + // End quote not found + return source.Substring(startIndex); + } + + // Check if this quote is escaped + if (endIndex > 0 && source[endIndex - 1] == '\\') + { + // Count how many backslashes before the quote + int backslashCount = 0; + int position = endIndex - 1; + + while (position >= 0 && source[position] == '\\') + { + backslashCount++; + position--; + } + + // If there's an odd number of backslashes, the quote is escaped + if (backslashCount % 2 == 1) + { + // This quote is escaped, continue searching + endIndex++; + continue; + } + } + + // We found a genuine end quote + foundEnd = true; + } + + if (!foundEnd) + return source.Substring(startIndex); // Return the rest if no unescaped end quote + + return source.Substring(startIndex, endIndex - startIndex); + } + else + { + // Standard extraction for non-path and non-JSON strings + int endIndex = source.IndexOf(end, startIndex); + + if (endIndex < 0) + return source.Substring(startIndex); // Return the rest if end marker not found + + return source.Substring(startIndex, endIndex - startIndex); + } + } + + /// + /// Creates a structured test result object from a test result + /// + /// The test result + /// The test run + /// The group name + /// A dictionary containing the test result data + private Dictionary CreateTestResultData(UnitTestResult testResult, TestRun testRun, string groupName) + { + // Extract app URL and results path if available + string appUrl = ""; + string resultsPath = ""; + string videoPath = ""; + string entityType = "Unknown"; // Default type + string appType = "Unknown"; + string pageType = "Unknown"; + string errorMessage = testResult.Output?.ErrorInfo?.Message ?? ""; + bool hasError = !string.IsNullOrEmpty(errorMessage); + List videos = new List(); + + // Get app info from test run + if (testRun.ResultSummary?.Output?.StdOut != null) + { + try + { + // Try to parse the JSON content from StdOut + var stdOut = testRun.ResultSummary.Output.StdOut; + // Try to extract videos if they exist in the data + if (stdOut.Contains("\"Videos\"")) + { + // First try to parse using regex - more reliable for malformed JSON + var videosMatch = Regex.Match(stdOut, "\"Videos\"\\s*:\\s*\\[(.*?)\\]", RegexOptions.Singleline); + if (videosMatch.Success && videosMatch.Groups.Count > 1) + { + var videosContent = videosMatch.Groups[1].Value; + var videoItems = Regex.Matches(videosContent, "\"([^\"]*)\""); + foreach (Match match in videoItems) + { + if (match.Groups.Count > 1) + { + videos.Add(match.Groups[1].Value.Replace(@"\\", @"\")); + } + } + } + + // If regex didn't find any videos, try JSON parsing + if (videos.Count == 0 && stdOut.StartsWith("{") && stdOut.EndsWith("}")) + { + try + { + using (JsonDocument doc = JsonDocument.Parse(stdOut)) + { + if (doc.RootElement.TryGetProperty("Videos", out JsonElement videosElement) && + videosElement.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement videoItem in videosElement.EnumerateArray()) + { + if (videoItem.ValueKind == JsonValueKind.String) + { + var videoPathItem = videoItem.GetString(); + if (!string.IsNullOrWhiteSpace(videoPathItem)) + { + videos.Add(videoPathItem); + } + } + } + } + } + } + catch + { + // Ignore JSON parsing errors - we'll use other methods + } + } + } + + // Try parsing with JsonDocument for more reliable extraction + try + { + if (stdOut.StartsWith("{") && stdOut.EndsWith("}")) + { + using (JsonDocument doc = JsonDocument.Parse(stdOut)) + { + if (doc.RootElement.TryGetProperty("AppURL", out JsonElement appUrlElement) && + appUrlElement.ValueKind == JsonValueKind.String) + { + appUrl = appUrlElement.GetString() ?? ""; + } + + if (doc.RootElement.TryGetProperty("TestResults", out JsonElement resultsElement) && + resultsElement.ValueKind == JsonValueKind.String) + { + resultsPath = resultsElement.GetString() ?? ""; + } + + if (doc.RootElement.TryGetProperty("VideoPath", out JsonElement videoElement) && + videoElement.ValueKind == JsonValueKind.String) + { + videoPath = videoElement.GetString() ?? ""; + } + + if (doc.RootElement.TryGetProperty("EntityType", out JsonElement entityElement) && + entityElement.ValueKind == JsonValueKind.String) + { + entityType = entityElement.GetString() ?? "Unknown"; + } + } + } + } + catch + { + // Fallback to simple parsing if JSON parsing fails + appUrl = ExtractValueBetween(stdOut, "AppURL\": \"", "\""); + resultsPath = ExtractValueBetween(stdOut, "TestResults\": \"", "\""); + videoPath = ExtractValueBetween(stdOut, "VideoPath\": \"", "\""); + entityType = ExtractValueBetween(stdOut, "EntityType\": \"", "\""); + } + + // Parse app URL to determine app type, page type, and entity name + if (!string.IsNullOrEmpty(appUrl)) + { + var appInfo = GetAppTypeAndEntityFromUrl(appUrl); + appType = appInfo.appType; + + // Only override page type and entity name if they're not already set + // or if they're set to "Unknown" + if (pageType == "Unknown") + { + pageType = appInfo.pageType; + } + + // Update entityType based on the URL analysis if needed + if ((entityType == "Unknown" || string.IsNullOrEmpty(entityType)) && appInfo.entityName != "Unknown") + { + entityType = appInfo.entityName; + } + } + } + catch + { + // Ignore parsing errors + } + } + + // Format the duration + string duration = "N/A"; + if (!string.IsNullOrEmpty(testResult.Duration)) + { + TimeSpan durationTs; + if (TimeSpan.TryParse(testResult.Duration, out durationTs)) + { + duration = $"{durationTs.TotalSeconds:F2}s"; + } + else + { + duration = testResult.Duration; + } + } + // Format the start time - Use test run start time if available, otherwise use individual test start time + string startTime; + if (testRun.Times != null && testRun.Times.Start != default) + { + startTime = testRun.Times.Start.ToString("g"); + } + else + { + startTime = testResult.StartTime != default ? testResult.StartTime.ToString("g") : "N/A"; + } + + // Format end time - Use test run finish time if available, otherwise use individual test end time + string endTime; + if (testRun.Times != null && testRun.Times.Finish != default) + { + endTime = testRun.Times.Finish.ToString("g"); + } + else + { + endTime = testResult.EndTime != default ? testResult.EndTime.ToString("g") : "N/A"; + } + + // Get results directory + string resultsDirectory = string.IsNullOrEmpty(resultsPath) ? + string.Empty : + resultsPath?.Replace("\\", "/") ?? string.Empty; + + // Extract any video path mentioned in the test output + if (string.IsNullOrEmpty(videoPath)) + { + // First try to extract from test result StdOut if available + if (testResult.Output?.StdOut != null) + { + videoPath = ExtractValueBetween(testResult.Output.StdOut, "VideoPath: \"", "\""); + } + + // If not found, try test run StdOut + if (string.IsNullOrEmpty(videoPath) && testRun.ResultSummary?.Output?.StdOut != null) + { + videoPath = ExtractValueBetween(testRun.ResultSummary.Output.StdOut, "VideoPath\": \"", "\""); + } + } + + // Extract any timecodes from test results + string timecodeStart = "00:00:00"; + if (testResult.Output?.StdOut != null) + { + var extractedTimecode = ExtractValueBetween(testResult.Output.StdOut, "TimecodeStart: \"", "\""); + if (!string.IsNullOrEmpty(extractedTimecode)) + { + timecodeStart = extractedTimecode; + } + } + + // Store video information in a structured way + var videoInfo = new Dictionary(); + if (!string.IsNullOrEmpty(videoPath)) + { + videoInfo["path"] = videoPath.Replace("\\", "/"); + videoInfo["id"] = $"video-{testResult.TestId}"; + videoInfo["timecodeStart"] = timecodeStart; + videoInfo["timecode30s"] = AddTimeToTimecode(timecodeStart, 30); + videoInfo["timecode60s"] = AddTimeToTimecode(timecodeStart, 60); + } + + // Find log files in the results directory for this test + var testFolder = resultsDirectory; + if (testResult.ResultFiles?.ResultFile.Count() > 0) + { + testFolder = Path.GetDirectoryName(testResult.ResultFiles.ResultFile.FirstOrDefault()?.Path); + } + + var hasVideo = !string.IsNullOrEmpty(videoPath) || videos.Count > 0; + + // Create the test result data object + var results = new Dictionary { + { "id", testResult.TestId?.ToString() ?? Guid.NewGuid().ToString() }, + { "testName", testResult.TestName }, + { "outcome", testResult.Outcome }, + { "duration", duration }, + { "startTime", startTime }, + { "endTime", endTime }, + { "entityName", groupName }, + { "pageType", pageType }, + { "entityType", entityType }, + { "appType", appType }, + { "errorMessage", errorMessage }, + { "hasError", hasError }, + { "appUrl", appUrl }, + { "resultsPath", resultsPath }, + { "hasVideo", hasVideo }, + { "timecode", timecodeStart }, + { "folderPath", !string.IsNullOrEmpty(testFolder) ? testFolder : null }, + { "logFiles", testResult.ResultFiles?.ResultFile.Select(r => r.Path).ToList() }, + { "videoInfo", videoInfo.Count > 0 ? videoInfo : null } + }; + + // Add single video path for backward compatibility + if (!string.IsNullOrEmpty(videoPath)) + { + results.Add("videoPath", videoPath); + } + + // Make sure videos list includes the single videoPath if it exists + if (!string.IsNullOrEmpty(videoPath) && !videos.Contains(videoPath)) + { + videos.Add(videoPath); + } + + // Always add the videos array if we have any videos + if (videos.Count > 0) + { + results.Add("videos", videos.ToArray()); + } + + return results; + } + + /// + /// Helper method to generate the HTML for a test result row + /// + /// Dictionary containing test data + /// HTML string for the test result row + private string GenerateTestResultRowHtml(Dictionary testData) + { + string outcome = (string)testData["outcome"]; + string appUrl = (string)testData["appUrl"]; + string errorMessage = (string)testData["errorMessage"]; + bool hasError = (bool)testData["hasError"]; + + // Convert app URL to a hyperlink if available + string appUrlLink = string.IsNullOrEmpty(appUrl) ? + "" : + $@"Open App"; + + // Format error message using template + string errorDisplay; + if (hasError && errorMessage.Length > 100) + { + string errorTemplate = GetTemplate("ErrorDisplay"); + if (string.IsNullOrEmpty(errorTemplate)) + { + // Fall back to inline HTML if template not available + errorDisplay = $@"
+
{errorMessage.Substring(0, 100)}...
+ +
{errorMessage}
+
"; + } + else + { + // Use template + errorDisplay = RenderTemplate(errorTemplate, new Dictionary { + { "ERROR_SHORT", errorMessage.Substring(0, 100) + "..." }, + { "ERROR_FULL", errorMessage } + }); + } + } + else + { + errorDisplay = errorMessage; + } + + // Build the row with appropriate styling based on outcome + string rowClass = outcome == TestReporter.PassedResultOutcome ? "success" : + outcome == TestReporter.FailedResultOutcome ? "danger" : + "warning"; + + // Try to use template + string template = GetTemplate("TestResultRow"); + if (string.IsNullOrEmpty(template)) + { + // Fall back to inline HTML if template not available + return $@" + + {testData["testName"]} + {outcome} + {testData["startTime"]} + {testData["duration"]} + {testData["appType"]} + {testData["pageType"]} + {appUrlLink} + {errorDisplay} + "; + } + else + { + // Use template with placeholders + return RenderTemplate(template, new Dictionary { + { "ROW_CLASS", rowClass }, + { "TEST_NAME", (string)testData["testName"] }, + { "OUTCOME", outcome }, + { "START_TIME", (string)testData["startTime"] }, + { "DURATION", (string)testData["duration"] }, + { "APP_TYPE", (string)testData["appType"] }, + { "PAGE_TYPE", (string)testData["pageType"] }, + { "APP_URL_LINK", appUrlLink }, + { "ERROR_DISPLAY", errorDisplay } + }); + } + } + + /// + /// Helper method to generate the HTML for video section + /// + /// Dictionary containing test data + /// HTML string for the video section or empty string if no video + private string GenerateVideoSectionHtml(Dictionary testData) + { + if (testData["hasVideo"] as bool? != true || testData["videoInfo"] == null) + { + return string.Empty; + } + + var videoInfo = testData["videoInfo"] as Dictionary; + if (videoInfo == null || !videoInfo.ContainsKey("path")) + { + return string.Empty; + } + + // Try to use template + string template = GetTemplate("VideoSection"); + if (!string.IsNullOrEmpty(template)) + { + // Use template with placeholders + return RenderTemplate(template, new Dictionary { + { "VIDEO_ID", videoInfo["id"] }, + { "VIDEO_PATH", videoInfo["path"] }, + { "TIMECODE_START", videoInfo["timecodeStart"] }, + { "TIMECODE_30S", videoInfo["timecode30s"] }, + { "TIMECODE_60S", videoInfo["timecode60s"] } + }); + } + + // Fall back to inline HTML if template not available + return $@"
+
+ + + + +
+
+ +
+ Time: 00:00:00 + +
+
+ Keyboard shortcuts: Space Play/Pause + -5s +5s + Home Start End End +
+
"; + } + + /// + /// Adds a specified number of seconds to a timecode and returns the new timecode + /// + /// Starting timecode in format HH:MM:SS + /// Number of seconds to add + /// New timecode string in format HH:MM:SS + private string AddTimeToTimecode(string timecode, int secondsToAdd) + { + // Default value if parsing fails + if (string.IsNullOrEmpty(timecode)) + return "00:00:00"; + + try + { + // Parse the timecode as TimeSpan + if (TimeSpan.TryParse(timecode, out TimeSpan currentTime)) + { + // Add the seconds and return formatted string + TimeSpan newTime = currentTime.Add(TimeSpan.FromSeconds(secondsToAdd)); + return newTime.ToString(@"hh\:mm\:ss"); + } + + return timecode; // Return original if parsing fails + } + catch + { + return timecode; // Return original on any error + } + } + + /// + /// Parses an app URL to extract app type, page type, and entity information + /// + /// URL to parse + /// Tuple containing app type, page type, and entity name + public (string appType, string pageType, string entityName) GetAppTypeAndEntityFromUrl(string url) + { + // Default values for invalid or unknown URLs + string appType = "Unknown"; + string pageType = "Unknown"; + string entityName = "Unknown"; + + if (string.IsNullOrEmpty(url)) + { + return (appType, pageType, entityName); + } + + try + { + // Try to parse the URL + Uri uri; + if (!Uri.TryCreate(url, UriKind.Absolute, out uri)) + { + return (appType, pageType, entityName); + } + // Determine app type based on hostname + if (uri.Host.Contains("make.powerapps.com") || uri.Host.Contains("make.preview.powerapps.com")) + { + appType = "Power Apps Portal"; + + // Check for specific query parameters to determine entity type + if (uri.Segments.Count() >= 4) + { + pageType = uri.Segments[1].Replace("/", ""); + entityName = uri.Segments[3].Replace("/", ""); + } + } + else + // Determine app type based on hostname + if (uri.Host.Contains("apps.powerapps.com") || uri.Host.Contains("play.apps.appsplatform.us") || uri.Host.Contains("apps.high.powerapps.us") || uri.Host.Contains("play.apps.appsplatform.us") || uri.Host.Contains("apps.powerapps.cn")) + { + appType = "Canvas App"; + } + else if (uri.Host.Contains("dynamics.com")) + { + appType = "Model-driven App"; + + var query = HttpUtility.ParseQueryString(uri.Query); + + // Check for specific query parameters to determine entity type + if (query["etn"] != null) + { + entityName = query["etn"]; + } + + if (query["pageType"] != null) + { + switch (query["pageType"]) + { + case "custom": + pageType = "Custom Page"; + entityName = query["name"] ?? "Unknown"; + break; + default: + pageType = query["pageType"]; + break; + } + + } + } + } + catch + { + // Silently return default values on any parsing error + } + + return (appType, pageType, entityName); + } + + /// + /// Generate HTML for the environment information section + /// + /// Environment information object + /// HTML for the environment information section + private string GenerateEnvironmentInfoHtml(EnvironmentInfo environment) + { + if (environment == null) + { + return string.Empty; + } + + return $@" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Machine Name{environment.MachineName}
Operating System{environment.OperatingSystem}
Report Generated{environment.ReportTimestamp}
PowerApps Test Engine{environment.TestEngineVersion}
Start Time{environment.StartTime}
End Time{environment.EndTime}
Total Duration{environment.TotalDuration}
Test Files{environment.TestFileCount}
"; + } + + /// + /// Generate HTML for the summary statistics section + /// + /// Template data with test statistics + /// HTML for the summary statistics section + private string GenerateSummaryTableHtml(TemplateData templateData) + { + if (templateData == null) + { + return string.Empty; + } + + return $@" +
+
+
+
{templateData.TotalTests}
+
Total Tests
+
+
+
+
+
{templateData.PassedTests}
+
Passed
+
+
+
+
+
{templateData.FailedTests}
+
Failed
+
+
+
+
+
{templateData.PassPercentage:F2}%
+
Pass Rate
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Start Time{templateData.StartTime}
End Time{templateData.EndTime}
Total Duration{templateData.TotalDuration}
Test Files{templateData.TestFileCount}
+
"; + } + + /// + /// Generate HTML for the health score calculation section + /// + /// Template data with test statistics + /// HTML for the health calculation section + private string GenerateHealthCalculationHtml(TemplateData templateData) + { + if (templateData == null) + { + return string.Empty; + } + + return $@" +
+

Health Score Calculation

+
+

Total Tests: {templateData.TotalTests}

+

Passed Tests: {templateData.PassedTests}

+

Failed Tests: {templateData.FailedTests}

+
+
+

Health Score = (Passed Tests / Total Tests) * 100

+

Health Score = ({templateData.PassedTests} / {templateData.TotalTests}) * 100

+

Health Score = {templateData.PassPercentage:F2}%

+
+
+ Final Health Score: {templateData.HealthScore}% +
+
"; + } + + /// + /// Creates a template file for test result row + /// + /// Name of the template to create + /// Content of the template + /// True if template was created successfully, false otherwise + private bool CreateTemplateFile(string templateName, string content) + { + try + { + string templateDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Templates"); + if (!_fileSystem.Exists(templateDirectory)) + { + _fileSystem.CreateDirectory(templateDirectory); + } + + string filePath = Path.Combine(templateDirectory, $"{templateName}.html"); + _fileSystem.WriteTextToFile(filePath, content, overwrite: true); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Error creating template file {templateName}: {ex.Message}"); + return false; + } + } + + /// + /// Creates templates for different sections of the report if they don't exist + /// + private void EnsureTemplatesExist() + { + // Template for test result row + string testResultRow = @" + {{TEST_NAME}} + {{OUTCOME}} + {{START_TIME}} + {{DURATION}} + {{APP_TYPE}} + {{PAGE_TYPE}} + {{APP_URL_LINK}} + {{ERROR_DISPLAY}} +"; + CreateTemplateFile("TestResultRow", testResultRow); + + // Template for video section + string videoSection = @"
+
+ + + + +
+
+ +
+
"; + CreateTemplateFile("VideoSection", videoSection); + + // Template for error display + string errorDisplay = @"
+
{{ERROR_SHORT}}
+ +
{{ERROR_FULL}}
+
"; + CreateTemplateFile("ErrorDisplay", errorDisplay); + + // Template for test card + string testCard = @"
+
{{GROUP_NAME}}
+
+
+ + + + + + + + + + + + + + + {{TEST_ROWS}} + +
Test NameStatusDurationStart TimeApp TypePage TypeApp URLLogs
+
+
+
"; + CreateTemplateFile("TestCard", testCard); + + // Template for test card row + string testCardRow = @" + {{TEST_NAME}} + + {{STATUS_ICON}} {{OUTCOME}} + + {{DURATION}} + {{START_TIME}} + {{APP_TYPE}} + {{PAGE_TYPE}} + {{APP_URL_LINK}} + + {{ERROR_DISPLAY}} + {{VIDEO_SECTION}} + +"; + CreateTemplateFile("TestCardRow", testCardRow); + } + + /// + /// Generates HTML for a test card with all test results for a group + /// + /// Name of the test group + /// List of test results in this group + /// HTML for the test card + private string GenerateTestCardHtml(string groupName, List> testResults) + { + if (string.IsNullOrEmpty(groupName) || testResults == null || testResults.Count == 0) + { + return string.Empty; + } + + // Generate rows for all tests in this group + var testRows = new StringBuilder(); + foreach (var testData in testResults) + { + string outcome = (string)testData["outcome"]; + string appUrl = (string)testData["appUrl"]; + string errorMessage = (string)testData["errorMessage"]; + bool hasError = (bool)testData["hasError"]; + + // Convert app URL to a hyperlink if available + string appUrlLink = string.IsNullOrEmpty(appUrl) ? + "" : + $@"Open App"; + + // Format error display + string errorDisplay = ""; + if (hasError) + { + // Use previously created error display method or template + if (errorMessage.Length > 100) + { + string errorTemplate = GetTemplate("ErrorDisplay"); + if (!string.IsNullOrEmpty(errorTemplate)) + { + errorDisplay = RenderTemplate(errorTemplate, new Dictionary { + { "ERROR_SHORT", errorMessage.Substring(0, 100) + "..." }, + { "ERROR_FULL", errorMessage } + }); + } + else + { + errorDisplay = $@"
+
{errorMessage.Substring(0, 100)}...
+ +
{errorMessage}
+
"; + } + } + else + { + errorDisplay = errorMessage; + } + } + + // Generate video section if test has video + string videoSection = GenerateVideoSectionHtml(testData); + + // Determine styling based on outcome + string rowClass = outcome == TestReporter.PassedResultOutcome ? "success" : + outcome == TestReporter.FailedResultOutcome ? "danger" : + "warning"; + string badgeClass = outcome == TestReporter.PassedResultOutcome ? "badge-passed" : + outcome == TestReporter.FailedResultOutcome ? "badge-failed" : + "badge-result"; + string statusIcon = outcome == TestReporter.PassedResultOutcome ? + "" : + outcome == TestReporter.FailedResultOutcome ? + "" : + ""; + + // Add row using template + string rowTemplate = GetTemplate("TestCardRow"); + if (!string.IsNullOrEmpty(rowTemplate)) + { + string renderedRow = RenderTemplate(rowTemplate, new Dictionary + { + { "OUTCOME", outcome }, + { "TEST_NAME", (string)testData["testName"] }, + { "BADGE_CLASS", badgeClass }, + { "STATUS_ICON", statusIcon }, + { "DURATION", (string)testData["duration"] }, + { "START_TIME", (string)testData["startTime"] }, + { "APP_TYPE", (string)testData["appType"] }, + { "PAGE_TYPE", (string)testData["pageType"] }, + { "APP_URL_LINK", appUrlLink }, + { "ERROR_DISPLAY", errorDisplay }, + { "VIDEO_SECTION", videoSection } + }); + testRows.AppendLine(renderedRow); + } + else + { + // Fallback to inline HTML + testRows.AppendLine($@" + + {testData["testName"]} + + {statusIcon} {outcome} + + {testData["duration"]} + {testData["startTime"]} + {testData["appType"]} + {testData["pageType"]} + {appUrlLink} + + {errorDisplay} + {videoSection} + + "); + } + } + + // Generate the card using template + string cardTemplate = GetTemplate("TestCard"); + if (!string.IsNullOrEmpty(cardTemplate)) + { + return RenderTemplate(cardTemplate, new Dictionary + { + { "GROUP_NAME", groupName }, + { "TEST_ROWS", testRows.ToString() } + }); + } + + // Fallback to inline HTML + return $@" +
+
{groupName}
+
+
+ + + + + + + + + + + + + + + {testRows} + +
Test NameStatusDurationStart TimeApp TypePage TypeApp URLLogs
+
+
+
"; + } + + /// + /// Collects coverage information grouped by page type + /// + /// The list of test runs + /// Dictionary mapping page types to their coverage statistics + private Dictionary CollectCoverageData(List testRuns) + { + // Group the tests by page type + var groupedTests = GroupTestsByRun(testRuns, true); + var pageTypeGroups = new Dictionary(); + + // Process each grouped test to collect coverage statistics + foreach (var group in groupedTests) + { + foreach (var (testResult, testRun) in group.Value) + { + // Create test data to extract necessary information + var testData = CreateTestResultData(testResult, testRun, group.Key); + + string pageType = (string)testData["pageType"]; + string entityType = (string)testData["entityType"]; + string outcome = (string)testData["outcome"]; + + // If entityType is Unknown but pageType is known, use pageType as entityType + if (entityType == "Unknown" && pageType != "Unknown") + { + entityType = pageType; + } + + // Use page type as the key, or entity name as fallback + string groupKey = pageType != "Unknown" ? pageType : (string)testData["entityName"]; + + if (!pageTypeGroups.ContainsKey(groupKey)) + { + pageTypeGroups[groupKey] = (entityType, 0, 0); + } + + var stats = pageTypeGroups[groupKey]; + if (outcome == TestReporter.PassedResultOutcome) + { + stats.passes++; + } + else if (outcome == TestReporter.FailedResultOutcome) + { + stats.failures++; + } + pageTypeGroups[groupKey] = stats; + } + } + + return pageTypeGroups; + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/Reporting/TestRunSummaryCommand.cs b/src/Microsoft.PowerApps.TestEngine/Reporting/TestRunSummaryCommand.cs new file mode 100644 index 00000000..5831e5d4 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/Reporting/TestRunSummaryCommand.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.ComponentModel.Composition; +using System.IO; +using Microsoft.PowerApps.TestEngine.System; + +namespace Microsoft.PowerApps.TestEngine.Reporting +{ + /// + /// Command to generate a test run summary report + /// + [Export] + public class TestRunSummaryCommand + { + private readonly ITestRunSummary _testRunSummary; + private readonly IFileSystem _fileSystem; + + /// + /// Initializes a new instance of the class. + /// + /// The test run summary service + /// The file system service + [ImportingConstructor] + public TestRunSummaryCommand(ITestRunSummary testRunSummary, IFileSystem fileSystem) + { + _testRunSummary = testRunSummary; + _fileSystem = fileSystem; + } /// + /// Generates a test run summary report from a directory containing .trx files + /// + /// Directory containing the .trx files + /// Optional path where the summary report will be saved. If not specified, the report will be saved in the results directory + /// Optional name to filter test runs by + /// Path to the generated summary report + public string GenerateSummaryReport(string resultsDirectory, string outputPath = null, string runName = null) + { + if (string.IsNullOrEmpty(resultsDirectory)) + { + throw new ArgumentException("Results directory cannot be null or empty", nameof(resultsDirectory)); + } + + if (!_fileSystem.Exists(resultsDirectory)) + { + throw new ArgumentException($"Results directory does not exist: {resultsDirectory}", nameof(resultsDirectory)); + } + + // If output path is not specified, generate one in the results directory + if (string.IsNullOrEmpty(outputPath)) + { + outputPath = Path.Combine(resultsDirectory, $"TestRunSummary_{DateTime.Now:yyyyMMdd_HHmmss}.html"); + } + + return _testRunSummary.GenerateSummaryReport(resultsDirectory, outputPath, runName); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs b/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs index cae8f3c4..80b93f60 100644 --- a/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs +++ b/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs @@ -40,6 +40,15 @@ public class SingleTestRunner : ISingleTestRunner private Exception TestException { get; set; } private int RunCount { get; set; } = 0; + public Func GetFiles = (directoryName) => + { + if (string.IsNullOrEmpty(directoryName)) + { + return null; + } + return Directory.Exists(directoryName) ? Directory.GetFiles(directoryName) : null; + }; + public SingleTestRunner(ITestReporter testReporter, IPowerFxEngine powerFxEngine, ITestInfraFunctions testInfraFunctions, @@ -316,7 +325,7 @@ public async Task RunTestAsync(string testRunId, string testRunDirectory, TestSu } var additionalFiles = new List(); - var files = _fileSystem.GetFiles(testCaseResultDirectory); + var files = GetFiles(testCaseResultDirectory); if (files != null) { foreach (var file in files) diff --git a/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs b/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs index 4d7bb955..ebe18f10 100644 --- a/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs +++ b/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs @@ -579,5 +579,23 @@ public void DeleteDirectory(string directoryName) } throw new InvalidOperationException(string.Format("Path invalid or write to path: '{0}' not permitted.", directoryName)); } + + public long GetFileSize(string filePath) + { + filePath = Path.GetFullPath(filePath); + if (CanAccessFilePath(filePath)) + { + var fileInfo = new FileInfo(filePath); + if (fileInfo.Exists) + { + return fileInfo.Length; + } + return 0; + } + else + { + throw new InvalidOperationException($"Invalid file path '{filePath}'."); + } + } } } diff --git a/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs b/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs index 766ac1ec..3478d6f8 100644 --- a/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs +++ b/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs @@ -116,5 +116,12 @@ public interface IFileSystem /// /// The file to delete void DeleteDirectory(string directoryName); + + /// + /// Gets the size of a file in bytes + /// + /// Path to the file + /// Size of the file in bytes + public long GetFileSize(string filePath); } } diff --git a/src/Microsoft.PowerApps.TestEngine/TestEngine.cs b/src/Microsoft.PowerApps.TestEngine/TestEngine.cs index a0b29933..214b853c 100644 --- a/src/Microsoft.PowerApps.TestEngine/TestEngine.cs +++ b/src/Microsoft.PowerApps.TestEngine/TestEngine.cs @@ -55,10 +55,12 @@ public TestEngine(ITestState state, /// Optional query parameters that would be passed to the Player URL for optional features or parameters. /// The full path where the test results are saved. /// Throws ArgumentNullException if any of testConfigFile, environmentId, tenantId or domain are missing or empty. - public async Task RunTestAsync(FileInfo testConfigFile, string environmentId, Guid? tenantId, DirectoryInfo outputDirectory, string domain, string queryParams) + public async Task RunTestAsync(FileInfo testConfigFile, string environmentId, Guid? tenantId, DirectoryInfo outputDirectory, string domain, string queryParams, string runName = null) { // Set up test reporting - var testRunId = _testReporter.CreateTestRun("Power Fx Test Runner", "User"); // TODO: determine if there are more meaningful values we can put here + var testRunName = !String.IsNullOrEmpty(runName) ? runName : "Power Fx Test Runner"; + // TODO: determine if there are more meaningful values we can put here + var testRunId = _testReporter.CreateTestRun(testRunName, "User"); _testReporter.StartTestRun(testRunId); Logger = _loggerFactory.CreateLogger(testRunId); diff --git a/src/Microsoft.PowerApps.TestEngine/TestEngineEventHandler.cs b/src/Microsoft.PowerApps.TestEngine/TestEngineEventHandler.cs index f9f2392c..3bae2293 100644 --- a/src/Microsoft.PowerApps.TestEngine/TestEngineEventHandler.cs +++ b/src/Microsoft.PowerApps.TestEngine/TestEngineEventHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +using System; +using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.PowerApps.TestEngine.Config; using Microsoft.PowerApps.TestEngine.System; @@ -37,12 +39,12 @@ public void SetAndInitializeCounters(int numCases) _casesTotal = numCases; _casesPassed = 0; } - public void EncounteredException(Exception ex) { // Print assertion if exception is the result of an Assert failure if (ex is AssertionFailureException) { + Debug.WriteLine($" Assertion failed: {ex.InnerException.InnerException.Message}"); Console.WriteLine($" Assertion failed: {ex.InnerException.InnerException.Message}"); } else if (ex is UserInputException) @@ -50,68 +52,86 @@ public void EncounteredException(Exception ex) switch (ex.Message) { case nameof(UserInputException.ErrorMapping.UserInputExceptionInvalidTestSettings): + Debug.WriteLine(UserInputExceptionInvalidTestSettingsMessage); Console.WriteLine(UserInputExceptionInvalidTestSettingsMessage); break; case nameof(UserInputException.ErrorMapping.UserInputExceptionInvalidFilePath): + Debug.WriteLine(UserInputExceptionInvalidFilePathMessage); Console.WriteLine(UserInputExceptionInvalidFilePathMessage); break; case nameof(UserInputException.ErrorMapping.UserInputExceptionLoginCredential): + Debug.WriteLine(UserInputExceptionLoginCredentialMessage); Console.WriteLine(UserInputExceptionLoginCredentialMessage); break; case nameof(UserInputException.ErrorMapping.UserInputExceptionTestConfig): + Debug.WriteLine(UserInputExceptionTestConfigMessage); Console.WriteLine(UserInputExceptionTestConfigMessage); break; case nameof(UserInputException.ErrorMapping.UserInputExceptionYAMLFormat): + Debug.WriteLine(UserInputExceptionYAMLFormatMessage); Console.WriteLine(UserInputExceptionYAMLFormatMessage); break; case nameof(UserInputException.ErrorMapping.UserInputExceptionInvalidOutputPath): + Debug.WriteLine(UserInputExceptionInvalidOutputPathMessage); Console.WriteLine(UserInputExceptionInvalidOutputPathMessage); break; default: + Debug.WriteLine($" {ex.Message}"); Console.WriteLine($" {ex.Message}"); break; } } else if (ex is UserAppException) { + Debug.WriteLine(UserAppExceptionMessage); Console.WriteLine(UserAppExceptionMessage); } else { + Debug.WriteLine($" {ex.Message}"); Console.WriteLine($" {ex.Message}"); } } - public void SuiteBegin(string suiteName, string directory, string browserName, string url) { + Debug.WriteLine($"Running test suite: {suiteName}"); + Debug.WriteLine($" Test results will be stored in: {directory}"); + Debug.WriteLine($" Browser: {browserName}"); + Debug.WriteLine($" App URL: {url}"); + Console.WriteLine($"Running test suite: {suiteName}"); Console.WriteLine($" Test results will be stored in: {directory}"); Console.WriteLine($" Browser: {browserName}"); Console.WriteLine($" App URL: {url}"); } - public void SuiteEnd() { + Debug.WriteLine("\nTest suite summary"); + Debug.WriteLine($"Total cases: {_casesTotal}"); + Debug.WriteLine($"Cases passed: {_casesPassed}"); + Debug.WriteLine($"Cases failed: {(_casesTotal - _casesPassed)}"); + Console.WriteLine("\nTest suite summary"); Console.WriteLine($"Total cases: {_casesTotal}"); Console.WriteLine($"Cases passed: {_casesPassed}"); Console.WriteLine($"Cases failed: {(_casesTotal - _casesPassed)}"); } - public void TestCaseBegin(string name) { + Debug.WriteLine($"Test case: {name}"); Console.WriteLine($"Test case: {name}"); } - public void TestCaseEnd(bool result) { if (result) { _casesPassed++; + Debug.WriteLine(" Result: Passed"); Console.WriteLine(" Result: Passed"); } else { + Debug.WriteLine(" Result: Failed"); Console.WriteLine(" Result: Failed"); } } diff --git a/src/PowerAppsTestEngineWrapper/InputOptions.cs b/src/PowerAppsTestEngineWrapper/InputOptions.cs index 077bf103..e6c09a38 100644 --- a/src/PowerAppsTestEngineWrapper/InputOptions.cs +++ b/src/PowerAppsTestEngineWrapper/InputOptions.cs @@ -5,6 +5,8 @@ namespace PowerAppsTestEngineWrapper { public class InputOptions { + public string? OutputFile { get; set; } + public string? RunName { get; set; } public string? EnvironmentId { get; set; } public string? TenantId { get; set; } public string? TestPlanFile { get; set; } diff --git a/src/PowerAppsTestEngineWrapper/Program.cs b/src/PowerAppsTestEngineWrapper/Program.cs index 620bbf52..a5399786 100644 --- a/src/PowerAppsTestEngineWrapper/Program.cs +++ b/src/PowerAppsTestEngineWrapper/Program.cs @@ -37,7 +37,9 @@ public static async Task Main(string[] args) { "-a", "UserAuthType"}, { "-w", "Wait" }, { "-r", "Record" }, - { "-c", "UseStaticContext" } + { "-c", "UseStaticContext" }, + { "--run-name", "RunName" }, + { "--output-file", "OutputFile" } }; var inputOptions = new ConfigurationBuilder() @@ -55,6 +57,13 @@ public static async Task Main(string[] args) } else { + if (!string.IsNullOrEmpty(inputOptions.OutputFile) && !string.IsNullOrEmpty(inputOptions.RunName)) + { + var system = new FileSystem(); + var summary = new TestRunSummary(system); + summary.GenerateSummaryReport(Path.Combine(system.GetDefaultRootTestEngine(), "TestOutput"), inputOptions.OutputFile, inputOptions.RunName); + return; + } // If an empty field is put in via commandline, it won't register as empty // It will cannabalize the next flag, and then ruin the next flag's operation @@ -331,7 +340,7 @@ public static async Task Main(string[] args) } //setting defaults for optional parameters outside RunTestAsync - var testResult = await testEngine.RunTestAsync(testPlanFile, environmentId, tenantId, outputDirectory, domain, queryParams); + var testResult = await testEngine.RunTestAsync(testPlanFile, environmentId, tenantId, outputDirectory, domain, queryParams, inputOptions.RunName); if (testResult != "InvalidOutputDirectory") { Console.WriteLine($"Test results can be found here: {testResult}");