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
-
-
- Name
- Type
- Pass Count
- Fail Count
-
-"@
-
- $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 += "$($value.EntityName) $($value.EntityType) $($value.PassCount) $($value.FailCount) "
+
+ 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 += "
"
- $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}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Summary
+
+
+
+
+ Tests
+
+
+
+
+ Coverage
+
+
+
+
+ Results
+
+
+
+
+
+
+
+
+
+
Tests Passed
+
{{PASS_COUNT}}
+
+
+
Tests Failed
+
{{FAIL_COUNT}}
+
+
+
Health Score
+
{{HEALTH_PERCENT}}%
+
+
+
Total Tests
+
{{TOTAL_COUNT}}
+
+
+
+
+
+
+
+
+
+ {{ENVIRONMENT_INFO}}
+
+
+
+
+
+
+ {{SUMMARY_TABLE}}
+
+
+
+
+
+
+
+ {{HEALTH_CALCULATION}}
+
+
+
+
+
+
+
+
+
+ Status:
+
+ All
+ Passed
+ Failed
+
+
+
+ Page Type:
+
+ All
+
+
+
+ Search:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Page:
+
+ All
+
+
+
+ Status:
+
+ All
+ Passed
+ Failed
+
+
+
+
+
+
+ {{TEST_RESULTS_CARDS}}
+
+
+
+
+
+
+
+ {{HEALTH_CALCULATION}}
+
+
+
+ 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)}...
+
Show More
+
+
" :
+ 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)}...
+
Show More
+
+
";
+ }
+ 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 $@"
+
+
+ Play Video
+
+
+ Start
+
+
+ +30s
+
+
+ +1m
+
+
+
+
+
+ Your browser does not support the video tag.
+
+
+ Time: 00:00:00
+
+ Copy Timecode
+
+
+
+ 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 = @"
+
+
+ Play Video
+
+
+ Start
+
+
+ +30s
+
+
+ +1m
+
+
+
+
+
+ Your browser does not support the video tag.
+
+
+
";
+ CreateTemplateFile("VideoSection", videoSection);
+
+ // Template for error display
+ string errorDisplay = @"
+
{{ERROR_SHORT}}
+
Show More
+
+
";
+ CreateTemplateFile("ErrorDisplay", errorDisplay);
+
+ // Template for test card
+ string testCard = @"
+
{{GROUP_NAME}}
+
+
+
+
+
+ Test Name
+ Status
+ Duration
+ Start Time
+ App Type
+ Page Type
+ App URL
+ Logs
+
+
+
+ {{TEST_ROWS}}
+
+
+
+
+
";
+ 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)}...
+
Show More
+
+
";
+ }
+ }
+ 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}
+
+
+
+
+
+ Test Name
+ Status
+ Duration
+ Start Time
+ App Type
+ Page Type
+ App URL
+ Logs
+
+
+
+ {testRows}
+
+
+
+
+
";
+ }
+
+ ///
+ /// 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}");