diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml
index dc13ca1890..5929ff0dc1 100644
--- a/.github/workflows/CI.yaml
+++ b/.github/workflows/CI.yaml
@@ -40,6 +40,10 @@ jobs:
run: |
. (Join-Path "." "Tests/runtests.ps1") -Path "Tests"
+ - name: Test Code Coverage Modules (${{ matrix.psVersion }})
+ run: |
+ . (Join-Path "." "Tests/runtests.ps1") -Path "Tests/CodeCoverage"
+
- name: Test AL-Go Workflows (${{ matrix.psVersion }})
if: github.repository_owner == 'microsoft'
run: |
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c226d1a8fc..c20ab4871b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,29 +1,30 @@
-# See https://pre-commit.com for more information
-# See https://pre-commit.com/hooks.html for more hooks
-
-repos:
- - repo: https://github.com/executablebooks/mdformat
- rev: 0.7.21
- hooks:
- - id: mdformat
- args: [--end-of-line=keep]
-
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v5.0.0
- hooks:
- - id: check-added-large-files
- - id: check-case-conflict
- - id: check-json
- - id: check-xml
- - id: check-yaml
- - id: check-merge-conflict
- - id: detect-private-key
- - id: end-of-file-fixer
- - id: trailing-whitespace
- - id: mixed-line-ending
- - id: sort-simple-yaml
-
- - repo: https://github.com/gitleaks/gitleaks
- rev: v8.16.3
- hooks:
- - id: gitleaks
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+
+repos:
+ - repo: https://github.com/executablebooks/mdformat
+ rev: 0.7.21
+ hooks:
+ - id: mdformat
+ args: [--end-of-line=keep]
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: check-added-large-files
+ - id: check-case-conflict
+ - id: check-json
+ - id: check-xml
+ exclude: 'Tests/CodeCoverage/TestData/CoberturaFiles/cobertura-malformed\.xml$'
+ - id: check-yaml
+ - id: check-merge-conflict
+ - id: detect-private-key
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - id: mixed-line-ending
+ - id: sort-simple-yaml
+
+ - repo: https://github.com/gitleaks/gitleaks
+ rev: v8.16.3
+ hooks:
+ - id: gitleaks
diff --git a/Actions/.Modules/ReadSettings.psm1 b/Actions/.Modules/ReadSettings.psm1
index 46a07e4958..861dbecf1c 100644
--- a/Actions/.Modules/ReadSettings.psm1
+++ b/Actions/.Modules/ReadSettings.psm1
@@ -173,6 +173,12 @@ function GetDefaultSettings
"doNotRunTests" = $false
"doNotRunBcptTests" = $false
"doNotRunPageScriptingTests" = $false
+ "enableCodeCoverage" = $false
+ "codeCoverageSetup" = [ordered]@{
+ "trackingType" = "PerRun"
+ "produceCodeCoverageMap" = "PerCodeunit"
+ "excludeFilesPattern" = @()
+ }
"doNotPublishApps" = $false
"doNotSignApps" = $false
"configPackages" = @()
diff --git a/Actions/.Modules/TestRunner/ALTestRunner.psm1 b/Actions/.Modules/TestRunner/ALTestRunner.psm1
new file mode 100644
index 0000000000..351c2ad9ff
--- /dev/null
+++ b/Actions/.Modules/TestRunner/ALTestRunner.psm1
@@ -0,0 +1,266 @@
+function Run-AlTests
+(
+ [string] $TestSuite = $script:DefaultTestSuite,
+ [string] $TestCodeunitsRange = "",
+ [string] $TestProcedureRange = "",
+ [string] $ExtensionId = "",
+ [ValidateSet('None','Disabled','Codeunit','Function')]
+ [string] $RequiredTestIsolation = "None",
+ [ValidateSet('','None','UnitTest','IntegrationTest','Uncategorized','AITest')]
+ [string] $TestType = "",
+ [ValidateSet("Disabled", "Codeunit")]
+ [string] $TestIsolation = "Codeunit",
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AutorizationType = $script:DefaultAuthorizationType,
+ [string] $TestPage = $global:DefaultTestPage,
+ [switch] $DisableSSLVerification,
+ [Parameter(Mandatory=$true)]
+ [string] $ServiceUrl,
+ [Parameter(Mandatory=$false)]
+ [pscredential] $Credential,
+ [array] $DisabledTests = @(),
+ [bool] $Detailed = $true,
+ [ValidateSet('no','error','warning')]
+ [string] $AzureDevOps = 'no',
+ [bool] $SaveResultFile = $true,
+ [string] $ResultsFilePath = "$PSScriptRoot\TestResults.xml",
+ [ValidateSet('XUnit','JUnit')]
+ [string] $ResultsFormat = 'JUnit',
+ [string] $AppName = '',
+ [ValidateSet('Disabled', 'PerRun', 'PerCodeunit', 'PerTest')]
+ [string] $CodeCoverageTrackingType = 'Disabled',
+ [ValidateSet('Disabled','PerCodeunit','PerTest')]
+ [string] $ProduceCodeCoverageMap = 'Disabled',
+ [string] $CodeCoverageOutputPath = "$PSScriptRoot\CodeCoverage",
+ [string] $CodeCoverageExporterId = $script:DefaultCodeCoverageExporter,
+ [switch] $CodeCoverageTrackAllSessions,
+ [string] $CodeCoverageFilePrefix = ("TestCoverageMap_" + (get-date -Format 'yyyyMMdd')),
+ [bool] $StabilityRun
+)
+{
+ $testRunArguments = @{
+ TestSuite = $TestSuite
+ TestCodeunitsRange = $TestCodeunitsRange
+ TestProcedureRange = $TestProcedureRange
+ ExtensionId = $ExtensionId
+ RequiredTestIsolation = $RequiredTestIsolation
+ TestType = $TestType
+ TestRunnerId = (Get-TestRunnerId -TestIsolation $TestIsolation)
+ CodeCoverageTrackingType = $CodeCoverageTrackingType
+ ProduceCodeCoverageMap = $ProduceCodeCoverageMap
+ CodeCoverageOutputPath = $CodeCoverageOutputPath
+ CodeCoverageFilePrefix = $CodeCoverageFilePrefix
+ CodeCoverageExporterId = $CodeCoverageExporterId
+ AutorizationType = $AutorizationType
+ TestPage = $TestPage
+ DisableSSLVerification = $DisableSSLVerification
+ ServiceUrl = $ServiceUrl
+ Credential = $Credential
+ DisabledTests = $DisabledTests
+ Detailed = $Detailed
+ StabilityRun = $StabilityRun
+ }
+
+ [array]$testRunResult = Run-AlTestsInternal @testRunArguments
+
+ if($SaveResultFile -and $testRunResult)
+ {
+ # Import the formatter module
+ $formatterPath = Join-Path $PSScriptRoot "TestResultFormatter.psm1"
+ Import-Module $formatterPath -Force
+
+ Save-TestResults -TestRunResultObject $testRunResult -ResultsFilePath $ResultsFilePath -Format $ResultsFormat -ExtensionId $ExtensionId -AppName $AppName
+ }
+ elseif ($SaveResultFile -and -not $testRunResult) {
+ Write-Host "Warning: No test results to save - tests may not have run"
+ }
+
+ if($AzureDevOps -ne 'no' -and $testRunResult)
+ {
+ Report-ErrorsInAzureDevOps -AzureDevOps $AzureDevOps -TestRunResultObject $testRunResult
+ }
+}
+
+function Invoke-ALTestResultVerification
+(
+ [string] $TestResultsFolder = $(throw "Missing argument TestResultsFolder"),
+ [switch] $IgnoreErrorIfNoTestsExecuted
+)
+{
+ $failedTestList = Get-FailedTestsFromXMLFiles -TestResultsFolder $TestResultsFolder
+
+ if($failedTestList.Count -gt 0)
+ {
+ $testsExecuted = $true;
+ Write-Log "Failed tests:"
+ $testsFailed = ""
+ foreach($failedTest in $failedTestList)
+ {
+ $testsFailed += "Name: " + $failedTest.name + [environment]::NewLine
+ $testsFailed += "Method: " + $failedTest.method + [environment]::NewLine
+ $testsFailed += "Time: " + $failedTest.time + [environment]::NewLine
+ $testsFailed += "Message: " + [environment]::NewLine + $failedTest.message + [environment]::NewLine
+ $testsFailed += "StackTrace: "+ [environment]::NewLine + $failedTest.stackTrace + [environment]::NewLine + [environment]::NewLine
+ }
+
+ Write-Log $testsFailed
+ throw "Test execution failed due to the failing tests, see the list of the failed tests above."
+ }
+
+ if(-not $testsExecuted)
+ {
+ [array]$testResultFiles = Get-ChildItem -Path $TestResultsFolder -Filter "*.xml" | Foreach { "$($_.FullName)" }
+
+ foreach($resultFile in $testResultFiles)
+ {
+ [xml]$xmlDoc = Get-Content "$resultFile"
+ [array]$otherTests = $xmlDoc.assemblies.assembly.collection.ChildNodes | Where-Object {$_.result -ne 'Fail'}
+ if($otherTests.Length -gt 0)
+ {
+ return;
+ }
+
+ }
+
+ if (-not $IgnoreErrorIfNoTestsExecuted) {
+ throw "No test codeunits were executed"
+ }
+ }
+}
+
+function Get-FailedTestsFromXMLFiles
+(
+ [string] $TestResultsFolder = $(throw "Missing argument TestResultsFolder")
+)
+{
+ $failedTestList = New-Object System.Collections.ArrayList
+ $testsExecuted = $false
+ [array]$testResultFiles = Get-ChildItem -Path $TestResultsFolder -Filter "*.xml" | Foreach { "$($_.FullName)" }
+
+ if($testResultFiles.Length -eq 0)
+ {
+ throw "No test results were found"
+ }
+
+ foreach($resultFile in $testResultFiles)
+ {
+ [xml]$xmlDoc = Get-Content "$resultFile"
+ [array]$failedTests = $xmlDoc.assemblies.assembly.collection.ChildNodes | Where-Object {$_.result -eq 'Fail'}
+ if($failedTests)
+ {
+ $testsExecuted = $true
+ foreach($failedTest in $failedTests)
+ {
+ $failedTestObject = @{
+ codeunitID = [int]($failedTest.ParentNode.ParentNode.'x-code-unit');
+ codeunitName = $failedTest.name;
+ method = $failedTest.method;
+ time = $failedTest.time;
+ message = $failedTest.failure.message;
+ stackTrace = $failedTest.failure.'stack-trace';
+ }
+
+ $failedTestList.Add($failedTestObject) > $null
+ }
+ }
+ }
+
+ return $failedTestList
+}
+
+function Write-DisabledTestsJson
+(
+ $FailedTests,
+ [string] $OutputFolder = $(throw "Missing argument OutputFolder"),
+ [string] $FileName = 'DisabledTests.json'
+)
+{
+ $testsToDisable = New-Object -TypeName "System.Collections.ArrayList"
+ foreach($failedTest in $failedTests)
+ {
+ $test = @{
+ codeunitID = $failedTest.codeunitID;
+ codeunitName = $failedTest.name;
+ method = $failedTest.method;
+ }
+
+ $testsToDisable.Add($test)
+ }
+
+ $outputFile = Join-Path $OutputFolder $FileName
+ if(-not (Test-Path $outputFolder))
+ {
+ New-Item -Path $outputFolder -ItemType Directory
+ }
+
+ Add-Content -Value (ConvertTo-Json $testsToDisable) -Path $outputFile
+}
+
+function Report-ErrorsInAzureDevOps
+(
+ [ValidateSet('no','error','warning')]
+ [string] $AzureDevOps = 'no',
+ $TestRunResultObject
+)
+{
+ if ($AzureDevOps -eq 'no')
+ {
+ return
+ }
+
+ $failedCodeunits = $TestRunResultObject | Where-Object { $_.result -eq $script:FailureTestResultType }
+ $failedTests = $failedCodeunits.testResults | Where-Object { $_.result -eq $script:FailureTestResultType }
+
+ foreach($failedTest in $failedTests)
+ {
+ $methodName = $failedTest.method;
+ $errorMessage = $failedTests.message
+ Write-Host "##vso[task.logissue type=$AzureDevOps;sourcepath=$methodName;]$errorMessage"
+ }
+}
+
+function Get-DisabledAlTests
+(
+ [string] $DisabledTestsPath
+)
+{
+ $DisabledTests = @()
+ if(Test-Path $DisabledTestsPath)
+ {
+ $DisabledTests = Get-Content $DisabledTestsPath | ConvertFrom-Json
+ }
+
+ return $DisabledTests
+}
+
+function Get-TestRunnerId
+(
+ [ValidateSet("Disabled", "Codeunit")]
+ [string] $TestIsolation = "Codeunit"
+)
+{
+ switch($TestIsolation)
+ {
+ "Codeunit"
+ {
+ return Get-CodeunitTestIsolationTestRunnerId
+ }
+ "Disabled"
+ {
+ return Get-DisabledTestIsolationTestRunnerId
+ }
+ }
+}
+
+function Get-DisabledTestIsolationTestRunnerId()
+{
+ return $global:TestRunnerIsolationDisabled
+}
+
+function Get-CodeunitTestIsolationTestRunnerId()
+{
+ return $global:TestRunnerIsolationCodeunit
+}
+
+. "$PSScriptRoot\Internal\Constants.ps1"
+Import-Module "$PSScriptRoot\Internal\ALTestRunnerInternal.psm1"
diff --git a/Actions/.Modules/TestRunner/CoverageProcessor/ALSourceParser.psm1 b/Actions/.Modules/TestRunner/CoverageProcessor/ALSourceParser.psm1
new file mode 100644
index 0000000000..d37063b34a
--- /dev/null
+++ b/Actions/.Modules/TestRunner/CoverageProcessor/ALSourceParser.psm1
@@ -0,0 +1,541 @@
+<#
+.SYNOPSIS
+ Parses AL source files to extract metadata for code coverage mapping
+.DESCRIPTION
+ Extracts object definitions, procedure boundaries, and line mappings from .al source files
+ to enable accurate Cobertura output with proper filenames and method names.
+#>
+
+<#
+.SYNOPSIS
+ Parses an app.json file to extract app metadata
+.PARAMETER AppJsonPath
+ Path to the app.json file
+.OUTPUTS
+ Object with Id, Name, Publisher, Version properties
+#>
+function Read-AppJson {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$AppJsonPath
+ )
+
+ if (-not (Test-Path $AppJsonPath)) {
+ Write-Warning "app.json not found at: $AppJsonPath"
+ return $null
+ }
+
+ $appJson = Get-Content -Path $AppJsonPath -Raw | ConvertFrom-Json
+
+ return [PSCustomObject]@{
+ Id = $appJson.id
+ Name = $appJson.name
+ Publisher = $appJson.publisher
+ Version = $appJson.version
+ }
+}
+
+<#
+.SYNOPSIS
+ Scans a directory for .al files and extracts object definitions
+.PARAMETER SourcePath
+ Root path used for calculating relative file paths in coverage output
+.PARAMETER AppSourcePaths
+ Optional array of specific directories to scan for .al files.
+ When provided, only these directories are scanned instead of SourcePath.
+ Relative paths in output are still calculated from SourcePath.
+.PARAMETER ExcludePatterns
+ Optional array of glob patterns to exclude files from coverage.
+ Patterns are matched against both the file name and relative path using -like.
+ Example: @('*.PermissionSet.al', '*.PermissionSetExtension.al')
+.OUTPUTS
+ Hashtable mapping "ObjectType.ObjectId" to file and metadata info
+#>
+function Get-ALObjectMap {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$SourcePath,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$AppSourcePaths = @(),
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$ExcludePatterns = @()
+ )
+
+ $objectMap = @{}
+
+ if (-not (Test-Path $SourcePath)) {
+ Write-Warning "Source path not found: $SourcePath"
+ return $objectMap
+ }
+
+ # Normalize source path to resolve .\, ..\, and ensure consistent format
+ $normalizedSourcePath = [System.IO.Path]::GetFullPath($SourcePath).TrimEnd('\', '/')
+
+ # Scan specific app source paths if provided, otherwise scan entire SourcePath
+ if ($AppSourcePaths.Count -gt 0) {
+ $alFiles = @()
+ foreach ($appPath in $AppSourcePaths) {
+ if (Test-Path $appPath) {
+ $alFiles += @(Get-ChildItem -Path $appPath -Filter "*.al" -Recurse -File)
+ } else {
+ Write-Warning "App source path not found: $appPath"
+ }
+ }
+ Write-Host "Scanning $($AppSourcePaths.Count) app source path(s) for .al files ($($alFiles.Count) files found)"
+ } else {
+ $alFiles = Get-ChildItem -Path $SourcePath -Filter "*.al" -Recurse -File
+ }
+
+ # Apply exclude patterns to filter out unwanted files
+ if ($ExcludePatterns.Count -gt 0 -and $alFiles.Count -gt 0) {
+ $beforeCount = $alFiles.Count
+ $alFiles = @($alFiles | Where-Object {
+ $relativePath = $_.FullName.Substring($normalizedSourcePath.Length + 1)
+ $excluded = $false
+ foreach ($pattern in $ExcludePatterns) {
+ if ($relativePath -like $pattern -or $_.Name -like $pattern) {
+ $excluded = $true
+ break
+ }
+ }
+ -not $excluded
+ })
+ $excludedCount = $beforeCount - $alFiles.Count
+ if ($excludedCount -gt 0) {
+ Write-Host "Excluded $excludedCount file(s) matching pattern(s): $($ExcludePatterns -join ', ')"
+ }
+ }
+
+ foreach ($file in $alFiles) {
+ $content = Get-Content -Path $file.FullName -Raw -ErrorAction SilentlyContinue
+ if (-not $content) { continue }
+
+ # Parse object definition: type ID name
+ # Examples:
+ # codeunit 50100 "My Codeunit"
+ # table 50100 "My Table"
+ # pageextension 50100 "My Page Ext" extends "Customer Card"
+
+ $objectPattern = '(?im)^\s*(codeunit|table|page|report|query|xmlport|enum|interface|permissionset|tableextension|pageextension|reportextension|enumextension|permissionsetextension|profile|controladdin)\s+(\d+)\s+("([^"]+)"|([^\s]+))'
+
+ $match = [regex]::Match($content, $objectPattern)
+
+ if ($match.Success) {
+ $objectType = $match.Groups[1].Value
+ $objectId = [int]$match.Groups[2].Value
+ $objectName = if ($match.Groups[4].Value) { $match.Groups[4].Value } else { $match.Groups[5].Value }
+
+ # Normalize object type to match BC internal naming
+ $normalizedType = Get-NormalizedObjectType $objectType
+ $key = "$normalizedType.$objectId"
+
+ # Parse procedures in this file
+ $procedures = Get-ALProcedures -Content $content
+
+ # Get executable line information
+ $executableInfo = Get-ALExecutableLines -Content $content
+
+ # Calculate relative path (normalizedSourcePath is already normalized at function start)
+ $relativePath = $file.FullName.Substring($normalizedSourcePath.Length + 1)
+
+ $objectMap[$key] = [PSCustomObject]@{
+ ObjectType = $normalizedType
+ ObjectTypeAL = $objectType.ToLower()
+ ObjectId = $objectId
+ ObjectName = $objectName
+ FilePath = $file.FullName
+ RelativePath = $relativePath
+ Procedures = $procedures
+ TotalLines = ($content -split "`n").Count
+ ExecutableLines = $executableInfo.ExecutableLines
+ ExecutableLineNumbers = $executableInfo.ExecutableLineNumbers
+ }
+ }
+ }
+
+ Write-Host "Mapped $($objectMap.Count) AL objects from $SourcePath"
+ return $objectMap
+}
+
+<#
+.SYNOPSIS
+ Normalizes AL object type names to match BC internal naming
+.PARAMETER ObjectType
+ The object type as written in AL code
+.OUTPUTS
+ Normalized type name
+#>
+function Get-NormalizedObjectType {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$ObjectType
+ )
+
+ $typeMap = @{
+ 'codeunit' = 'Codeunit'
+ 'table' = 'Table'
+ 'page' = 'Page'
+ 'report' = 'Report'
+ 'query' = 'Query'
+ 'xmlport' = 'XMLport'
+ 'enum' = 'Enum'
+ 'interface' = 'Interface'
+ 'permissionset' = 'PermissionSet'
+ 'tableextension' = 'TableExtension'
+ 'pageextension' = 'PageExtension'
+ 'reportextension' = 'ReportExtension'
+ 'enumextension' = 'EnumExtension'
+ 'permissionsetextension' = 'PermissionSetExtension'
+ 'profile' = 'Profile'
+ 'controladdin' = 'ControlAddIn'
+ }
+
+ $lower = $ObjectType.ToLower()
+ if ($typeMap.ContainsKey($lower)) {
+ return $typeMap[$lower]
+ }
+ return $ObjectType
+}
+
+<#
+.SYNOPSIS
+ Extracts procedure definitions from AL source content
+.PARAMETER Content
+ The AL source file content
+.OUTPUTS
+ Array of procedure objects with Name, StartLine, EndLine
+#>
+function Get-ALProcedures {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Content
+ )
+
+ $procedures = @()
+ $lines = $Content -split "`n"
+
+ # Track procedure boundaries
+ # Patterns for procedure definitions:
+ # procedure Name()
+ # local procedure Name()
+ # internal procedure Name()
+ # [attribute] procedure Name()
+
+ $procedurePattern = '(?i)^\s*(?:local\s+|internal\s+|protected\s+)?(procedure|trigger)\s+("([^"]+)"|([^\s(]+))'
+
+ $currentProcedure = $null
+ $braceDepth = 0
+ $inProcedure = $false
+
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ $line = $lines[$i]
+ $lineNum = $i + 1 # 1-based line numbers
+
+ # Check for procedure start
+ $match = [regex]::Match($line, $procedurePattern)
+ if ($match.Success -and -not $inProcedure) {
+ $procType = $match.Groups[1].Value
+ $procName = if ($match.Groups[3].Value) { $match.Groups[3].Value } else { $match.Groups[4].Value }
+
+ $currentProcedure = @{
+ Name = $procName
+ Type = $procType
+ StartLine = $lineNum
+ EndLine = $lineNum
+ }
+ $inProcedure = $true
+ $braceDepth = 0
+ }
+
+ # Track braces for procedure end
+ if ($inProcedure) {
+ # Count AL block boundaries (word-boundary anchored to avoid matching variable names)
+ $openBraces = ([regex]::Matches($line, '\bbegin\b', 'IgnoreCase')).Count
+ $closeBraces = ([regex]::Matches($line, '\bend\b\s*;?\s*$', 'IgnoreCase')).Count
+
+ $braceDepth += $openBraces
+ $braceDepth -= $closeBraces
+
+ # Check if procedure ended
+ if ($braceDepth -le 0 -and $line -match '\bend\b\s*;?\s*$') {
+ $currentProcedure.EndLine = $lineNum
+ $procedures += [PSCustomObject]$currentProcedure
+ $currentProcedure = $null
+ $inProcedure = $false
+ }
+ }
+ }
+
+ # Handle unclosed procedure (shouldn't happen in valid AL)
+ if ($currentProcedure) {
+ $currentProcedure.EndLine = $lines.Count
+ $procedures += [PSCustomObject]$currentProcedure
+ }
+
+ return $procedures
+}
+
+<#
+.SYNOPSIS
+ Finds which procedure contains a given line number
+.PARAMETER Procedures
+ Array of procedure objects
+.PARAMETER LineNo
+ The line number to find
+.OUTPUTS
+ The procedure object containing the line, or $null
+#>
+function Find-ProcedureForLine {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [array]$Procedures,
+
+ [Parameter(Mandatory = $true)]
+ [int]$LineNo
+ )
+
+ foreach ($proc in $Procedures) {
+ if ($LineNo -ge $proc.StartLine -and $LineNo -le $proc.EndLine) {
+ return $proc
+ }
+ }
+ return $null
+}
+
+<#
+.SYNOPSIS
+ Finds all source folders in a project directory
+.PARAMETER ProjectPath
+ Path to the project root
+.OUTPUTS
+ Array of paths to folders containing .al files
+#>
+function Find-ALSourceFolders {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$ProjectPath
+ )
+
+ $sourceFolders = @()
+
+ # Common AL project structures:
+ # - src/ folder
+ # - app/ folder
+ # - Root folder with .al files
+ # - Multiple app folders
+
+ $commonFolders = @('src', 'app', 'Source', 'App')
+
+ foreach ($folder in $commonFolders) {
+ $path = Join-Path $ProjectPath $folder
+ if (Test-Path $path) {
+ $sourceFolders += $path
+ }
+ }
+
+ # If no common folders found, check for .al files in root
+ if ($sourceFolders.Count -eq 0) {
+ $alFiles = Get-ChildItem -Path $ProjectPath -Filter "*.al" -File -ErrorAction SilentlyContinue
+ if ($alFiles.Count -gt 0) {
+ $sourceFolders += $ProjectPath
+ }
+ }
+
+ # Also look for subfolders that contain app.json (multi-app repos)
+ $appJsonFiles = Get-ChildItem -Path $ProjectPath -Filter "app.json" -Recurse -File -Depth 2 -ErrorAction SilentlyContinue
+ foreach ($appJson in $appJsonFiles) {
+ $appFolder = $appJson.DirectoryName
+ if ($appFolder -ne $ProjectPath -and $sourceFolders -notcontains $appFolder) {
+ $sourceFolders += $appFolder
+ }
+ }
+
+ return $sourceFolders | Select-Object -Unique
+}
+
+<#
+.SYNOPSIS
+ Counts executable lines in AL source content
+.DESCRIPTION
+ Identifies lines that are executable code statements vs non-executable
+ (comments, blank lines, declarations, keywords like begin/end, etc.)
+.PARAMETER Content
+ The AL source file content
+.OUTPUTS
+ Object with TotalLines, ExecutableLines, and ExecutableLineNumbers array
+#>
+function Get-ALExecutableLines {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Content
+ )
+
+ $lines = $Content -split "`n"
+ $executableLineNumbers = @()
+ $inMultiLineComment = $false
+ $inProcedureBody = $false
+ $braceDepth = 0
+ $previousLineEndsContinuation = $false
+
+ # Tokens that indicate a line is a continuation of the previous statement.
+ # Based on the patterns from NAV's CCCalc/CodeLine.cs.
+ # Start tokens: if a line starts with one of these, it's a continuation of the previous line.
+ # End tokens: if a line ends with one of these, the next line is a continuation.
+ $lineContinueStartPattern = '(?i)^(,|=|\(|\+|-|\*|/|\[|:=|\+=|-=|\*=|/=|in\s|and\s|or\s|xor\s|not\s)'
+ $lineContinueEndPattern = '(?i)(,|=|\(|\+|-|\*|/|\[|:=|\+=|-=|\*=|/=|\sin|\sand|\sor|\sxor|\snot)$'
+
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ $lineNum = $i + 1
+ $line = $lines[$i].Trim()
+
+ # Skip empty lines
+ if ([string]::IsNullOrWhiteSpace($line)) {
+ continue
+ }
+
+ # Handle multi-line comments /* */
+ if ($line -match '/\*') {
+ $inMultiLineComment = $true
+ }
+ if ($inMultiLineComment) {
+ if ($line -match '\*/') {
+ $inMultiLineComment = $false
+ }
+ continue
+ }
+
+ # Skip single-line comments
+ if ($line -match '^//') {
+ continue
+ }
+
+ # Remove inline comments for analysis
+ $lineNoComment = $line -replace '//.*$', ''
+ $lineNoComment = $lineNoComment.Trim()
+
+ if ([string]::IsNullOrWhiteSpace($lineNoComment)) {
+ continue
+ }
+
+ # Line continuation detection: if this line starts with a continuation token,
+ # or the previous line ended with one, this line is a continuation of a
+ # multi-line statement and should not count as a separate executable line.
+ $isLineContinuation = $false
+ if ($lineNoComment -match $lineContinueStartPattern -and $lineNoComment -notmatch '^\s*/\*') {
+ $isLineContinuation = $true
+ }
+ if ($previousLineEndsContinuation) {
+ $isLineContinuation = $true
+ }
+ $previousLineEndsContinuation = ($lineNoComment -match $lineContinueEndPattern -and $lineNoComment -notmatch '\*/$')
+
+ # Skip non-executable constructs
+ # Namespace and using declarations
+ if ($lineNoComment -match '(?i)^(namespace|using)\s+') {
+ continue
+ }
+
+ # Object declarations
+ if ($lineNoComment -match '(?i)^(codeunit|table|page|report|query|xmlport|enum|interface|permissionset|tableextension|pageextension|reportextension|enumextension)\s+\d+') {
+ continue
+ }
+
+ # Field/column definitions (in tables)
+ if ($lineNoComment -match '(?i)^field\s*\(\s*\d+\s*;') {
+ continue
+ }
+
+ # Property assignments (Name = value;)
+ # In AL, properties always use `=` while code assignments use `:=`.
+ # A line starting with `Identifier = ` (single equals, not `:=`) is always a
+ # property declaration, never executable code. This catches all AL properties
+ # (PageType, ApplicationArea, ToolTip, Caption, Access, etc.) without
+ # needing to enumerate each one.
+ if ($lineNoComment -match '^\w+\s*=' -and $lineNoComment -notmatch ':=') {
+ continue
+ }
+
+ # Procedure/trigger declarations (the signature line itself)
+ if ($lineNoComment -match '(?i)^(local\s+|internal\s+|protected\s+)?(procedure|trigger)\s+') {
+ $inProcedureBody = $true
+ $braceDepth = 0
+ continue
+ }
+
+ # Variable declarations
+ if ($lineNoComment -match '(?i)^var\s*$') {
+ continue
+ }
+ if ($lineNoComment -match '(?i)^\w+\s*:\s*(Record|Code|Text|Integer|Decimal|Boolean|Date|Time|DateTime|Option|Enum|Codeunit|Page|Report|Query|Guid|BigInteger|Blob|Media|MediaSet|RecordRef|FieldRef|JsonObject|JsonArray|JsonToken|JsonValue|HttpClient|HttpContent|HttpRequestMessage|HttpResponseMessage|List|Dictionary|TextBuilder|OutStream|InStream|File|Char|Byte|Duration|Label|DotNet)') {
+ continue
+ }
+
+ # Keywords that are structural, not executable
+ if ($lineNoComment -match '(?i)^(begin|end;?|keys|fieldgroups|actions|area|group|repeater|layout|requestpage|dataset|column|dataitem|labels|trigger\s+OnRun|trigger\s+On\w+)\s*$') {
+ # Track begin/end for procedure body detection
+ if ($lineNoComment -match '(?i)^begin\s*$') {
+ $braceDepth++
+ }
+ if ($lineNoComment -match '(?i)^end;?\s*$') {
+ $braceDepth--
+ if ($braceDepth -le 0) {
+ $inProcedureBody = $false
+ }
+ }
+ continue
+ }
+
+ # Bare `else` / `else begin` — branch target keywords, not executable statements.
+ # BC's runtime does not instrument these as separate lines.
+ if ($lineNoComment -match '(?i)^else(\s+begin)?\s*$') {
+ continue
+ }
+
+ # Case labels (e.g., `MyEnum::Value:` or `1:`) — match labels, not executable.
+ # BC instruments the code within case branches, not the label itself.
+ # These lines end with `:` (the label separator), don't contain `:=`, and aren't keywords.
+ if ($lineNoComment -match ':\s*$' -and $lineNoComment -notmatch ':=' -and $lineNoComment -notmatch '(?i)^(begin|end|if|else|for|foreach|while|repeat|exit|error|message|case)') {
+ continue
+ }
+
+ # Skip continuation lines — they are part of a multi-line statement
+ # and should not count as separate executable lines
+ if ($isLineContinuation) {
+ continue
+ }
+
+ # At this point, if we're in a procedure body, it's likely executable
+ if ($inProcedureBody -or $braceDepth -gt 0) {
+ $executableLineNumbers += $lineNum
+ }
+ # Also count lines that look like statements (assignments, calls, control flow)
+ elseif ($lineNoComment -match '(?i)(:=|if\s+|else|for\s+|foreach\s+|while\s+|repeat|until|case\s+|exit\(|error\(|message\(|\.\w+\(|;$)') {
+ $executableLineNumbers += $lineNum
+ }
+ }
+
+ return [PSCustomObject]@{
+ TotalLines = $lines.Count
+ ExecutableLines = $executableLineNumbers.Count
+ ExecutableLineNumbers = $executableLineNumbers
+ }
+}
+
+Export-ModuleMember -Function @(
+ 'Read-AppJson',
+ 'Get-ALObjectMap',
+ 'Get-NormalizedObjectType',
+ 'Get-ALProcedures',
+ 'Find-ProcedureForLine',
+ 'Find-ALSourceFolders',
+ 'Get-ALExecutableLines'
+)
diff --git a/Actions/.Modules/TestRunner/CoverageProcessor/BCCoverageParser.psm1 b/Actions/.Modules/TestRunner/CoverageProcessor/BCCoverageParser.psm1
new file mode 100644
index 0000000000..256aaa5658
--- /dev/null
+++ b/Actions/.Modules/TestRunner/CoverageProcessor/BCCoverageParser.psm1
@@ -0,0 +1,430 @@
+<#
+.SYNOPSIS
+ Parses Business Central code coverage files
+.DESCRIPTION
+ Reads and parses the code coverage output files generated by BC CodeCoverage exporters.
+ Supports two formats:
+ - CSV (.dat): XMLport 130470/130471 - ObjectType,ObjectID,LineNo,CoverageStatus,NoOfHits
+ - XML: XMLport 130007 - Full XML format with all code lines including not covered
+ Encoding can be UTF-8 or UTF-16 LE.
+#>
+
+# Object type mapping from BC internal IDs to names
+$script:ObjectTypeMap = @{
+ 1 = 'TableData'
+ 3 = 'Table'
+ 5 = 'Codeunit'
+ 6 = 'XMLport'
+ 7 = 'MenuSuite'
+ 8 = 'Page'
+ 9 = 'Query'
+ 14 = 'Report'
+ 22 = 'TableExtension'
+ 23 = 'PageExtension'
+ 35 = 'PageCustomization'
+ 37 = 'Enum'
+ 38 = 'EnumExtension'
+ 40 = 'Profile'
+ 42 = 'ControlAddIn'
+ 44 = 'PermissionSet'
+ 45 = 'PermissionSetExtension'
+ 46 = 'ReportExtension'
+ 47 = 'Interface'
+}
+
+# Coverage status mapping
+$script:CoverageStatusMap = @{
+ 0 = 'Covered'
+ 1 = 'NotCovered'
+ 2 = 'PartiallyCovered'
+}
+
+# Reverse mapping from object type names to IDs
+$script:ObjectTypeNameMap = @{
+ 'tabledata' = 1
+ 'table' = 3
+ 'codeunit' = 5
+ 'xmlport' = 6
+ 'menusuite' = 7
+ 'page' = 8
+ 'query' = 9
+ 'report' = 14
+ 'tableextension' = 22
+ 'pageextension' = 23
+ 'pagecustomization' = 35
+ 'enum' = 37
+ 'enumextension' = 38
+ 'profile' = 40
+ 'controladdin' = 42
+ 'permissionset' = 44
+ 'permissionsetextension' = 45
+ 'reportextension' = 46
+ 'interface' = 47
+}
+
+<#
+.SYNOPSIS
+ Gets the object type ID from a name
+.PARAMETER ObjectTypeName
+ The object type name (e.g., "Table", "Codeunit")
+.OUTPUTS
+ Integer ID or 0 if not found
+#>
+function Get-ObjectTypeId {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$ObjectTypeName
+ )
+
+ $lower = $ObjectTypeName.ToLower().Trim()
+ if ($script:ObjectTypeNameMap.ContainsKey($lower)) {
+ return $script:ObjectTypeNameMap[$lower]
+ }
+ return 0
+}
+
+<#
+.SYNOPSIS
+ Parses a BC code coverage file (auto-detects CSV or XML format)
+.PARAMETER Path
+ Path to the coverage file (.dat for CSV, .xml for XML)
+.OUTPUTS
+ Array of coverage entry objects with ObjectType, ObjectID, LineNo, CoverageStatus, Hits
+#>
+function Read-BCCoverageFile {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+ )
+
+ if (-not (Test-Path $Path)) {
+ throw "Coverage file not found: $Path"
+ }
+
+ # Detect format based on file extension or content
+ $extension = [System.IO.Path]::GetExtension($Path).ToLower()
+
+ if ($extension -eq '.xml') {
+ return Read-BCCoverageXmlFile -Path $Path
+ }
+
+ # For .dat or other extensions, check content to detect format
+ $bytes = [System.IO.File]::ReadAllBytes($Path)
+ $contentStart = ""
+
+ # Get first few characters to detect XML
+ if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
+ # UTF-16 LE
+ $contentStart = [System.Text.Encoding]::Unicode.GetString($bytes, 2, [Math]::Min(100, $bytes.Length - 2))
+ } elseif ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
+ # UTF-8 with BOM
+ $contentStart = [System.Text.Encoding]::UTF8.GetString($bytes, 3, [Math]::Min(100, $bytes.Length - 3))
+ } else {
+ # No BOM, assume UTF-8
+ $contentStart = [System.Text.Encoding]::UTF8.GetString($bytes, 0, [Math]::Min(100, $bytes.Length))
+ }
+
+ if ($contentStart.TrimStart().StartsWith('
+function Read-BCCoverageXmlFile {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+ )
+
+ $coverageEntries = [System.Collections.Generic.List[object]]::new()
+
+ try {
+ [xml]$xml = Get-Content -Path $Path -Encoding UTF8
+ }
+ catch {
+ # Try Unicode encoding
+ [xml]$xml = Get-Content -Path $Path -Encoding Unicode
+ }
+
+ # XMLport 130007 format: ...
+ # Each CodeLine has: ObjectType, ObjectID, LineNo, Code, CoverageStatus, NoOfHits
+ $codeLines = $xml.SelectNodes('//CodeLine')
+ if (-not $codeLines -or $codeLines.Count -eq 0) {
+ # Try alternate root element names
+ $codeLines = $xml.SelectNodes('//CodeCoverageLines/CodeCoverageLine')
+ }
+ if (-not $codeLines -or $codeLines.Count -eq 0) {
+ $codeLines = $xml.SelectNodes('//*[ObjectType and ObjectID and LineNo]')
+ }
+
+ foreach ($node in $codeLines) {
+ $objectTypeRaw = $node.ObjectType
+ $objectId = [int]$node.ObjectID
+ $lineNo = [int]$node.LineNo
+
+ # CoverageStatus: 0=Covered, 1=NotCovered, 2=PartiallyCovered
+ $coverageStatus = 1 # Default to NotCovered
+ if ($node.CoverageStatus) {
+ $coverageStatus = [int]$node.CoverageStatus
+ } elseif ($node.NoOfHits -and [int]$node.NoOfHits -gt 0) {
+ $coverageStatus = 0 # Covered
+ }
+
+ $hits = 0
+ if ($node.NoOfHits) {
+ $hits = [int]$node.NoOfHits
+ }
+
+ # Handle both numeric and text object types
+ $objectTypeId = 0
+ $objectType = $objectTypeRaw
+
+ if ($objectTypeRaw -match '^\d+$') {
+ $objectTypeId = [int]$objectTypeRaw
+ $objectType = if ($script:ObjectTypeMap.ContainsKey($objectTypeId)) {
+ $script:ObjectTypeMap[$objectTypeId]
+ } else {
+ "Unknown_$objectTypeId"
+ }
+ } else {
+ $objectType = $objectTypeRaw
+ $objectTypeId = Get-ObjectTypeId $objectTypeRaw
+ }
+
+ $entry = [PSCustomObject]@{
+ ObjectTypeId = $objectTypeId
+ ObjectType = $objectType
+ ObjectId = $objectId
+ LineNo = $lineNo
+ CoverageStatus = $coverageStatus
+ CoverageStatusName = if ($script:CoverageStatusMap.ContainsKey($coverageStatus)) {
+ $script:CoverageStatusMap[$coverageStatus]
+ } else {
+ "Unknown_$coverageStatus"
+ }
+ Hits = $hits
+ IsCovered = ($coverageStatus -eq 0 -or $coverageStatus -eq 2)
+ }
+
+ $coverageEntries.Add($entry)
+ }
+
+ Write-Host "Parsed $($coverageEntries.Count) coverage entries from XML file: $Path"
+ return ,@($coverageEntries)
+}
+
+<#
+.SYNOPSIS
+ Parses a BC code coverage CSV file (XMLport 130470/130471 format)
+.PARAMETER Path
+ Path to the .dat CSV coverage file
+.OUTPUTS
+ Array of coverage entry objects
+#>
+function Read-BCCoverageCsvFile {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+ )
+
+ $coverageEntries = [System.Collections.Generic.List[object]]::new()
+
+ # BC coverage files can be UTF-16 (Unicode) or UTF-8 encoded
+ # Try to detect based on BOM or content validation
+ $content = $null
+ $bytes = [System.IO.File]::ReadAllBytes($Path)
+
+ # Check for UTF-16 LE BOM (FF FE)
+ if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
+ $content = Get-Content -Path $Path -Encoding Unicode
+ }
+ # Check for UTF-8 BOM (EF BB BF)
+ elseif ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
+ $content = Get-Content -Path $Path -Encoding UTF8
+ }
+ else {
+ # No BOM - try UTF8 first (more common for text files without BOM)
+ $content = Get-Content -Path $Path -Encoding UTF8
+
+ # Validate first line looks like coverage data
+ if ($content.Count -gt 0) {
+ $firstLine = $content[0]
+ $parts = $firstLine.Split(',')
+ # If first line doesn't have 5 parts or second part isn't numeric, try Unicode
+ if ($parts.Count -lt 5 -or $parts[1] -notmatch '^\d+$') {
+ $content = Get-Content -Path $Path -Encoding Unicode
+ }
+ }
+ }
+
+ foreach ($line in $content) {
+ # Skip empty lines
+ if ([string]::IsNullOrWhiteSpace($line)) {
+ continue
+ }
+
+ # Parse CSV: ObjectType,ObjectID,LineNo,CoverageStatus,NoOfHits
+ # ObjectType can be numeric (5) or text (Codeunit, Table, etc.)
+ $parts = $line.Split(',')
+
+ if ($parts.Count -ge 5) {
+ $objectTypeRaw = $parts[0].Trim()
+ $objectId = [int]$parts[1]
+ $lineNo = [int]$parts[2]
+ $coverageStatus = [int]$parts[3]
+ $hits = [int]$parts[4]
+
+ # Handle both numeric and text object types
+ $objectTypeId = 0
+ $objectType = $objectTypeRaw
+
+ if ($objectTypeRaw -match '^\d+$') {
+ # Numeric object type ID
+ $objectTypeId = [int]$objectTypeRaw
+ $objectType = if ($script:ObjectTypeMap.ContainsKey($objectTypeId)) {
+ $script:ObjectTypeMap[$objectTypeId]
+ } else {
+ "Unknown_$objectTypeId"
+ }
+ } else {
+ # Text object type name - normalize and find ID
+ $objectType = $objectTypeRaw
+ $objectTypeId = Get-ObjectTypeId $objectTypeRaw
+ }
+
+ $entry = [PSCustomObject]@{
+ ObjectTypeId = $objectTypeId
+ ObjectType = $objectType
+ ObjectId = $objectId
+ LineNo = $lineNo
+ CoverageStatus = $coverageStatus
+ CoverageStatusName = if ($script:CoverageStatusMap.ContainsKey($coverageStatus)) {
+ $script:CoverageStatusMap[$coverageStatus]
+ } else {
+ "Unknown_$coverageStatus"
+ }
+ Hits = $hits
+ IsCovered = ($coverageStatus -eq 0 -or $coverageStatus -eq 2)
+ }
+
+ $coverageEntries.Add($entry)
+ }
+ }
+
+ Write-Host "Parsed $($coverageEntries.Count) coverage entries from $Path"
+ return ,@($coverageEntries)
+}
+
+<#
+.SYNOPSIS
+ Groups coverage entries by object (ObjectType + ObjectId)
+.PARAMETER CoverageEntries
+ Array of coverage entries from Read-BCCoverageFile
+.OUTPUTS
+ Hashtable keyed by "ObjectType.ObjectId" containing grouped entries
+#>
+function Group-CoverageByObject {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [array]$CoverageEntries
+ )
+
+ $grouped = @{}
+
+ foreach ($entry in $CoverageEntries) {
+ $key = "$($entry.ObjectType).$($entry.ObjectId)"
+
+ if (-not $grouped.ContainsKey($key)) {
+ $grouped[$key] = @{
+ ObjectType = $entry.ObjectType
+ ObjectTypeId = $entry.ObjectTypeId
+ ObjectId = $entry.ObjectId
+ Lines = [System.Collections.Generic.List[object]]::new()
+ }
+ }
+
+ $grouped[$key].Lines.Add($entry)
+ }
+
+ return $grouped
+}
+
+<#
+.SYNOPSIS
+ Calculates coverage statistics for a set of entries
+.PARAMETER CoverageEntries
+ Array of coverage entries
+.OUTPUTS
+ Object with TotalLines, CoveredLines, NotCoveredLines, CoveragePercent
+#>
+function Get-CoverageStatistics {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [array]$CoverageEntries
+ )
+
+ $totalLines = $CoverageEntries.Count
+ $coveredLines = ($CoverageEntries | Where-Object { $_.IsCovered }).Count
+ $notCoveredLines = $totalLines - $coveredLines
+ $coveragePercent = if ($totalLines -gt 0) {
+ [math]::Round(($coveredLines / $totalLines) * 100, 2)
+ } else {
+ 0
+ }
+
+ return [PSCustomObject]@{
+ TotalLines = $totalLines
+ CoveredLines = $coveredLines
+ NotCoveredLines = $notCoveredLines
+ CoveragePercent = $coveragePercent
+ LineRate = if ($totalLines -gt 0) { $coveredLines / $totalLines } else { 0 }
+ }
+}
+
+<#
+.SYNOPSIS
+ Gets the object type name from an ID
+.PARAMETER ObjectTypeId
+ The BC object type ID
+.OUTPUTS
+ String name of the object type
+#>
+function Get-ObjectTypeName {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [int]$ObjectTypeId
+ )
+
+ if ($script:ObjectTypeMap.ContainsKey($ObjectTypeId)) {
+ return $script:ObjectTypeMap[$ObjectTypeId]
+ }
+ return "Unknown_$ObjectTypeId"
+}
+
+Export-ModuleMember -Function @(
+ 'Read-BCCoverageFile',
+ 'Read-BCCoverageXmlFile',
+ 'Read-BCCoverageCsvFile',
+ 'Group-CoverageByObject',
+ 'Get-CoverageStatistics',
+ 'Get-ObjectTypeName',
+ 'Get-ObjectTypeId'
+)
diff --git a/Actions/.Modules/TestRunner/CoverageProcessor/CoberturaFormatter.psm1 b/Actions/.Modules/TestRunner/CoverageProcessor/CoberturaFormatter.psm1
new file mode 100644
index 0000000000..1a413e161b
--- /dev/null
+++ b/Actions/.Modules/TestRunner/CoverageProcessor/CoberturaFormatter.psm1
@@ -0,0 +1,418 @@
+<#
+.SYNOPSIS
+ Formats code coverage data as Cobertura XML
+.DESCRIPTION
+ Converts BC code coverage data into standard Cobertura XML format
+ for use with coverage visualization tools and GitHub Actions.
+#>
+
+<#
+.SYNOPSIS
+ Creates a Cobertura XML document from coverage data
+.PARAMETER CoverageData
+ Processed coverage data with object and line information
+.PARAMETER SourcePath
+ Base path for source files (used in sources element)
+.PARAMETER AppInfo
+ Optional app metadata (Name, Publisher, Version)
+.OUTPUTS
+ XmlDocument containing Cobertura-formatted coverage
+#>
+function New-CoberturaDocument {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [hashtable]$CoverageData,
+
+ [Parameter(Mandatory = $false)]
+ [string]$SourcePath = "",
+
+ [Parameter(Mandatory = $false)]
+ $AppInfo = $null
+ )
+
+ # Calculate overall statistics
+ # XMLport 130470 only exports covered lines, so we need source info for total executable lines
+ $totalExecutableLines = 0
+ $coveredLines = 0
+
+ foreach ($obj in $CoverageData.Values) {
+ # Prefer source-based count for total lines (accurate for XMLport 130470)
+ # Fall back to coverage data line count if no source info available
+ $objTotalLines = 0
+ if ($obj.SourceInfo -and $obj.SourceInfo.ExecutableLines) {
+ $objTotalLines = $obj.SourceInfo.ExecutableLines
+ } elseif ($obj.Lines.Count -gt 0) {
+ $objTotalLines = $obj.Lines.Count
+ }
+
+ $totalExecutableLines += $objTotalLines
+
+ # Count covered lines from coverage data
+ foreach ($line in $obj.Lines) {
+ if ($line.IsCovered) { $coveredLines++ }
+ }
+ }
+
+ $lineRate = if ($totalExecutableLines -gt 0) { [math]::Round($coveredLines / $totalExecutableLines, 4) } else { 0 }
+ $branchRate = 0 # BC coverage doesn't provide branch information
+
+ # Create XML document
+ $xml = New-Object System.Xml.XmlDocument
+
+ # XML declaration
+ $declaration = $xml.CreateXmlDeclaration("1.0", "UTF-8", $null)
+ $xml.AppendChild($declaration) | Out-Null
+
+ # DOCTYPE for Cobertura
+ # Note: Omitting DOCTYPE as many tools don't require it and it can cause issues
+
+ # Root coverage element
+ $coverage = $xml.CreateElement("coverage")
+ $coverage.SetAttribute("line-rate", $lineRate.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $coverage.SetAttribute("branch-rate", $branchRate.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $coverage.SetAttribute("lines-covered", $coveredLines.ToString())
+ $coverage.SetAttribute("lines-valid", $totalExecutableLines.ToString())
+ $coverage.SetAttribute("branches-covered", "0")
+ $coverage.SetAttribute("branches-valid", "0")
+ $coverage.SetAttribute("complexity", "0")
+ $coverage.SetAttribute("version", "1.0")
+ $coverage.SetAttribute("timestamp", [DateTimeOffset]::Now.ToUnixTimeSeconds().ToString())
+ $xml.AppendChild($coverage) | Out-Null
+
+ # Sources element
+ $sources = $xml.CreateElement("sources")
+ $source = $xml.CreateElement("source")
+ $source.InnerText = if ($SourcePath) { $SourcePath } else { "." }
+ $sources.AppendChild($source) | Out-Null
+ $coverage.AppendChild($sources) | Out-Null
+
+ # Packages element
+ $packages = $xml.CreateElement("packages")
+ $coverage.AppendChild($packages) | Out-Null
+
+ # Create a package for the app
+ $packageName = if ($AppInfo -and $AppInfo.Name) { $AppInfo.Name } else { "BCApp" }
+ $package = $xml.CreateElement("package")
+ $package.SetAttribute("name", $packageName)
+ $package.SetAttribute("line-rate", $lineRate.ToString())
+ $package.SetAttribute("branch-rate", "0")
+ $package.SetAttribute("complexity", "0")
+ $packages.AppendChild($package) | Out-Null
+
+ # Classes element within package
+ $classes = $xml.CreateElement("classes")
+ $package.AppendChild($classes) | Out-Null
+
+ # Add each object as a class
+ foreach ($key in $CoverageData.Keys | Sort-Object) {
+ $obj = $CoverageData[$key]
+ $classElement = New-CoberturaClass -Xml $xml -ObjectData $obj
+ $classes.AppendChild($classElement) | Out-Null
+ }
+
+ return $xml
+}
+
+<#
+.SYNOPSIS
+ Creates a Cobertura class element for a BC object
+.PARAMETER Xml
+ The parent XmlDocument
+.PARAMETER ObjectData
+ Object data with type, id, lines, and optional source info
+.OUTPUTS
+ XmlElement for the class
+#>
+function New-CoberturaClass {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [System.Xml.XmlDocument]$Xml,
+
+ [Parameter(Mandatory = $true)]
+ $ObjectData
+ )
+
+ # Calculate class statistics
+ # Prefer source-based count for total lines (accurate for XMLport 130470)
+ $totalExecutableLines = 0
+ if ($ObjectData.SourceInfo -and $ObjectData.SourceInfo.ExecutableLines) {
+ $totalExecutableLines = $ObjectData.SourceInfo.ExecutableLines
+ } elseif ($ObjectData.Lines.Count -gt 0) {
+ $totalExecutableLines = $ObjectData.Lines.Count
+ }
+ $coveredLines = @($ObjectData.Lines | Where-Object { $_.IsCovered }).Count
+ $lineRate = if ($totalExecutableLines -gt 0) { [math]::Round($coveredLines / $totalExecutableLines, 4) } else { 0 }
+
+ # Create class element
+ $class = $Xml.CreateElement("class")
+
+ # Class name: ObjectType.ObjectId (e.g., "Codeunit.50100")
+ $className = "$($ObjectData.ObjectType).$($ObjectData.ObjectId)"
+ $class.SetAttribute("name", $className)
+
+ # Filename - use source file path if available, otherwise construct a logical name
+ $filename = if ($ObjectData.SourceInfo -and $ObjectData.SourceInfo.RelativePath) {
+ $ObjectData.SourceInfo.RelativePath
+ } else {
+ "$($ObjectData.ObjectType)/$($ObjectData.ObjectId).al"
+ }
+ $class.SetAttribute("filename", $filename.Replace('\', '/'))
+
+ $class.SetAttribute("line-rate", $lineRate.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $class.SetAttribute("branch-rate", "0")
+ $class.SetAttribute("complexity", "0")
+
+ # Methods element
+ $methods = $Xml.CreateElement("methods")
+ $class.AppendChild($methods) | Out-Null
+
+ # Group lines by procedure if source info available and there are lines to process
+ if ($ObjectData.SourceInfo -and $ObjectData.SourceInfo.Procedures -and $ObjectData.Lines -and $ObjectData.Lines.Count -gt 0) {
+ $procedureCoverage = Get-ProcedureCoverage -Lines $ObjectData.Lines -Procedures $ObjectData.SourceInfo.Procedures
+
+ foreach ($proc in $procedureCoverage) {
+ $method = New-CoberturaMethod -Xml $Xml -ProcedureData $proc
+ $methods.AppendChild($method) | Out-Null
+ }
+ }
+
+ # Lines element (all executable lines for the class)
+ $linesElement = $Xml.CreateElement("lines")
+ $class.AppendChild($linesElement) | Out-Null
+
+ # Build a set of covered line numbers for quick lookup
+ $coveredLineNumbers = @{}
+ foreach ($line in $ObjectData.Lines) {
+ $coveredLineNumbers[$line.LineNo] = $line.Hits
+ }
+
+ # If we have source info with executable line numbers, include all of them
+ if ($ObjectData.SourceInfo -and $ObjectData.SourceInfo.ExecutableLineNumbers) {
+ foreach ($lineNo in $ObjectData.SourceInfo.ExecutableLineNumbers | Sort-Object) {
+ $lineElement = $Xml.CreateElement("line")
+ $lineElement.SetAttribute("number", $lineNo.ToString())
+ $hits = if ($coveredLineNumbers.ContainsKey($lineNo)) { $coveredLineNumbers[$lineNo] } else { 0 }
+ $lineElement.SetAttribute("hits", $hits.ToString())
+ $lineElement.SetAttribute("branch", "false")
+ $linesElement.AppendChild($lineElement) | Out-Null
+ }
+ } else {
+ # Fallback: only output covered lines (BC data only)
+ foreach ($line in $ObjectData.Lines | Sort-Object -Property LineNo) {
+ $lineElement = $Xml.CreateElement("line")
+ $lineElement.SetAttribute("number", $line.LineNo.ToString())
+ $lineElement.SetAttribute("hits", $line.Hits.ToString())
+ $lineElement.SetAttribute("branch", "false")
+ $linesElement.AppendChild($lineElement) | Out-Null
+ }
+ }
+
+ return $class
+}
+
+<#
+.SYNOPSIS
+ Creates a Cobertura method element
+.PARAMETER Xml
+ The parent XmlDocument
+.PARAMETER ProcedureData
+ Procedure data with name and line coverage
+.OUTPUTS
+ XmlElement for the method
+#>
+function New-CoberturaMethod {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [System.Xml.XmlDocument]$Xml,
+
+ [Parameter(Mandatory = $true)]
+ $ProcedureData
+ )
+
+ $method = $Xml.CreateElement("method")
+ $method.SetAttribute("name", $ProcedureData.Name)
+ $method.SetAttribute("signature", "()") # AL doesn't have traditional signatures
+
+ $totalLines = $ProcedureData.Lines.Count
+ $coveredLines = ($ProcedureData.Lines | Where-Object { $_.IsCovered }).Count
+ $lineRate = if ($totalLines -gt 0) { [math]::Round($coveredLines / $totalLines, 4) } else { 0 }
+
+ $method.SetAttribute("line-rate", $lineRate.ToString())
+ $method.SetAttribute("branch-rate", "0")
+ $method.SetAttribute("complexity", "0")
+
+ # Lines within method
+ $lines = $Xml.CreateElement("lines")
+ $method.AppendChild($lines) | Out-Null
+
+ foreach ($line in $ProcedureData.Lines | Sort-Object -Property LineNo) {
+ $lineElement = $Xml.CreateElement("line")
+ $lineElement.SetAttribute("number", $line.LineNo.ToString())
+ $lineElement.SetAttribute("hits", $line.Hits.ToString())
+ $lineElement.SetAttribute("branch", "false")
+ $lines.AppendChild($lineElement) | Out-Null
+ }
+
+ return $method
+}
+
+<#
+.SYNOPSIS
+ Groups coverage lines by procedure
+.PARAMETER Lines
+ Array of coverage line entries
+.PARAMETER Procedures
+ Array of procedure definitions with StartLine/EndLine
+.OUTPUTS
+ Array of procedure coverage objects
+#>
+function Get-ProcedureCoverage {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [array]$Lines = @(),
+
+ [Parameter(Mandatory = $true)]
+ [array]$Procedures
+ )
+
+ $result = @()
+
+ # If no lines, return empty result
+ if (-not $Lines -or $Lines.Count -eq 0) {
+ return $result
+ }
+
+ foreach ($proc in $Procedures) {
+ $procLines = $Lines | Where-Object {
+ $_.LineNo -ge $proc.StartLine -and $_.LineNo -le $proc.EndLine
+ }
+
+ if ($procLines.Count -gt 0) {
+ $result += [PSCustomObject]@{
+ Name = $proc.Name
+ Type = $proc.Type
+ Lines = $procLines
+ }
+ }
+ }
+
+ return $result
+}
+
+<#
+.SYNOPSIS
+ Saves a Cobertura XML document to file
+.PARAMETER XmlDocument
+ The Cobertura XmlDocument
+.PARAMETER OutputPath
+ Path to save the XML file
+#>
+function Save-CoberturaFile {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [System.Xml.XmlDocument]$XmlDocument,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+ )
+
+ # Ensure directory exists
+ $directory = Split-Path -Path $OutputPath -Parent
+ if ($directory -and -not (Test-Path $directory)) {
+ New-Item -ItemType Directory -Path $directory -Force | Out-Null
+ }
+
+ # Configure XML writer settings for proper formatting
+ $settings = New-Object System.Xml.XmlWriterSettings
+ $settings.Indent = $true
+ $settings.IndentChars = " "
+ $settings.Encoding = [System.Text.UTF8Encoding]::new($false) # UTF-8 without BOM
+
+ $writer = [System.Xml.XmlWriter]::Create($OutputPath, $settings)
+ try {
+ $XmlDocument.Save($writer)
+ }
+ finally {
+ $writer.Close()
+ }
+
+ Write-Host "Saved Cobertura coverage report to: $OutputPath"
+}
+
+<#
+.SYNOPSIS
+ Creates a minimal Cobertura document for summary display
+.PARAMETER TotalLines
+ Total number of lines
+.PARAMETER CoveredLines
+ Number of covered lines
+.PARAMETER PackageName
+ Name for the package
+.OUTPUTS
+ XmlDocument with summary coverage
+#>
+function New-CoberturaSummary {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [int]$TotalLines,
+
+ [Parameter(Mandatory = $true)]
+ [int]$CoveredLines,
+
+ [Parameter(Mandatory = $false)]
+ [string]$PackageName = "BCApp"
+ )
+
+ $lineRate = if ($TotalLines -gt 0) { [math]::Round($CoveredLines / $TotalLines, 4) } else { 0 }
+
+ $xml = New-Object System.Xml.XmlDocument
+ $declaration = $xml.CreateXmlDeclaration("1.0", "UTF-8", $null)
+ $xml.AppendChild($declaration) | Out-Null
+
+ $coverage = $xml.CreateElement("coverage")
+ $coverage.SetAttribute("line-rate", $lineRate.ToString())
+ $coverage.SetAttribute("branch-rate", "0")
+ $coverage.SetAttribute("lines-covered", $CoveredLines.ToString())
+ $coverage.SetAttribute("lines-valid", $TotalLines.ToString())
+ $coverage.SetAttribute("branches-covered", "0")
+ $coverage.SetAttribute("branches-valid", "0")
+ $coverage.SetAttribute("complexity", "0")
+ $coverage.SetAttribute("version", "1.0")
+ $coverage.SetAttribute("timestamp", [DateTimeOffset]::Now.ToUnixTimeSeconds().ToString())
+ $xml.AppendChild($coverage) | Out-Null
+
+ $sources = $xml.CreateElement("sources")
+ $source = $xml.CreateElement("source")
+ $source.InnerText = "."
+ $sources.AppendChild($source) | Out-Null
+ $coverage.AppendChild($sources) | Out-Null
+
+ $packages = $xml.CreateElement("packages")
+ $package = $xml.CreateElement("package")
+ $package.SetAttribute("name", $PackageName)
+ $package.SetAttribute("line-rate", $lineRate.ToString())
+ $package.SetAttribute("branch-rate", "0")
+ $package.SetAttribute("complexity", "0")
+ $packages.AppendChild($package) | Out-Null
+ $coverage.AppendChild($packages) | Out-Null
+
+ $classes = $xml.CreateElement("classes")
+ $package.AppendChild($classes) | Out-Null
+
+ return $xml
+}
+
+Export-ModuleMember -Function @(
+ 'New-CoberturaDocument',
+ 'New-CoberturaClass',
+ 'New-CoberturaMethod',
+ 'Get-ProcedureCoverage',
+ 'Save-CoberturaFile',
+ 'New-CoberturaSummary'
+)
diff --git a/Actions/.Modules/TestRunner/CoverageProcessor/CoverageProcessor.psm1 b/Actions/.Modules/TestRunner/CoverageProcessor/CoverageProcessor.psm1
new file mode 100644
index 0000000000..cd328f00e9
--- /dev/null
+++ b/Actions/.Modules/TestRunner/CoverageProcessor/CoverageProcessor.psm1
@@ -0,0 +1,507 @@
+<#
+.SYNOPSIS
+ Main orchestrator for processing BC code coverage into Cobertura format
+.DESCRIPTION
+ Combines BCCoverageParser, ALSourceParser, and CoberturaFormatter to produce
+ standardized coverage reports from BC code coverage .dat files.
+#>
+
+# Import sub-modules
+$scriptPath = Split-Path -Path $MyInvocation.MyCommand.Path -Parent
+Import-Module (Join-Path $scriptPath "BCCoverageParser.psm1") -Force
+Import-Module (Join-Path $scriptPath "ALSourceParser.psm1") -Force
+Import-Module (Join-Path $scriptPath "CoberturaFormatter.psm1") -Force
+
+<#
+.SYNOPSIS
+ Processes BC code coverage files and generates Cobertura XML output
+.PARAMETER CoverageFilePath
+ Path to the BC coverage .dat file
+.PARAMETER SourcePath
+ Path to the source code directory (for file/method mapping)
+.PARAMETER OutputPath
+ Path where the Cobertura XML file should be written
+.PARAMETER AppJsonPath
+ Optional path to app.json for app metadata
+.OUTPUTS
+ Returns coverage statistics object
+#>
+function Convert-BCCoverageToCobertura {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$CoverageFilePath,
+
+ [Parameter(Mandatory = $false)]
+ [string]$SourcePath = "",
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AppJsonPath = "",
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$AppSourcePaths = @(),
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$ExcludePatterns = @()
+ )
+
+ Write-Host "Converting BC coverage to Cobertura format..."
+ Write-Host " Coverage file: $CoverageFilePath"
+ Write-Host " Source path: $SourcePath"
+ Write-Host " Output path: $OutputPath"
+
+ # Step 1: Parse the coverage file
+ Write-Host "`nStep 1: Parsing coverage data..."
+ $coverageEntries = Read-BCCoverageFile -Path $CoverageFilePath
+
+ if ($coverageEntries.Count -eq 0) {
+ Write-Warning "No coverage entries found in file"
+ return $null
+ }
+
+ # Step 2: Group coverage by object
+ Write-Host "`nStep 2: Grouping coverage by object..."
+ $groupedCoverage = Group-CoverageByObject -CoverageEntries $coverageEntries
+ Write-Host " Found $($groupedCoverage.Count) unique objects"
+
+ # Step 3: Load app metadata if available
+ $appInfo = $null
+ if ($AppJsonPath -and (Test-Path $AppJsonPath)) {
+ Write-Host "`nStep 3: Loading app metadata..."
+ $appInfo = Read-AppJson -AppJsonPath $AppJsonPath
+ if ($appInfo) {
+ Write-Host " App: $($appInfo.Name) v$($appInfo.Version)"
+ }
+ }
+ elseif ($SourcePath) {
+ # Try to find app.json in source path
+ $autoAppJson = Join-Path $SourcePath "app.json"
+ if (Test-Path $autoAppJson) {
+ Write-Host "`nStep 3: Loading app metadata from source path..."
+ $appInfo = Read-AppJson -AppJsonPath $autoAppJson
+ if ($appInfo) {
+ Write-Host " App: $($appInfo.Name) v$($appInfo.Version)"
+ }
+ }
+ }
+
+ # Step 4: Map source files if source path provided
+ $objectMap = @{}
+ $excludedObjectsData = [System.Collections.Generic.List[object]]::new()
+
+ if ($SourcePath -and (Test-Path $SourcePath)) {
+ Write-Host "`nStep 4: Mapping source files..."
+ $objectMap = Get-ALObjectMap -SourcePath $SourcePath -AppSourcePaths $AppSourcePaths -ExcludePatterns $ExcludePatterns
+
+ # Filter coverage to only include objects from user's source files
+ # This excludes Microsoft base app objects
+ $filteredCoverage = @{}
+
+ foreach ($key in $groupedCoverage.Keys) {
+ if ($objectMap.ContainsKey($key)) {
+ $filteredCoverage[$key] = $groupedCoverage[$key]
+ $filteredCoverage[$key].SourceInfo = $objectMap[$key]
+ } else {
+ # Track excluded object details for reporting
+ $objData = $groupedCoverage[$key]
+ $linesExecuted = @($objData.Lines | Where-Object { $_.IsCovered }).Count
+ $excludedObjectsData.Add([PSCustomObject]@{
+ ObjectType = $objData.ObjectType
+ ObjectId = $objData.ObjectId
+ LinesExecuted = $linesExecuted
+ TotalHits = ($objData.Lines | Measure-Object -Property Hits -Sum).Sum
+ })
+ }
+ }
+
+ Write-Host " Found $($objectMap.Count) objects in source files"
+ Write-Host " Matched $($filteredCoverage.Count) objects with coverage data"
+ if ($excludedObjectsData.Count -gt 0) {
+ Write-Host " Excluded $($excludedObjectsData.Count) objects (Microsoft/external)"
+ }
+
+ # Use filtered coverage going forward
+ $groupedCoverage = $filteredCoverage
+
+ # Add objects from source that have no coverage (not executed at all)
+ foreach ($key in $objectMap.Keys) {
+ if (-not $groupedCoverage.ContainsKey($key)) {
+ # Object exists in source but has no coverage - add with empty lines
+ $groupedCoverage[$key] = @{
+ ObjectType = $objectMap[$key].ObjectType
+ ObjectTypeId = Get-ObjectTypeId $objectMap[$key].ObjectType
+ ObjectId = $objectMap[$key].ObjectId
+ Lines = @()
+ SourceInfo = $objectMap[$key]
+ }
+ }
+ }
+ }
+
+ # Step 5: Generate Cobertura XML
+ Write-Host "`nStep 5: Generating Cobertura XML..."
+ $coberturaXml = New-CoberturaDocument -CoverageData $groupedCoverage -SourcePath $SourcePath -AppInfo $appInfo
+
+ # Step 6: Save output
+ Write-Host "`nStep 6: Saving output..."
+ Save-CoberturaFile -XmlDocument $coberturaXml -OutputPath $OutputPath
+
+ # Calculate and return statistics
+ # Always prefer source-based executable line count when available.
+ # XMLport 130470 only exports covered lines, making Lines.Count inaccurate
+ # as a denominator (it equals covered-line count, not total executable lines).
+ # XMLport 130007 exports all executable lines, so Lines.Count would be correct,
+ # but source-based count is still preferred for consistency.
+ $totalExecutableLines = 0
+ $coveredLines = 0
+
+ foreach ($obj in $groupedCoverage.Values) {
+ $objTotalLines = if ($obj.SourceInfo -and $obj.SourceInfo.ExecutableLines -gt 0) {
+ $obj.SourceInfo.ExecutableLines
+ } else {
+ $obj.Lines.Count
+ }
+
+ $totalExecutableLines += $objTotalLines
+ $coveredLines += @($obj.Lines | Where-Object { $_.IsCovered }).Count
+ }
+
+ $coveragePercent = if ($totalExecutableLines -gt 0) {
+ [math]::Round(($coveredLines / $totalExecutableLines) * 100, 2)
+ } else {
+ 0
+ }
+
+ # Calculate stats for excluded objects (external/base app code)
+ $excludedLinesExecuted = ($excludedObjectsData | Measure-Object -Property LinesExecuted -Sum).Sum
+ $excludedTotalHits = ($excludedObjectsData | Measure-Object -Property TotalHits -Sum).Sum
+
+ $stats = [PSCustomObject]@{
+ TotalLines = $totalExecutableLines
+ CoveredLines = $coveredLines
+ NotCoveredLines = $totalExecutableLines - $coveredLines
+ CoveragePercent = $coveragePercent
+ LineRate = if ($totalExecutableLines -gt 0) { $coveredLines / $totalExecutableLines } else { 0 }
+ ObjectCount = $groupedCoverage.Count
+ ExcludedObjectCount = $excludedObjectsData.Count
+ ExcludedLinesExecuted = $excludedLinesExecuted
+ ExcludedTotalHits = $excludedTotalHits
+ ExcludedObjects = $excludedObjectsData
+ AppSourcePaths = @($AppSourcePaths | ForEach-Object {
+ # Store paths relative to SourcePath for portability
+ $normalizedSrc = [System.IO.Path]::GetFullPath($SourcePath).TrimEnd('\', '/')
+ $normalizedApp = [System.IO.Path]::GetFullPath($_).TrimEnd('\', '/')
+ if ($normalizedApp.StartsWith($normalizedSrc, [System.StringComparison]::OrdinalIgnoreCase)) {
+ $normalizedApp.Substring($normalizedSrc.Length + 1).Replace('\', '/')
+ } else { $normalizedApp.Replace('\', '/') }
+ })
+ }
+
+ # Save extended stats to JSON file alongside Cobertura XML
+ $statsOutputPath = [System.IO.Path]::ChangeExtension($OutputPath, '.stats.json')
+ $stats | ConvertTo-Json -Depth 10 | Set-Content -Path $statsOutputPath -Encoding UTF8
+ Write-Host " Saved coverage stats to: $statsOutputPath"
+
+ Write-Host "`n=== Coverage Summary (User Code Only) ==="
+ Write-Host " Objects: $($stats.ObjectCount)"
+ Write-Host " Total lines: $($stats.TotalLines)"
+ Write-Host " Covered lines: $($stats.CoveredLines)"
+ Write-Host " Coverage: $($stats.CoveragePercent)%"
+ if ($stats.ExcludedObjectCount -gt 0) {
+ Write-Host " --- External Code (no source) ---"
+ Write-Host " Excluded objects: $($stats.ExcludedObjectCount)"
+ Write-Host " Lines executed: $($stats.ExcludedLinesExecuted)"
+ }
+ Write-Host "==========================================`n"
+
+ return $stats
+}
+
+<#
+.SYNOPSIS
+ Processes multiple coverage files and merges into single Cobertura output
+.PARAMETER CoverageFiles
+ Array of paths to coverage .dat files
+.PARAMETER SourcePath
+ Path to the source code directory
+.PARAMETER OutputPath
+ Path where the merged Cobertura XML should be written
+.PARAMETER AppJsonPath
+ Optional path to app.json
+.OUTPUTS
+ Returns merged coverage statistics
+#>
+function Merge-BCCoverageToCobertura {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$CoverageFiles,
+
+ [Parameter(Mandatory = $false)]
+ [string]$SourcePath = "",
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AppJsonPath = "",
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$AppSourcePaths = @(),
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$ExcludePatterns = @()
+ )
+
+ Write-Host "Merging $($CoverageFiles.Count) coverage files..."
+
+ $allEntries = [System.Collections.Generic.List[object]]::new()
+
+ foreach ($file in $CoverageFiles) {
+ if (Test-Path $file) {
+ Write-Host " Reading: $file"
+ $entries = Read-BCCoverageFile -Path $file
+ $allEntries.AddRange(@($entries))
+ }
+ else {
+ Write-Warning "Coverage file not found: $file"
+ }
+ }
+
+ if ($allEntries.Count -eq 0) {
+ Write-Warning "No coverage entries found in any file"
+ return $null
+ }
+
+ # Merge entries by object+line (take max hits)
+ $mergedEntries = @{}
+ foreach ($entry in $allEntries) {
+ $key = "$($entry.ObjectTypeId)_$($entry.ObjectId)_$($entry.LineNo)"
+
+ if ($mergedEntries.ContainsKey($key)) {
+ $existing = $mergedEntries[$key]
+ # Accumulate hits and promote coverage status
+ if ($entry.Hits -gt $existing.Hits) {
+ $mergedEntries[$key].Hits = $entry.Hits
+ }
+ if ($entry.IsCovered -and -not $existing.IsCovered) {
+ $mergedEntries[$key].IsCovered = $true
+ $mergedEntries[$key].CoverageStatus = $entry.CoverageStatus
+ $mergedEntries[$key].CoverageStatusName = $entry.CoverageStatusName
+ }
+ }
+ else {
+ $mergedEntries[$key] = $entry
+ }
+ }
+
+ $coverageEntries = $mergedEntries.Values | Sort-Object ObjectTypeId, ObjectId, LineNo
+ Write-Host "Merged to $($coverageEntries.Count) unique line entries"
+
+ # Group and process
+ $groupedCoverage = Group-CoverageByObject -CoverageEntries $coverageEntries
+
+ # Load metadata
+ $appInfo = $null
+ if ($AppJsonPath -and (Test-Path $AppJsonPath)) {
+ $appInfo = Read-AppJson -AppJsonPath $AppJsonPath
+ }
+ elseif ($SourcePath) {
+ $autoAppJson = Join-Path $SourcePath "app.json"
+ if (Test-Path $autoAppJson) {
+ $appInfo = Read-AppJson -AppJsonPath $autoAppJson
+ }
+ }
+
+ # Map sources and track excluded objects
+ $excludedObjectsData = [System.Collections.Generic.List[object]]::new()
+ if ($SourcePath -and (Test-Path $SourcePath)) {
+ $objectMap = Get-ALObjectMap -SourcePath $SourcePath -AppSourcePaths $AppSourcePaths -ExcludePatterns $ExcludePatterns
+ $filteredCoverage = @{}
+
+ foreach ($key in $groupedCoverage.Keys) {
+ if ($objectMap.ContainsKey($key)) {
+ $filteredCoverage[$key] = $groupedCoverage[$key]
+ $filteredCoverage[$key].SourceInfo = $objectMap[$key]
+ } else {
+ # Track excluded object details
+ $objData = $groupedCoverage[$key]
+ $linesExecuted = @($objData.Lines | Where-Object { $_.IsCovered }).Count
+ $excludedObjectsData.Add([PSCustomObject]@{
+ ObjectType = $objData.ObjectType
+ ObjectId = $objData.ObjectId
+ LinesExecuted = $linesExecuted
+ TotalHits = ($objData.Lines | Measure-Object -Property Hits -Sum).Sum
+ })
+ }
+ }
+
+ Write-Host " Matched $($filteredCoverage.Count) objects with source"
+ if ($excludedObjectsData.Count -gt 0) {
+ Write-Host " Excluded $($excludedObjectsData.Count) objects (Microsoft/external)"
+ }
+ $groupedCoverage = $filteredCoverage
+
+ # Add objects from source that have no coverage (not executed at all)
+ foreach ($key in $objectMap.Keys) {
+ if (-not $groupedCoverage.ContainsKey($key)) {
+ $groupedCoverage[$key] = @{
+ ObjectType = $objectMap[$key].ObjectType
+ ObjectTypeId = Get-ObjectTypeId $objectMap[$key].ObjectType
+ ObjectId = $objectMap[$key].ObjectId
+ Lines = @()
+ SourceInfo = $objectMap[$key]
+ }
+ }
+ }
+ }
+
+ # Generate and save
+ $coberturaXml = New-CoberturaDocument -CoverageData $groupedCoverage -SourcePath $SourcePath -AppInfo $appInfo
+ Save-CoberturaFile -XmlDocument $coberturaXml -OutputPath $OutputPath
+
+ # Calculate stats from filtered/grouped coverage (user code only), consistent with Convert-BCCoverageToCobertura
+ # Always prefer source-based executable line count — see Convert-BCCoverageToCobertura for rationale
+ $totalExecutableLines = 0
+ $coveredLines = 0
+
+ foreach ($obj in $groupedCoverage.Values) {
+ $objTotalLines = if ($obj.SourceInfo -and $obj.SourceInfo.ExecutableLines -gt 0) {
+ $obj.SourceInfo.ExecutableLines
+ } else {
+ $obj.Lines.Count
+ }
+ $totalExecutableLines += $objTotalLines
+ $coveredLines += @($obj.Lines | Where-Object { $_.IsCovered }).Count
+ }
+
+ $coveragePercent = if ($totalExecutableLines -gt 0) {
+ [math]::Round(($coveredLines / $totalExecutableLines) * 100, 2)
+ } else {
+ 0
+ }
+
+ # Calculate stats for excluded objects (external/base app code)
+ $excludedLinesExecuted = ($excludedObjectsData | Measure-Object -Property LinesExecuted -Sum).Sum
+ $excludedTotalHits = ($excludedObjectsData | Measure-Object -Property TotalHits -Sum).Sum
+
+ $stats = [PSCustomObject]@{
+ TotalLines = $totalExecutableLines
+ CoveredLines = $coveredLines
+ NotCoveredLines = $totalExecutableLines - $coveredLines
+ CoveragePercent = $coveragePercent
+ LineRate = if ($totalExecutableLines -gt 0) { $coveredLines / $totalExecutableLines } else { 0 }
+ ObjectCount = $groupedCoverage.Count
+ ExcludedObjectCount = $excludedObjectsData.Count
+ ExcludedLinesExecuted = $excludedLinesExecuted
+ ExcludedTotalHits = $excludedTotalHits
+ ExcludedObjects = $excludedObjectsData
+ AppSourcePaths = @($AppSourcePaths | ForEach-Object {
+ $normalizedSrc = [System.IO.Path]::GetFullPath($SourcePath).TrimEnd('\', '/')
+ $normalizedApp = [System.IO.Path]::GetFullPath($_).TrimEnd('\', '/')
+ if ($normalizedApp.StartsWith($normalizedSrc, [System.StringComparison]::OrdinalIgnoreCase)) {
+ $normalizedApp.Substring($normalizedSrc.Length + 1).Replace('\', '/')
+ } else { $normalizedApp.Replace('\', '/') }
+ })
+ }
+
+ # Save extended stats to JSON file
+ $statsOutputPath = [System.IO.Path]::ChangeExtension($OutputPath, '.stats.json')
+ $stats | ConvertTo-Json -Depth 10 | Set-Content -Path $statsOutputPath -Encoding UTF8
+ Write-Host " Saved coverage stats to: $statsOutputPath"
+
+ Write-Host "`n=== Merged Coverage Summary (User Code Only) ==="
+ Write-Host " Objects: $($stats.ObjectCount)"
+ Write-Host " Total lines: $($stats.TotalLines)"
+ Write-Host " Covered lines: $($stats.CoveredLines)"
+ Write-Host " Coverage: $($stats.CoveragePercent)%"
+ if ($stats.ExcludedObjectCount -gt 0) {
+ Write-Host " --- External Code (no source) ---"
+ Write-Host " Excluded objects: $($stats.ExcludedObjectCount)"
+ Write-Host " Lines executed: $($stats.ExcludedLinesExecuted)"
+ }
+ Write-Host "================================================`n"
+
+ return $stats
+}
+
+<#
+.SYNOPSIS
+ Finds coverage files in a directory
+.PARAMETER Directory
+ Directory to search for coverage files
+.PARAMETER Pattern
+ File pattern to match (default: *.dat)
+.OUTPUTS
+ Array of file paths
+#>
+function Find-CoverageFiles {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Directory,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Pattern = "*.dat"
+ )
+
+ if (-not (Test-Path $Directory)) {
+ Write-Warning "Directory not found: $Directory"
+ return @()
+ }
+
+ $files = Get-ChildItem -Path $Directory -Filter $Pattern -File -Recurse
+ return $files.FullName
+}
+
+<#
+.SYNOPSIS
+ Quick coverage summary without generating full Cobertura output
+.PARAMETER CoverageFilePath
+ Path to the coverage .dat file
+.OUTPUTS
+ Coverage statistics object
+#>
+function Get-BCCoverageSummary {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$CoverageFilePath
+ )
+
+ $entries = Read-BCCoverageFile -Path $CoverageFilePath
+ $stats = Get-CoverageStatistics -CoverageEntries $entries
+
+ # Add object breakdown
+ $grouped = Group-CoverageByObject -CoverageEntries $entries
+ $objectStats = @()
+
+ foreach ($key in $grouped.Keys | Sort-Object) {
+ $obj = $grouped[$key]
+ $objEntries = $obj.Lines
+ $objStats = Get-CoverageStatistics -CoverageEntries $objEntries
+
+ $objectStats += [PSCustomObject]@{
+ Object = $key
+ ObjectType = $obj.ObjectType
+ ObjectId = $obj.ObjectId
+ TotalLines = $objStats.TotalLines
+ CoveredLines = $objStats.CoveredLines
+ CoveragePercent = $objStats.CoveragePercent
+ }
+ }
+
+ $stats | Add-Member -NotePropertyName "Objects" -NotePropertyValue $objectStats
+
+ return $stats
+}
+
+Export-ModuleMember -Function @(
+ 'Convert-BCCoverageToCobertura',
+ 'Merge-BCCoverageToCobertura',
+ 'Find-CoverageFiles',
+ 'Get-BCCoverageSummary'
+)
diff --git a/Actions/.Modules/TestRunner/Internal/ALTestRunnerInternal.psm1 b/Actions/.Modules/TestRunner/Internal/ALTestRunnerInternal.psm1
new file mode 100644
index 0000000000..cfb3af496a
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/ALTestRunnerInternal.psm1
@@ -0,0 +1,316 @@
+# Core test execution logic.
+# Helper modules are imported for client session management, form helpers, and coverage collection.
+
+. "$PSScriptRoot\Constants.ps1"
+. "$PSScriptRoot\ModuleInit.ps1"
+
+Import-Module "$PSScriptRoot\ClientSessionManager.psm1" -Force -DisableNameChecking
+Import-Module "$PSScriptRoot\TestFormHelpers.psm1" -Force -DisableNameChecking
+Import-Module "$PSScriptRoot\CoverageCollector.psm1" -Force -DisableNameChecking
+
+function Write-Log {
+ param(
+ [Parameter(Position=0)]
+ [string]$Message
+ )
+ Write-Host $Message
+}
+
+function Run-AlTestsInternal
+(
+ [string] $TestSuite = $script:DefaultTestSuite,
+ [string] $TestCodeunitsRange = "",
+ [string] $TestProcedureRange = "",
+ [string] $ExtensionId = "",
+ [ValidateSet('None','Disabled','Codeunit','Function')]
+ [string] $RequiredTestIsolation = "None",
+ [string] $TestType,
+ [int] $TestRunnerId = $global:DefaultTestRunner,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AutorizationType = $script:DefaultAuthorizationType,
+ [string] $TestPage = $global:DefaultTestPage,
+ [switch] $DisableSSLVerification,
+ [Parameter(Mandatory=$true)]
+ [string] $ServiceUrl,
+ [Parameter(Mandatory=$false)]
+ [pscredential] $Credential,
+ [bool] $Detailed = $true,
+ [array] $DisabledTests = @(),
+ [ValidateSet('Disabled', 'PerRun', 'PerCodeunit', 'PerTest')]
+ [string] $CodeCoverageTrackingType = 'Disabled',
+ [ValidateSet('Disabled','PerCodeunit','PerTest')]
+ [string] $ProduceCodeCoverageMap = 'Disabled',
+ [string] $CodeCoverageOutputPath = "$PSScriptRoot\CodeCoverage",
+ [string] $CodeCoverageExporterId,
+ [switch] $CodeCoverageTrackAllSessions,
+ [string] $CodeCoverageFilePrefix,
+ [bool] $StabilityRun
+)
+{
+ $ErrorActionPreference = $script:DefaultErrorActionPreference
+
+ Setup-TestRun -DisableSSLVerification:$DisableSSLVerification -AutorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl -TestSuite $TestSuite `
+ -TestCodeunitsRange $TestCodeunitsRange -TestProcedureRange $TestProcedureRange -ExtensionId $ExtensionId -RequiredTestIsolation $RequiredTestIsolation -TestType $TestType `
+ -TestRunnerId $TestRunnerId -TestPage $TestPage -DisabledTests $DisabledTests -CodeCoverageTrackingType $CodeCoverageTrackingType -CodeCoverageTrackAllSessions:$CodeCoverageTrackAllSessions -CodeCoverageOutputPath $CodeCoverageOutputPath -CodeCoverageExporterId $CodeCoverageExporterId -ProduceCodeCoverageMap $ProduceCodeCoverageMap -StabilityRun $StabilityRun
+
+ $testRunResults = New-Object System.Collections.ArrayList
+ $testResult = ''
+ $numberOfUnexpectedFailures = 0;
+
+ do
+ {
+ try
+ {
+ $testStartTime = $(Get-Date)
+ $testResult = Run-NextTest -DisableSSLVerification:$DisableSSLVerification -AutorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl -TestSuite $TestSuite
+ if($testResult -eq $script:AllTestsExecutedResult)
+ {
+ return [Array]$testRunResults
+ }
+
+ $testRunResultObject = ConvertFrom-Json $testResult
+ if($CodeCoverageTrackingType -ne 'Disabled') {
+ $null = CollectCoverageResults -TrackingType $CodeCoverageTrackingType -OutputPath $CodeCoverageOutputPath -DisableSSLVerification:$DisableSSLVerification -AutorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl -CodeCoverageFilePrefix $CodeCoverageFilePrefix -TestPage $TestPage -ProduceCodeCoverageMap $ProduceCodeCoverageMap
+ }
+ }
+ catch
+ {
+ $numberOfUnexpectedFailures++
+
+ $stackTrace = $_.Exception.StackTrace + "Script stack trace: " + $_.ScriptStackTrace
+ $testMethodResult = @{
+ method = "Unexpected Failure"
+ codeUnit = "Unexpected Failure"
+ startTime = $testStartTime.ToString($script:DateTimeFormat)
+ finishTime = ($(Get-Date).ToString($script:DateTimeFormat))
+ result = $script:FailureTestResultType
+ message = $_.Exception.Message
+ stackTrace = $stackTrace
+ }
+
+ $testRunResultObject = @{
+ name = "Unexpected Failure"
+ codeUnit = "UnexpectedFailure"
+ startTime = $testStartTime.ToString($script:DateTimeFormat)
+ finishTime = ($(Get-Date).ToString($script:DateTimeFormat))
+ result = $script:FailureTestResultType
+ testResults = @($testMethodResult)
+ }
+ }
+
+ $testRunResults.Add($testRunResultObject) > $null
+ if($Detailed)
+ {
+ Print-TestResults -TestRunResultObject $testRunResultObject
+ }
+ }
+ until((!$testRunResultObject) -or ($script:NumberOfUnexpectedFailuresBeforeAborting -lt $numberOfUnexpectedFailures))
+
+ throw "Expected to end the test execution, something went wrong with returning test results."
+}
+
+function Print-TestResults
+(
+ $TestRunResultObject
+)
+{
+ $startTime = Convert-ResultStringToDateTimeSafe -DateTimeString $TestRunResultObject.startTime
+ $finishTime = Convert-ResultStringToDateTimeSafe -DateTimeString $TestRunResultObject.finishTime
+ $duration = $finishTime.Subtract($startTime)
+ $durationSeconds = [Math]::Round($duration.TotalSeconds,3)
+
+ switch($TestRunResultObject.result)
+ {
+ $script:SuccessTestResultType
+ {
+ Write-Host -ForegroundColor Green "Success - Codeunit $($TestRunResultObject.name) - Duration $durationSeconds seconds"
+ break;
+ }
+ $script:FailureTestResultType
+ {
+ Write-Host -ForegroundColor Red "Failure - Codeunit $($TestRunResultObject.name) - Duration $durationSeconds seconds"
+ break;
+ }
+ default
+ {
+ if($TestRunResultObject.codeUnit -and $TestRunResultObject.codeUnit -ne "0")
+ {
+ Write-Host -ForegroundColor Yellow "No tests were executed - Codeunit $($TestRunResultObject.name)"
+ }
+ }
+ }
+
+ if($TestRunResultObject.testResults)
+ {
+ foreach($testFunctionResult in $TestRunResultObject.testResults)
+ {
+ $durationSeconds = 0;
+ $methodName = $testFunctionResult.method
+
+ if($testFunctionResult.result -ne $script:SkippedTestResultType)
+ {
+ $startTime = Convert-ResultStringToDateTimeSafe -DateTimeString $testFunctionResult.startTime
+ $finishTime = Convert-ResultStringToDateTimeSafe -DateTimeString $testFunctionResult.finishTime
+ $duration = $finishTime.Subtract($startTime)
+ $durationSeconds = [Math]::Round($duration.TotalSeconds,3)
+ }
+
+ switch($testFunctionResult.result)
+ {
+ $script:SuccessTestResultType
+ {
+ Write-Host -ForegroundColor Green " Success - Test method: $methodName - Duration $durationSeconds seconds)"
+ break;
+ }
+ $script:FailureTestResultType
+ {
+ $callStack = $testFunctionResult.stackTrace
+ Write-Host -ForegroundColor Red " Failure - Test method: $methodName - Duration $durationSeconds seconds"
+ Write-Host -ForegroundColor Red " Error:"
+ Write-Host -ForegroundColor Red " $($testFunctionResult.message)"
+ Write-Host -ForegroundColor Red " Call Stack:"
+ if($callStack)
+ {
+ Write-Host -ForegroundColor Red " $($callStack.Replace(';',"`n "))"
+ }
+ break;
+ }
+ $script:SkippedTestResultType
+ {
+ Write-Host -ForegroundColor Yellow " Skipped - Test method: $methodName"
+ break;
+ }
+ }
+ }
+ }
+}
+
+function Setup-TestRun
+(
+ [switch] $DisableSSLVerification,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AutorizationType = $script:DefaultAuthorizationType,
+ [Parameter(Mandatory=$false)]
+ [pscredential] $Credential,
+ [Parameter(Mandatory=$true)]
+ [string] $ServiceUrl,
+ [string] $TestSuite = $script:DefaultTestSuite,
+ [string] $TestCodeunitsRange = "",
+ [string] $TestProcedureRange = "",
+ [string] $ExtensionId = "",
+ [ValidateSet('None','Disabled','Codeunit','Function')]
+ [string] $RequiredTestIsolation = "None",
+ [string] $TestType,
+ [int] $TestRunnerId = $global:DefaultTestRunner,
+ [string] $TestPage = $global:DefaultTestPage,
+ [array] $DisabledTests = @(),
+ [ValidateSet('Disabled', 'PerRun', 'PerCodeunit', 'PerTest')]
+ [string] $CodeCoverageTrackingType = 'Disabled',
+ [ValidateSet('Disabled','PerCodeunit','PerTest')]
+ [string] $ProduceCodeCoverageMap = 'Disabled',
+ [string] $CodeCoverageOutputPath = "$PSScriptRoot\CodeCoverage",
+ [string] $CodeCoverageExporterId,
+ [switch] $CodeCoverageTrackAllSessions,
+ [bool] $StabilityRun
+)
+{
+ Write-Log "Setting up test run: $CodeCoverageTrackingType - $CodeCoverageOutputPath"
+ if($CodeCoverageTrackingType -ne 'Disabled')
+ {
+ if (-not (Test-Path -Path $CodeCoverageOutputPath))
+ {
+ $null = New-Item -Path $CodeCoverageOutputPath -ItemType Directory
+ }
+ }
+
+ try
+ {
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl
+
+ $form = Open-TestForm -TestPage $TestPage -ClientContext $clientContext
+ Set-TestSuite -TestSuite $TestSuite -ClientContext $clientContext -Form $form
+ Set-ExtensionId -ExtensionId $ExtensionId -Form $form -ClientContext $clientContext
+ if (![string]::IsNullOrEmpty($TestType)) {
+ Set-RequiredTestIsolation -RequiredTestIsolation $RequiredTestIsolation -Form $form -ClientContext $clientContext
+ Set-TestType -TestType $TestType -Form $form -ClientContext $clientContext
+ }
+ Set-TestCodeunits -TestCodeunitsFilter $TestCodeunitsRange -Form $form -ClientContext $clientContext
+ Set-TestProcedures -Filter $TestProcedureRange -Form $form -ClientContext $clientContext
+ Set-TestRunner -TestRunnerId $TestRunnerId -Form $form -ClientContext $clientContext
+ Set-RunFalseOnDisabledTests -DisabledTests $DisabledTests -Form $form -ClientContext $clientContext
+ Set-StabilityRun -StabilityRun $StabilityRun -Form $form -ClientContext $clientContext
+ Clear-TestResults -Form $form -ClientContext $clientContext
+ if($CodeCoverageTrackingType -ne 'Disabled'){
+ Set-CCTrackingType -Value $CodeCoverageTrackingType -Form $form -ClientContext $clientContext
+ Set-CCTrackAllSessions -Value:$CodeCoverageTrackAllSessions -Form $form -ClientContext $clientContext
+ Set-CCExporterID -Value $CodeCoverageExporterId -Form $form -ClientContext $clientContext
+ Clear-CCResults -Form $form -ClientContext $clientContext
+ Set-CCProduceCodeCoverageMap -Value $ProduceCodeCoverageMap -Form $form -ClientContext $clientContext
+ }
+ $clientContext.CloseForm($form)
+ }
+ finally
+ {
+ if($clientContext)
+ {
+ $clientContext.Dispose()
+ }
+ Write-Log "Complete Test Setup"
+ }
+}
+
+function Run-NextTest
+(
+ [switch] $DisableSSLVerification,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AutorizationType = $script:DefaultAuthorizationType,
+ [Parameter(Mandatory=$false)]
+ [pscredential] $Credential,
+ [Parameter(Mandatory=$true)]
+ [string] $ServiceUrl,
+ [string] $TestSuite = $script:DefaultTestSuite,
+ [string] $TestPage = $global:DefaultTestPage
+)
+{
+ try
+ {
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl
+ $form = Open-TestForm -TestPage $TestPage -ClientContext $clientContext
+ if($TestSuite -ne $script:DefaultTestSuite)
+ {
+ Set-TestSuite -TestSuite $TestSuite -ClientContext $clientContext -Form $form
+ }
+
+ $clientContext.InvokeAction($clientContext.GetActionByName($form, "RunNextTest"))
+
+ $testResultControl = $clientContext.GetControlByName($form, "TestResultJson")
+ $testResultJson = $testResultControl.StringValue
+ $clientContext.CloseForm($form)
+ return $testResultJson
+ }
+ finally
+ {
+ if($clientContext)
+ {
+ $clientContext.Dispose()
+ }
+ }
+}
+
+function Convert-ResultStringToDateTimeSafe([string] $DateTimeString)
+{
+ [datetime]$parsedDateTime = New-Object DateTime
+
+ try
+ {
+ [datetime]$parsedDateTime = [datetime]$DateTimeString
+ }
+ catch
+ {
+ Write-Host -ForegroundColor Red "Failed parsing DateTime: $DateTimeString"
+ }
+
+ return $parsedDateTime
+}
+
+Export-ModuleMember -Function Run-AlTestsInternal, Open-ClientSessionWithWait, Open-TestForm, Open-ClientSession
diff --git a/Actions/.Modules/TestRunner/Internal/AadTokenProvider.ps1 b/Actions/.Modules/TestRunner/Internal/AadTokenProvider.ps1
new file mode 100644
index 0000000000..686aaf0e87
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/AadTokenProvider.ps1
@@ -0,0 +1,68 @@
+# Example test script for using Cloud Migration APIs E2E
+# API documentation is here: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/cloudmigrationapi/cloud-migration-api-overview?branch=cloud-migration-api
+# Run the "Install-Module -Name MSAL.PS" command on the first run, unless you have installed MSAL.PS. This function is used to obtain the token
+
+# Specify the name of the module you want to check/install
+$moduleName = "MSAL.PS"
+
+# Check if the module is already installed
+if (-not (Get-Module -Name $moduleName -ListAvailable)) {
+ # Module is not installed, so install it
+ Write-Host "Installing $moduleName..."
+ Install-Module -Name $moduleName -Force -Scope AllUsers # Use -Scope CurrentUser or -Scope AllUsers as needed
+}
+
+Import-Module $moduleName
+
+class AadTokenProvider
+{
+ [string] $AADTenantID
+ [string] $ClientId
+ [string] $RedirectUri
+ [string] $CurrentToken
+ [DateTimeOffset] $TokenExpirationTime
+ [Array] $BcScopes
+ [string] $AuthorityUri
+
+ AadTokenProvider([string] $AADTenantID, [string] $ClientId, [string] $RedirectUri)
+ {
+ $this.Initialize($AADTenantID, $ClientId, $RedirectUri)
+ }
+
+ Initialize([string] $AADTenantID, [string] $ClientId, [string] $RedirectUri)
+ {
+ $this.AADTenantID = $AADTenantID
+ $this.ClientId = $ClientId
+ $this.RedirectUri = $RedirectUri
+ $BaseAuthorityUri = "https://login.microsoftonline.com"
+ $BcAppIdUri = "https://api.businesscentral.dynamics.com"
+ $this.BcScopes = @("$BcAppIdUri/user_impersonation", ("$BcAppIdUri/Financials.ReadWrite.All" ))
+ $this.AuthorityUri = "$BaseAuthorityUri/$AADTenantID"
+ $this.TokenExpirationTime = (Get-Date)
+ }
+
+ [string] GetToken([pscredential] $Credential)
+ {
+
+ if($this.TokenExpirationTime)
+ {
+ if ($this.TokenExpirationTime -gt (Get-Date))
+ {
+ return $this.CurrentToken
+ }
+ }
+
+ try
+ {
+ $AuthenticationResult = Get-MsalToken -ClientId $this.ClientId -RedirectUri $this.RedirectUri -TenantId $this.AADTenantID -Authority $this.AuthorityUri -UserCredential $Credential -Scopes $this.BcScopes
+ }
+ catch {
+ $AuthenticationResult = Get-MsalToken -ClientId $this.ClientId -RedirectUri $this.RedirectUri -TenantId $this.AADTenantID -Authority $this.AuthorityUri -Prompt SelectAccount -Scopes $this.BcScopes
+ }
+
+ $this.CurrentToken = $AuthenticationResult.AccessToken;
+
+ $this.TokenExpirationTime = ($AuthenticationResult.ExpiresOn - (New-TimeSpan -Minutes 3))
+ return $AuthenticationResult.AccessToken;
+ }
+}
diff --git a/Actions/.Modules/TestRunner/Internal/BCPTTestRunnerInternal.psm1 b/Actions/.Modules/TestRunner/Internal/BCPTTestRunnerInternal.psm1
new file mode 100644
index 0000000000..1f6201e906
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/BCPTTestRunnerInternal.psm1
@@ -0,0 +1,341 @@
+function Setup-Enviroment
+(
+ [ValidateSet("PROD","OnPrem")]
+ [string] $Environment = $script:DefaultEnvironment,
+ [string] $SandboxName = $script:DefaultSandboxName,
+ [pscredential] $Credential,
+ [pscredential] $Token,
+ [string] $ClientId,
+ [string] $RedirectUri,
+ [string] $AadTenantId
+)
+{
+ switch ($Environment)
+ {
+ "PROD"
+ {
+ $authority = "https://login.microsoftonline.com/"
+ $resource = "https://api.businesscentral.dynamics.com"
+ $global:AadTokenProvider = [AadTokenProvider]::new($AadTenantId, $ClientId, $RedirectUri)
+
+ if(!$global:AadTokenProvider){
+ $example = @'
+
+ $UserName = 'USERNAME'
+ $Password = 'PASSWORD'
+ $securePassword = ConvertTo-SecureString $Password -AsPlainText -Force
+ $UserCredential = New-Object System.Management.Automation.PSCredential($UserName, $securePassword)
+
+ $script:AADTenantID = 'Guid like - 212415e1-054e-401b-ad32-3cdfa301b1d2'
+ $script:ClientId = 'Guid like 0a576aea-5e61-4153-8639-4c5fd5e7d1f6'
+ $script:RedirectUri = 'https://login.microsoftonline.com/common/oauth2/nativeclient'
+ $global:AadTokenProvider = [AadTokenProvider]::new($script:AADTenantID, $script:ClientId, $scrit:RedirectUri)
+'@
+ throw 'You need to initialize and set the $global:AadTokenProvider. Example: ' + $example
+ }
+ $tenantDomain = ''
+ if ($Token -ne $null)
+ {
+ $tenantDomain = ($Token.UserName.Substring($Token.UserName.IndexOf('@') + 1))
+ }
+ else
+ {
+ $tenantDomain = ($Credential.UserName.Substring($Credential.UserName.IndexOf('@') + 1))
+ }
+ $script:discoveryUrl = "https://businesscentral.dynamics.com/$tenantDomain/$SandboxName/deployment/url" #Sandbox
+ $script:automationApiBaseUrl = "https://api.businesscentral.dynamics.com/v1.0/api/microsoft/automation/v1.0/companies"
+ }
+ }
+}
+
+function Get-SaaSServiceURL()
+{
+ $status = ''
+
+ $provisioningTimeout = new-timespan -Minutes 15
+ $stopWatch = [diagnostics.stopwatch]::StartNew()
+ while ($stopWatch.elapsed -lt $provisioningTimeout)
+ {
+ $response = Invoke-RestMethod -Method Get -Uri $script:discoveryUrl
+ if($response.status -eq 'Ready')
+ {
+ $clusterUrl = $response.data
+ return $clusterUrl
+ }
+ else
+ {
+ Write-Host "Could not get Service url status - $($response.status)"
+ }
+
+ sleep -Seconds 10
+ }
+}
+
+function Run-BCPTTestsInternal
+(
+ [ValidateSet("PROD","OnPrem")]
+ [string] $Environment,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AuthorizationType,
+ [pscredential] $Credential,
+ [pscredential] $Token,
+ [string] $SandboxName,
+ [int] $TestRunnerPage,
+ [switch] $DisableSSLVerification,
+ [string] $ServiceUrl,
+ [string] $SuiteCode,
+ [int] $SessionTimeoutInMins,
+ [string] $ClientId,
+ [string] $RedirectUri,
+ [string] $AadTenantId,
+ [switch] $SingleRun
+)
+{
+ <#
+ .SYNOPSIS
+ Runs the Application Beanchmark Tool(BCPT) tests.
+
+ .DESCRIPTION
+ Runs BCPT tests in different environment.
+
+ .PARAMETER Environment
+ Specifies the environment the tests will be run in. The supported values are 'PROD', 'TIE' and 'OnPrem'. Default is 'PROD'.
+
+ .PARAMETER AuthorizationType
+ Specifies the authorizatin type needed to authorize to the service. The supported values are 'Windows','NavUserPassword' and 'AAD'.
+
+ .PARAMETER Credential
+ Specifies the credential object that needs to be used to authenticate. Both 'NavUserPassword' and 'AAD' needs a valid credential objects to eb passed in.
+
+ .PARAMETER Token
+ Specifies the AAD token credential object that needs to be used to authenticate. The credential object should contain username and token.
+
+ .PARAMETER SandboxName
+ Specifies the sandbox name. This is necessary only when the environment is either 'PROD' or 'TIE'. Default is 'sandbox'.
+
+ .PARAMETER TestRunnerPage
+ Specifies the page id that is used to start the tests. Defualt is 150010.
+
+ .PARAMETER DisableSSLVerification
+ Specifies if the SSL verification should be disabled or not.
+
+ .PARAMETER ServiceUrl
+ Specifies the base url of the service. This parameter is used only in 'OnPrem' environment.
+
+ .PARAMETER SuiteCode
+ Specifies the code that will be used to select the test suite to be run.
+
+ .PARAMETER SessionTimeoutInMins
+ Specifies the timeout for the client session. This will be same the length you expect the test suite to run.
+
+ .PARAMETER ClientId
+ Specifies the guid that the BC is registered with in AAD.
+
+ .PARAMETER SingleRun
+ Specifies if it is a full run or a single iteration run.
+
+ .INPUTS
+ None. You cannot pipe objects to Add-Extension.
+
+ .EXAMPLE
+ C:\PS> Run-BCPTTestsInternal -DisableSSLVerification -Environment OnPrem -AuthorizationType Windows -ServiceUrl 'htto://localhost:48900' -TestRunnerPage 150002 -SuiteCode DEMO -SessionTimeoutInMins 20
+ File.txt
+
+ .EXAMPLE
+ C:\PS> Run-BCPTTestsInternal -DisableSSLVerification -Environment PROD -AuthorizationType AAD -Credential $Credential -TestRunnerPage 150002 -SuiteCode DEMO -SessionTimeoutInMins 20 -ClientId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
+ #>
+
+ Run-NextTest -DisableSSLVerification -Environment $Environment -AuthorizationType $AuthorizationType -Credential $Credential -Token $Token -SandboxName $SandboxName -ServiceUrl $ServiceUrl -TestRunnerPage $TestRunnerPage -SuiteCode $SuiteCode -SessionTimeout $SessionTimeoutInMins -ClientId $ClientId -RedirectUri $RedirectUri -AadTenantId $AadTenantId -SingleRun:$SingleRun
+}
+
+function Run-NextTest
+(
+ [switch] $DisableSSLVerification,
+ [ValidateSet("PROD","OnPrem")]
+ [string] $Environment,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AuthorizationType,
+ [pscredential] $Credential,
+ [pscredential] $Token,
+ [string] $SandboxName,
+ [string] $ServiceUrl,
+ [int] $TestRunnerPage,
+ [string] $SuiteCode,
+ [int] $SessionTimeout,
+ [string] $ClientId,
+ [string] $RedirectUri,
+ [string] $AadTenantId,
+ [switch] $SingleRun
+)
+{
+ Setup-Enviroment -Environment $Environment -SandboxName $SandboxName -Credential $Credential -Token $Token -ClientId $ClientId -RedirectUri $RedirectUri -AadTenantId $AadTenantId
+ if ($Environment -ne 'OnPrem')
+ {
+ $ServiceUrl = Get-SaaSServiceURL
+ }
+
+ try
+ {
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AuthorizationType -Credential $Credential -ServiceUrl $ServiceUrl -ClientSessionTimeout $SessionTimeout
+ $form = Open-TestForm -TestPage $TestRunnerPage -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AuthorizationType -ClientContext $clientContext
+
+ $SelectSuiteControl = $clientContext.GetControlByName($form, "Select Code")
+ $clientContext.SaveValue($SelectSuiteControl, $SuiteCode);
+
+ if ($SingleRun.IsPresent)
+ {
+ $StartNextAction = $clientContext.GetActionByName($form, "StartNextPRT")
+ }
+ else
+ {
+ $StartNextAction = $clientContext.GetActionByName($form, "StartNext")
+ }
+
+ $clientContext.InvokeAction($StartNextAction)
+
+ $clientContext.CloseForm($form)
+ }
+ finally
+ {
+ if($clientContext)
+ {
+ $clientContext.Dispose()
+ }
+ }
+}
+
+function Get-NoOfIterations
+(
+ [ValidateSet("PROD","OnPrem")]
+ [string] $Environment,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AuthorizationType,
+ [pscredential] $Credential,
+ [pscredential] $Token,
+ [string] $SandboxName,
+ [int] $TestRunnerPage,
+ [switch] $DisableSSLVerification,
+ [string] $ServiceUrl,
+ [string] $SuiteCode,
+ [String] $ClientId,
+ [string] $RedirectUri,
+ [string] $AadTenantId
+)
+{
+ <#
+ .SYNOPSIS
+ Opens the Application Beanchmark Tool(BCPT) test runner page and reads the number of sessions that needs to be created.
+
+ .DESCRIPTION
+ Opens the Application Beanchmark Tool(BCPT) test runner page and reads the number of sessions that needs to be created.
+
+ .PARAMETER Environment
+ Specifies the environment the tests will be run in. The supported values are 'PROD', 'TIE' and 'OnPrem'.
+
+ .PARAMETER AuthorizationType
+ Specifies the authorizatin type needed to authorize to the service. The supported values are 'Windows','NavUserPassword' and 'AAD'.
+
+ .PARAMETER Credential
+ Specifies the credential object that needs to be used to authenticate. Both 'NavUserPassword' and 'AAD' needs a valid credential objects to eb passed in.
+
+ .PARAMETER Token
+ Specifies the AAD token credential object that needs to be used to authenticate. The credential object should contain username and token.
+
+ .PARAMETER SandboxName
+ Specifies the sandbox name. This is necessary only when the environment is either 'PROD' or 'TIE'. Default is 'sandbox'.
+
+ .PARAMETER TestRunnerPage
+ Specifies the page id that is used to start the tests.
+
+ .PARAMETER DisableSSLVerification
+ Specifies if the SSL verification should be disabled or not.
+
+ .PARAMETER ServiceUrl
+ Specifies the base url of the service. This parameter is used only in 'OnPrem' environment.
+
+ .PARAMETER SuiteCode
+ Specifies the code that will be used to select the test suite to be run.
+
+ .PARAMETER ClientId
+ Specifies the guid that the BC is registered with in AAD.
+
+ .INPUTS
+ None. You cannot pipe objects to Add-Extension.
+
+ .EXAMPLE
+ C:\PS> $NoOfTasks,$TaskLifeInMins,$NoOfTests = Get-NoOfIterations -DisableSSLVerification -Environment OnPrem -AuthorizationType Windows -ServiceUrl 'htto://localhost:48900' -TestRunnerPage 150010 -SuiteCode DEMO
+ File.txt
+
+ .EXAMPLE
+ C:\PS> $NoOfTasks,$TaskLifeInMins,$NoOfTests = Get-NoOfIterations -DisableSSLVerification -Environment PROD -AuthorizationType AAD -Credential $Credential -TestRunnerPage 50010 -SuiteCode DEMO -ClientId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
+
+ #>
+
+ Setup-Enviroment -Environment $Environment -SandboxName $SandboxName -Credential $Credential -Token $Token -ClientId $ClientId -RedirectUri $RedirectUri -AadTenantId $AadTenantId
+ if ($Environment -ne 'OnPrem')
+ {
+ $ServiceUrl = Get-SaaSServiceURL
+ }
+
+ try
+ {
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AuthorizationType -Credential $Credential -ServiceUrl $ServiceUrl
+ $form = Open-TestForm -TestPage $TestRunnerPage -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AuthorizationType -ClientContext $clientContext
+ $SelectSuiteControl = $clientContext.GetControlByName($form, "Select Code")
+ $clientContext.SaveValue($SelectSuiteControl, $SuiteCode);
+
+ $testResultControl = $clientContext.GetControlByName($form, "No. of Instances")
+ $NoOfInstances = [int]$testResultControl.StringValue
+
+ $testResultControl = $clientContext.GetControlByName($form, "Duration (minutes)")
+ $DurationInMins = [int]$testResultControl.StringValue
+
+ $testResultControl = $clientContext.GetControlByName($form, "No. of Tests")
+ $NoOfTests = [int]$testResultControl.StringValue
+
+ $clientContext.CloseForm($form)
+ return $NoOfInstances,$DurationInMins,$NoOfTests
+ }
+ finally
+ {
+ if($clientContext)
+ {
+ $clientContext.Dispose()
+ }
+ }
+}
+
+$ErrorActionPreference = "Stop"
+
+if(!$script:TypesLoaded)
+{
+ Add-type -Path "$PSScriptRoot\Microsoft.Dynamics.Framework.UI.Client.dll"
+ Add-type -Path "$PSScriptRoot\NewtonSoft.Json.dll"
+ Add-type -Path "$PSScriptRoot\Microsoft.Internal.AntiSSRF.dll"
+
+ $alTestRunnerInternalPath = Join-Path $PSScriptRoot "ALTestRunnerInternal.psm1"
+ Import-Module "$alTestRunnerInternalPath"
+
+ $clientContextScriptPath = Join-Path $PSScriptRoot "ClientContext.ps1"
+ . "$clientContextScriptPath"
+
+ $aadTokenProviderScriptPath = Join-Path $PSScriptRoot "AadTokenProvider.ps1"
+ . "$aadTokenProviderScriptPath"
+}
+
+$script:TypesLoaded = $true;
+$script:ActiveDirectoryDllsLoaded = $false;
+$script:AadTokenProvider = $null
+
+$script:DefaultEnvironment = "OnPrem"
+$script:DefaultAuthorizationType = 'Windows'
+$script:DefaultSandboxName = "sandbox"
+$script:DefaultTestPage = 150002;
+$script:DefaultTestSuite = 'DEFAULT'
+$script:DefaultErrorActionPreference = 'Stop'
+
+$script:DefaultTcpKeepActive = [timespan]::FromMinutes(2);
+$script:DefaultTransactionTimeout = [timespan]::FromMinutes(30);
+$script:DefaultCulture = "en-US";
+
+Export-ModuleMember -Function Run-BCPTTestsInternal,Get-NoOfIterations
diff --git a/Actions/.Modules/TestRunner/Internal/ClientContext.ps1 b/Actions/.Modules/TestRunner/Internal/ClientContext.ps1
new file mode 100644
index 0000000000..17a5e3877d
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/ClientContext.ps1
@@ -0,0 +1,411 @@
+#requires -Version 5.0
+using namespace Microsoft.Dynamics.Framework.UI.Client
+using namespace Microsoft.Dynamics.Framework.UI.Client.Interactions
+
+class ClientContext {
+
+ $events = @()
+ $clientSession = $null
+ $culture = ""
+ $caughtForm = $null
+ $IgnoreErrors = $true
+
+ ClientContext([string] $serviceUrl, [bool] $disableSSL, [pscredential] $credential, [timespan] $interactionTimeout, [string] $culture)
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::UserNamePassword), (New-Object System.Net.NetworkCredential -ArgumentList $credential.UserName, $credential.Password), $disableSSL, $interactionTimeout, $culture)
+ }
+
+ ClientContext([string] $serviceUrl, [pscredential] $credential, [bool] $disableSSL, [timespan] $interactionTimeout, [string] $culture)
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::UserNamePassword), (New-Object System.Net.NetworkCredential -ArgumentList $credential.UserName, $credential.Password), $disableSSL, $interactionTimeout, $culture)
+ }
+
+ ClientContext([string] $serviceUrl, [pscredential] $credential)
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::UserNamePassword), (New-Object System.Net.NetworkCredential -ArgumentList $credential.UserName, $credential.Password), $false, ([timespan]::FromHours(12)), 'en-US')
+ }
+
+ ClientContext([string] $serviceUrl, [bool] $disableSSL, [timespan] $interactionTimeout, [string] $culture)
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::Windows), $null, $disableSSL, $interactionTimeout, $culture)
+ }
+
+ ClientContext([string] $serviceUrl, [timespan] $interactionTimeout, [string] $culture)
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::Windows), $null, $false, $interactionTimeout, $culture)
+ }
+
+ ClientContext([string] $serviceUrl)
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::Windows), $null, $false, ([timespan]::FromHours(12)), 'en-US')
+ }
+
+ ClientContext([string] $serviceUrl, [Microsoft.Dynamics.Framework.UI.Client.tokenCredential] $tokenCredential, [bool] $disableSSL, [timespan] $interactionTimeout = ([timespan]::FromHours(12)), [string] $culture = 'en-US')
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::AzureActiveDirectory), $tokenCredential, $disableSSL, $interactionTimeout, $culture)
+ }
+
+ ClientContext([string] $serviceUrl, [Microsoft.Dynamics.Framework.UI.Client.tokenCredential] $tokenCredential, [timespan] $interactionTimeout = ([timespan]::FromHours(12)), [string] $culture = 'en-US')
+ {
+ $this.Initialize($serviceUrl, ([AuthenticationScheme]::AzureActiveDirectory), $tokenCredential, $false, $interactionTimeout, $culture)
+ }
+
+ Initialize([string] $serviceUrl, [AuthenticationScheme] $authenticationScheme, [System.Net.ICredentials] $credential, [bool] $disableSSL, [timespan] $interactionTimeout, [string] $culture) {
+
+ $clientServicesUrl = $serviceUrl
+ if(-not $clientServicesUrl.Contains("/cs/"))
+ {
+ if($clientServicesUrl.Contains("?"))
+ {
+ $clientServicesUrl = $clientServicesUrl.Insert($clientServicesUrl.LastIndexOf("?"),"cs/")
+ }
+ else
+ {
+ $clientServicesUrl = $clientServicesUrl.TrimEnd("/")
+ $clientServicesUrl = $clientServicesUrl + "/cs/"
+ }
+ }
+ $addressUri = New-Object System.Uri -ArgumentList $clientServicesUrl
+ $jsonClient = New-Object JsonHttpClient -ArgumentList $addressUri, $credential, $authenticationScheme
+ $httpClient = ($jsonClient.GetType().GetField("httpClient", [Reflection.BindingFlags]::NonPublic -bor [Reflection.BindingFlags]::Instance)).GetValue($jsonClient)
+ $httpClient.Timeout = $interactionTimeout
+
+ # On PS7/.NET Core, ServicePointManager.ServerCertificateValidationCallback does not
+ # affect HttpClient instances. We must set the callback on the HttpClientHandler directly.
+ # The handler is accessed via reflection since JsonHttpClient creates it internally.
+ if ($disableSSL -and $global:PSVersionTable.PSVersion.Major -ge 6) {
+ $this.DisableSSLOnHttpClient($httpClient)
+ }
+
+ $this.clientSession = New-Object ClientSession -ArgumentList $jsonClient, (New-Object NonDispatcher), (New-Object 'TimerFactory[TaskTimer]')
+ $this.culture = $culture
+ $this.OpenSession()
+ }
+
+ DisableSSLOnHttpClient($httpClient) {
+ # Walk the handler chain to find all HttpClientHandler instances and disable SSL on each.
+ # JsonHttpClient wraps handlers in a DelegatingHandler chain (e.g. BasicAuthHandler for NavUserPassword).
+ $handlerField = [System.Net.Http.HttpMessageInvoker].GetField("_handler", [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)
+ $handler = $handlerField.GetValue($httpClient)
+ $this.DisableSSLOnHandler($handler)
+ }
+
+ DisableSSLOnHandler($handler) {
+ if ($handler -is [System.Net.Http.HttpClientHandler]) {
+ $handler.ServerCertificateCustomValidationCallback = [System.Net.Http.HttpClientHandler]::DangerousAcceptAnyServerCertificateValidator
+ }
+ # Walk DelegatingHandler chain to find nested HttpClientHandlers
+ if ($handler -is [System.Net.Http.DelegatingHandler]) {
+ $innerHandler = $handler.InnerHandler
+ if ($innerHandler) {
+ $this.DisableSSLOnHandler($innerHandler)
+ }
+ }
+ }
+
+ OpenSession() {
+ $clientSessionParameters = New-Object ClientSessionParameters
+ $clientSessionParameters.CultureId = $this.culture
+ $clientSessionParameters.UICultureId = $this.culture
+ $clientSessionParameters.AdditionalSettings.Add("IncludeControlIdentifier", $true)
+
+ $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName MessageToShow -Action {
+ Write-Host -ForegroundColor Yellow "Message : $($EventArgs.Message)"
+ })
+ $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName CommunicationError -Action {
+ HandleError -ErrorMessage "CommunicationError : $($EventArgs.Exception.Message)"
+ })
+ $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName UnhandledException -Action {
+ HandleError -ErrorMessage "UnhandledException : $($EventArgs.Exception.Message)"
+ })
+ $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName InvalidCredentialsError -Action {
+ HandleError -ErrorMessage "InvalidCredentialsError"
+ })
+ $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName UriToShow -Action {
+ Write-Host -ForegroundColor Yellow "UriToShow : $($EventArgs.UriToShow)"
+ })
+ $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName DialogToShow -Action {
+ $form = $EventArgs.DialogToShow
+ if ( $form.ControlIdentifier -eq "00000000-0000-0000-0800-0000836bd2d2" ) {
+ $errorControl = $form.ContainedControls | Where-Object { $_ -is [ClientStaticStringControl] } | Select-Object -First 1
+ HandleError -ErrorMessage "ERROR: $($errorControl.StringValue)"
+ } elseif ( $form.ControlIdentifier -eq "00000000-0000-0000-0300-0000836bd2d2" ) {
+ $errorControl = $form.ContainedControls | Where-Object { $_ -is [ClientStaticStringControl] } | Select-Object -First 1
+ Write-Host -ForegroundColor Yellow "WARNING: $($errorControl.StringValue)"
+ } elseif ( $form.MappingHint -eq "InfoDialog" ) {
+ $errorControl = $form.ContainedControls | Where-Object { $_ -is [ClientStaticStringControl] } | Select-Object -First 1
+ Write-Host -ForegroundColor Yellow "INFO: $($errorControl.StringValue)"
+ }
+ })
+
+ $this.clientSession.OpenSessionAsync($clientSessionParameters)
+ $this.AwaitState([ClientSessionState]::Ready)
+ }
+
+ SetIgnoreServerErrors([bool] $IgnoreServerErrors) {
+ $this.IgnoreErrors = $IgnoreServerErrors
+ }
+
+ HandleError([string] $ErrorMessage) {
+ Remove-ClientSession
+ if ($this.IgnoreErrors) {
+ Write-Host -ForegroundColor Red $ErrorMessage
+ } else {
+ throw $ErrorMessage
+ }
+ }
+
+ Dispose() {
+ $this.events | % { Unregister-Event $_.Name }
+ $this.events = @()
+
+ try {
+ if ($this.clientSession -and ($this.clientSession.State -ne ([ClientSessionState]::Closed))) {
+ $this.clientSession.CloseSessionAsync()
+ $this.AwaitState([ClientSessionState]::Closed)
+ }
+ }
+ catch {
+ }
+ }
+
+ AwaitState([ClientSessionState] $state) {
+ While ($this.clientSession.State -ne $state) {
+ Start-Sleep -Milliseconds 100
+ if ($this.clientSession.State -eq [ClientSessionState]::InError) {
+ if ($this.clientSession.LastException) {
+ Write-Host -ForegroundColor Red "ClientSession in Error. LastException: $($this.clientSession.LastException.Message)"
+ Write-Host -ForegroundColor Red "StackTrace: $($this.clientSession.LastException.StackTrace)"
+ }
+ throw "ClientSession in Error"
+ }
+ if ($this.clientSession.State -eq [ClientSessionState]::TimedOut) {
+ throw "ClientSession timed out"
+ }
+ if ($this.clientSession.State -eq [ClientSessionState]::Uninitialized) {
+ throw "ClientSession is Uninitialized"
+ }
+ }
+ }
+
+ InvokeInteraction([ClientInteraction] $interaction) {
+ $this.clientSession.InvokeInteractionAsync($interaction)
+ $this.AwaitState([ClientSessionState]::Ready)
+ }
+
+ [ClientLogicalForm] InvokeInteractionAndCatchForm([ClientInteraction] $interaction) {
+ $Global:PsTestRunnerCaughtForm = $null
+ $formToShowEvent = Register-ObjectEvent -InputObject $this.clientSession -EventName FormToShow -Action {
+ $Global:PsTestRunnerCaughtForm = $EventArgs.FormToShow
+ }
+ try {
+ $this.InvokeInteraction($interaction)
+ if (!($Global:PsTestRunnerCaughtForm)) {
+ $this.CloseAllWarningOrInfoForms()
+ }
+ }
+ catch
+ {
+ $ErrorMessage = $_.Exception.Message
+ $FailedItem = $_.Exception.ItemName
+ Write-Host "Error:" $ErrorMessage "Item: " $FailedItem
+ }
+ finally {
+ Unregister-Event -SourceIdentifier $formToShowEvent.Name
+ }
+ $form = $Global:PsTestRunnerCaughtForm
+ Remove-Variable PsTestRunnerCaughtForm -Scope Global
+ return $form
+ }
+
+ [ClientLogicalForm] OpenForm([int] $page) {
+ $interaction = New-Object OpenFormInteraction
+ $interaction.Page = $page
+ return $this.InvokeInteractionAndCatchForm($interaction)
+ }
+
+ CloseForm([ClientLogicalForm] $form) {
+ $this.InvokeInteraction((New-Object CloseFormInteraction -ArgumentList $form))
+ }
+
+ [ClientLogicalForm[]]GetAllForms() {
+ $forms = @()
+ foreach ($form in $this.clientSession.OpenedForms) {
+ $forms += $form
+ }
+ return $forms
+ }
+
+ [string]GetErrorFromErrorForm() {
+ $errorText = ""
+ $this.GetAllForms() | % {
+ $form = $_
+ if ( $form.ControlIdentifier -eq "00000000-0000-0000-0800-0000836bd2d2" ) {
+ $form.ContainedControls | Where-Object { $_ -is [ClientStaticStringControl] } | % {
+ $errorText = $_.StringValue
+ }
+ }
+ }
+ return $errorText
+ }
+
+ [string]GetWarningFromWarningForm() {
+ $warningText = ""
+ $this.GetAllForms() | % {
+ $form = $_
+ if ( $form.ControlIdentifier -eq "00000000-0000-0000-0300-0000836bd2d2" ) {
+ $form.ContainedControls | Where-Object { $_ -is [ClientStaticStringControl] } | % {
+ $warningText = $_.StringValue
+ }
+ }
+ }
+ return $warningText
+ }
+
+ [Hashtable]GetFormInfo([ClientLogicalForm] $form) {
+
+ function Dump-RowControl {
+ Param(
+ [ClientLogicalControl] $control
+ )
+ @{
+ "$($control.Name)" = $control.ObjectValue
+ }
+ }
+
+ function Dump-Control {
+ Param(
+ [ClientLogicalControl] $control,
+ [int] $indent
+ )
+
+ $output = @{
+ "name" = $control.Name
+ "type" = $control.GetType().Name
+ }
+ if ($control -is [ClientGroupControl]) {
+ $output += @{
+ "caption" = $control.Caption
+ "mappingHint" = $control.MappingHint
+ }
+ } elseif ($control -is [ClientStaticStringControl]) {
+ $output += @{
+ "value" = $control.StringValue
+ }
+ } elseif ($control -is [ClientInt32Control]) {
+ $output += @{
+ "value" = $control.ObjectValue
+ }
+ } elseif ($control -is [ClientStringControl]) {
+ $output += @{
+ "value" = $control.stringValue
+ }
+ } elseif ($control -is [ClientActionControl]) {
+ $output += @{
+ "caption" = $control.Caption
+ }
+ } elseif ($control -is [ClientFilterLogicalControl]) {
+ } elseif ($control -is [ClientRepeaterControl]) {
+ $output += @{
+ "$($control.name)" = @()
+ }
+ $index = 0
+ while ($true) {
+ if ($index -ge ($control.Offset + $control.DefaultViewport.Count)) {
+ $this.ScrollRepeater($control, 1)
+ }
+ $rowIndex = $index - $control.Offset
+ if ($rowIndex -ge $control.DefaultViewport.Count) {
+ break
+ }
+ $row = $control.DefaultViewport[$rowIndex]
+ $rowoutput = @{}
+ $row.Children | % { $rowoutput += Dump-RowControl -control $_ }
+ $output[$control.name] += $rowoutput
+ $index++
+ }
+ }
+ else {
+ }
+ $output
+ }
+
+ return @{
+ "title" = "$($form.Name) $($form.Caption)"
+ "controls" = $form.Children | % { Dump-Control -output $output -control $_ -indent 1 }
+ }
+ }
+
+ CloseAllForms() {
+ $this.GetAllForms() | % { $this.CloseForm($_) }
+ }
+
+ CloseAllErrorForms() {
+ $this.GetAllForms() | % {
+ if ($_.ControlIdentifier -eq "00000000-0000-0000-0800-0000836bd2d2") {
+ $this.CloseForm($_)
+ }
+ }
+ }
+
+ CloseAllWarningOrInfoForms() {
+ while ($this.HasWarningOrInfoForms()) {
+ $form = $this.clientSession.TopMostInteractiveForm;
+ $this.CloseForm($form)
+ $this.AwaitState([ClientSessionState]::Ready)
+ }
+ }
+
+ [bool]HasWarningOrInfoForms() {
+ $form = $this.clientSession.TopMostInteractiveForm;
+ if($form -eq $null) {
+ return $false
+ }
+
+ if ($form.ControlIdentifier -eq "00000000-0000-0000-0300-0000836bd2d2" -or $form.MappingHint -eq "InfoDialog") {
+ return $true
+ }
+ return $false
+ }
+
+ [ClientLogicalControl]GetControlByCaption([ClientLogicalControl] $control, [string] $caption) {
+ return $control.ContainedControls | Where-Object { $_.Caption.Replace("&","") -eq $caption } | Select-Object -First 1
+ }
+
+ [ClientLogicalControl]GetControlByName([ClientLogicalControl] $control, [string] $name) {
+ return $control.ContainedControls | Where-Object { $_.Name -eq $name } | Select-Object -First 1
+ }
+
+ [ClientLogicalControl]GetControlByType([ClientLogicalControl] $control, [Type] $type) {
+ return $control.ContainedControls | Where-Object { $_ -is $type } | Select-Object -First 1
+ }
+
+ SaveValue([ClientLogicalControl] $control, [string] $newValue) {
+ $this.InvokeInteraction((New-Object SaveValueInteraction -ArgumentList $control, $newValue))
+ }
+
+ ScrollRepeater([ClientRepeaterControl] $repeater, [int] $by) {
+ $this.InvokeInteraction((New-Object ScrollRepeaterInteraction -ArgumentList $repeater, $by))
+ }
+
+ ActivateControl([ClientLogicalControl] $control) {
+ $this.InvokeInteraction((New-Object ActivateControlInteraction -ArgumentList $control))
+ }
+
+ [ClientActionControl]GetActionByCaption([ClientLogicalControl] $control, [string] $caption) {
+ return $control.ContainedControls | Where-Object { ($_ -is [ClientActionControl]) -and ($_.Caption.Replace("&","") -eq $caption) } | Select-Object -First 1
+ }
+
+ [ClientActionControl]GetActionByName([ClientLogicalControl] $control, [string] $name) {
+ return $control.ContainedControls | Where-Object { ($_ -is [ClientActionControl]) -and ($_.Name -eq $name) } | Select-Object -First 1
+ }
+
+ InvokeAction([ClientActionControl] $action) {
+ $this.InvokeInteraction((New-Object InvokeActionInteraction -ArgumentList $action))
+ $this.CloseAllWarningOrInfoForms()
+ }
+
+ [ClientLogicalForm]InvokeActionAndCatchForm([ClientActionControl] $action) {
+ return $this.InvokeInteractionAndCatchForm((New-Object InvokeActionInteraction -ArgumentList $action))
+ }
+}
diff --git a/Actions/.Modules/TestRunner/Internal/ClientSessionManager.psm1 b/Actions/.Modules/TestRunner/Internal/ClientSessionManager.psm1
new file mode 100644
index 0000000000..1cb4f42717
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/ClientSessionManager.psm1
@@ -0,0 +1,132 @@
+# Client session management and SSL handling.
+# Extracted from ALTestRunnerInternal.psm1 for clarity and PS7 SSL support.
+
+. "$PSScriptRoot\Constants.ps1"
+
+function Open-ClientSessionWithWait
+(
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AuthorizationType = $script:DefaultAuthorizationType,
+ [switch] $DisableSSLVerification,
+ [string] $ServiceUrl,
+ [pscredential] $Credential,
+ [int] $ClientSessionTimeout = 20,
+ [timespan] $TransactionTimeout = $script:DefaultTransactionTimeout,
+ [string] $Culture = $script:DefaultCulture
+)
+{
+ $lastErrorMessage = ""
+ while(($ClientSessionTimeout -gt 0))
+ {
+ try
+ {
+ $clientContext = Open-ClientSession -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AuthorizationType -Credential $Credential -ServiceUrl $ServiceUrl -TransactionTimeout $TransactionTimeout -Culture $Culture
+ return $clientContext
+ }
+ catch
+ {
+ Start-Sleep -Seconds 1
+ $ClientSessionTimeout--
+ $lastErrorMessage = $_.Exception.Message
+ }
+ }
+
+ throw "Could not open the client session. Check if the web server is running and you can log in. Last error: $lastErrorMessage"
+}
+
+function Open-ClientSession
+(
+ [switch] $DisableSSLVerification,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AuthorizationType,
+ [Parameter(Mandatory=$false)]
+ [pscredential] $Credential,
+ [Parameter(Mandatory=$true)]
+ [string] $ServiceUrl,
+ [string] $Culture = $script:DefaultCulture,
+ [timespan] $TransactionTimeout = $script:DefaultTransactionTimeout,
+ [timespan] $TcpKeepActive = $script:DefaultTcpKeepActive
+)
+{
+ if ($PSVersionTable.PSVersion.Major -lt 6) {
+ # PS5/.NET Framework: ServicePointManager is process-global and works
+ [System.Net.ServicePointManager]::SetTcpKeepAlive($true, [int]$TcpKeepActive.TotalMilliseconds, [int]$TcpKeepActive.TotalMilliseconds)
+ }
+
+ if($DisableSSLVerification)
+ {
+ Disable-SslVerification
+ }
+
+ switch ($AuthorizationType)
+ {
+ "Windows"
+ {
+ $clientContext = [ClientContext]::new($ServiceUrl, $DisableSSLVerification, $TransactionTimeout, $Culture)
+ break;
+ }
+ "NavUserPassword"
+ {
+ if ($Credential -eq $null -or $Credential -eq [System.Management.Automation.PSCredential]::Empty)
+ {
+ throw "You need to specify credentials if using NavUserPassword authentication"
+ }
+
+ $clientContext = [ClientContext]::new($ServiceUrl, $Credential, $DisableSSLVerification, $TransactionTimeout, $Culture)
+ break;
+ }
+ "AAD"
+ {
+ $AadTokenProvider = $global:AadTokenProvider
+ if ($AadTokenProvider -eq $null)
+ {
+ throw "You need to specify the AadTokenProvider for obtaining the token if using AAD authentication"
+ }
+
+ $token = $AadTokenProvider.GetToken($Credential)
+ $tokenCredential = [Microsoft.Dynamics.Framework.UI.Client.TokenCredential]::new($token)
+ $clientContext = [ClientContext]::new($ServiceUrl, $tokenCredential, $DisableSSLVerification, $TransactionTimeout, $Culture)
+ }
+ }
+
+ return $clientContext;
+}
+
+function Disable-SslVerification
+{
+ # On PS7/.NET Core, ServicePointManager.ServerCertificateValidationCallback does not
+ # affect HttpClient. The per-handler approach in ClientContext handles it instead.
+ # This function only applies to PS5/.NET Framework as a global fallback.
+ if ($PSVersionTable.PSVersion.Major -ge 6) {
+ return
+ }
+
+ if (-not ([System.Management.Automation.PSTypeName]"SslVerification").Type)
+ {
+ Add-Type -TypeDefinition @"
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+public static class SslVerification
+{
+ private static bool ValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { return true; }
+ public static void Disable() { System.Net.ServicePointManager.ServerCertificateValidationCallback = ValidationCallback; }
+ public static void Enable() { System.Net.ServicePointManager.ServerCertificateValidationCallback = null; }
+}
+"@
+ }
+ [SslVerification]::Disable()
+}
+
+function Enable-SslVerification
+{
+ if ($PSVersionTable.PSVersion.Major -ge 6) {
+ return
+ }
+
+ if (([System.Management.Automation.PSTypeName]"SslVerification").Type)
+ {
+ [SslVerification]::Enable()
+ }
+}
+
+Export-ModuleMember -Function Open-ClientSessionWithWait, Open-ClientSession, Disable-SslVerification, Enable-SslVerification
diff --git a/Actions/.Modules/TestRunner/Internal/Constants.ps1 b/Actions/.Modules/TestRunner/Internal/Constants.ps1
new file mode 100644
index 0000000000..057ebb3279
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/Constants.ps1
@@ -0,0 +1,41 @@
+# Shared constants for the CodeCoverage test runner module.
+# Dot-sourced by ALTestRunner.psm1 and Internal modules.
+
+# Line types
+$script:CodeunitLineType = '0'
+$script:FunctionLineType = '1'
+
+# Test result types
+$script:FailureTestResultType = '1'
+$script:SuccessTestResultType = '2'
+$script:SkippedTestResultType = '3'
+
+# Defaults
+$script:DefaultAuthorizationType = 'NavUserPassword'
+$script:DefaultTestSuite = 'DEFAULT'
+$script:DefaultErrorActionPreference = 'Stop'
+$script:DateTimeFormat = 's'
+
+# Network defaults
+$script:DefaultTcpKeepActive = [timespan]::FromMinutes(2)
+$script:DefaultTransactionTimeout = [timespan]::FromMinutes(10)
+$script:DefaultCulture = "en-US"
+
+# Test runner codeunit IDs
+$global:TestRunnerIsolationCodeunit = 130450
+$global:TestRunnerIsolationDisabled = 130451
+$global:DefaultTestRunner = $global:TestRunnerIsolationCodeunit
+$global:TestRunnerAppId = "23de40a6-dfe8-4f80-80db-d70f83ce8caf"
+
+# Console test tool page
+$global:DefaultTestPage = 130455
+$global:AadTokenProvider = $null
+
+# XMLport 130470 (Code Coverage Results) - exports covered/partially covered lines as CSV
+# XMLport 130007 (Code Coverage Internal) - exports all lines including not covered as XML
+$script:DefaultCodeCoverageExporter = 130470
+
+# Sentinel values
+$script:NumberOfUnexpectedFailuresBeforeAborting = 50
+$script:AllTestsExecutedResult = "All tests executed."
+$script:CCCollectedResult = "Done."
diff --git a/Actions/.Modules/TestRunner/Internal/CoverageCollector.psm1 b/Actions/.Modules/TestRunner/Internal/CoverageCollector.psm1
new file mode 100644
index 0000000000..2b3fd4e0e9
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/CoverageCollector.psm1
@@ -0,0 +1,101 @@
+# Code coverage result collection functions.
+# Extracted from ALTestRunnerInternal.psm1.
+
+. "$PSScriptRoot\Constants.ps1"
+
+$script:_ccFileIndex = 0
+
+function CollectCoverageResults {
+ param (
+ [ValidateSet('PerRun', 'PerCodeunit', 'PerTest')]
+ [string] $TrackingType,
+ [string] $OutputPath,
+ [switch] $DisableSSLVerification,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AutorizationType = $script:DefaultAuthorizationType,
+ [Parameter(Mandatory=$false)]
+ [pscredential] $Credential,
+ [Parameter(Mandatory=$true)]
+ [string] $ServiceUrl,
+ [string] $CodeCoverageFilePrefix,
+ [string] $TestPage = $global:DefaultTestPage,
+ [ValidateSet('Disabled','PerCodeunit','PerTest')]
+ [string] $ProduceCodeCoverageMap = 'Disabled'
+ )
+ try{
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl
+ $form = Open-TestForm -TestPage $TestPage -ClientContext $clientContext
+ do {
+ $clientContext.InvokeAction($clientContext.GetActionByName($form, "GetCodeCoverage"))
+
+ $CCResultControl = $clientContext.GetControlByName($form, "CCResultsCSVText")
+ $CCInfoControl = $clientContext.GetControlByName($form, "CCInfo")
+ $CCResult = $CCResultControl.StringValue
+ $CCInfo = $CCInfoControl.StringValue
+ if($CCInfo -ne $script:CCCollectedResult){
+ $CCInfo = $CCInfo -replace ",","-"
+ $script:_ccFileIndex++
+ $CCOutputFilename = $CodeCoverageFilePrefix +"_${CCInfo}_$($script:_ccFileIndex).dat"
+ Write-Host "Storing coverage results of $CCInfo in: $OutputPath\$CCOutputFilename"
+ Set-Content -Path "$OutputPath\$CCOutputFilename" -Value $CCResult
+ }
+ } while ($CCInfo -ne $script:CCCollectedResult)
+
+ if($ProduceCodeCoverageMap -ne 'Disabled') {
+ $codeCoverageMapPath = Join-Path $OutputPath "TestCoverageMap"
+ SaveCodeCoverageMap -OutputPath $codeCoverageMapPath -DisableSSLVerification:$DisableSSLVerification -AutorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl -TestPage $TestPage
+ }
+
+ $clientContext.CloseForm($form)
+ }
+ finally{
+ if($clientContext){
+ $clientContext.Dispose()
+ }
+ }
+}
+
+function SaveCodeCoverageMap {
+ param (
+ [string] $OutputPath,
+ [switch] $DisableSSLVerification,
+ [ValidateSet('Windows','NavUserPassword','AAD')]
+ [string] $AutorizationType = $script:DefaultAuthorizationType,
+ [Parameter(Mandatory=$false)]
+ [pscredential] $Credential,
+ [Parameter(Mandatory=$true)]
+ [string] $ServiceUrl,
+ [string] $TestPage = $global:DefaultTestPage
+ )
+ try{
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$DisableSSLVerification -AuthorizationType $AutorizationType -Credential $Credential -ServiceUrl $ServiceUrl
+ $form = Open-TestForm -TestPage $TestPage -ClientContext $clientContext
+
+ $clientContext.InvokeAction($clientContext.GetActionByName($form, "GetCodeCoverageMap"))
+
+ $CCResultControl = $clientContext.GetControlByName($form, "CCMapCSVText")
+ $CCMap = $CCResultControl.StringValue
+
+ if (-not (Test-Path $OutputPath))
+ {
+ New-Item $OutputPath -ItemType Directory
+ }
+
+ $codeCoverageMapFileName = Join-Path $OutputPath "TestCoverageMap.txt"
+ if (-not (Test-Path $codeCoverageMapFileName))
+ {
+ New-Item $codeCoverageMapFileName -ItemType File
+ }
+
+ Add-Content -Path $codeCoverageMapFileName -Value $CCMap
+
+ $clientContext.CloseForm($form)
+ }
+ finally{
+ if($clientContext){
+ $clientContext.Dispose()
+ }
+ }
+}
+
+Export-ModuleMember -Function CollectCoverageResults, SaveCodeCoverageMap
diff --git a/Actions/.Modules/TestRunner/Internal/Microsoft.Dynamics.Framework.UI.Client.dll b/Actions/.Modules/TestRunner/Internal/Microsoft.Dynamics.Framework.UI.Client.dll
new file mode 100644
index 0000000000..2797be724e
Binary files /dev/null and b/Actions/.Modules/TestRunner/Internal/Microsoft.Dynamics.Framework.UI.Client.dll differ
diff --git a/Actions/.Modules/TestRunner/Internal/Microsoft.Internal.AntiSSRF.dll b/Actions/.Modules/TestRunner/Internal/Microsoft.Internal.AntiSSRF.dll
new file mode 100644
index 0000000000..e5afab6d5a
Binary files /dev/null and b/Actions/.Modules/TestRunner/Internal/Microsoft.Internal.AntiSSRF.dll differ
diff --git a/Actions/.Modules/TestRunner/Internal/ModuleInit.ps1 b/Actions/.Modules/TestRunner/Internal/ModuleInit.ps1
new file mode 100644
index 0000000000..74b38a4667
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/ModuleInit.ps1
@@ -0,0 +1,206 @@
+# Module initialization: DLL loading, WCF dependency installation, type loading.
+# Extracted from ALTestRunnerInternal.psm1.
+# This script is dot-sourced once during module import.
+
+function Install-WcfDependencies {
+ <#
+ .SYNOPSIS
+ Downloads and extracts NuGet packages required for .NET Core/5+/6+ environments.
+ These are needed because Microsoft.Dynamics.Framework.UI.Client.dll depends on types
+ that are not included in modern .NET runtimes (only in full .NET Framework).
+ #>
+ param(
+ [string]$TargetPath = $PSScriptRoot
+ )
+
+ $requiredPackages = @(
+ @{ Name = "System.ServiceModel.Primitives"; Version = "6.0.0" },
+ @{ Name = "System.ServiceModel.Http"; Version = "6.0.0" },
+ @{ Name = "System.Private.ServiceModel"; Version = "4.10.3" },
+ @{ Name = "System.Threading.Tasks.Extensions"; Version = "4.5.4" },
+ @{ Name = "System.Runtime.CompilerServices.Unsafe"; Version = "6.0.0" }
+ )
+
+ $tempFolder = Join-Path ([System.IO.Path]::GetTempPath()) "BcClientPackages_$([Guid]::NewGuid().ToString().Substring(0,8))"
+
+ try {
+ foreach ($package in $requiredPackages) {
+ $packageName = $package.Name
+ $packageVersion = $package.Version
+ $expectedDll = Join-Path $TargetPath "$packageName.dll"
+
+ # Skip if already exists
+ if (Test-Path $expectedDll) {
+ Write-Host "Dependency $packageName already exists"
+ continue
+ }
+
+ Write-Host "Downloading dependency: $packageName v$packageVersion"
+
+ $nugetUrl = "https://www.nuget.org/api/v2/package/$packageName/$packageVersion"
+ $packageZip = Join-Path $tempFolder "$packageName.zip"
+ $packageExtract = Join-Path $tempFolder $packageName
+
+ if (-not (Test-Path $tempFolder)) {
+ New-Item -Path $tempFolder -ItemType Directory -Force | Out-Null
+ }
+
+ # Download the package
+ Invoke-WebRequest -Uri $nugetUrl -OutFile $packageZip -UseBasicParsing
+
+ # Extract
+ Expand-Archive -Path $packageZip -DestinationPath $packageExtract -Force
+
+ # Find the appropriate DLL (prefer net6.0, then netstandard2.0)
+ $dllPath = $null
+ $searchPaths = @(
+ (Join-Path $packageExtract "lib\net6.0\$packageName.dll"),
+ (Join-Path $packageExtract "lib\netstandard2.1\$packageName.dll"),
+ (Join-Path $packageExtract "lib\netstandard2.0\$packageName.dll"),
+ (Join-Path $packageExtract "lib\netcoreapp3.1\$packageName.dll")
+ )
+
+ foreach ($searchPath in $searchPaths) {
+ if (Test-Path $searchPath) {
+ $dllPath = $searchPath
+ break
+ }
+ }
+
+ if ($dllPath -and (Test-Path $dllPath)) {
+ Copy-Item -Path $dllPath -Destination $TargetPath -Force
+ Write-Host "Installed $packageName to $TargetPath"
+ } else {
+ Write-Warning "Could not find DLL for $packageName in package"
+ }
+ }
+ }
+ finally {
+ # Cleanup temp folder
+ if (Test-Path $tempFolder) {
+ Remove-Item -Path $tempFolder -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+}
+
+if(!$script:TypesLoaded)
+{
+ # Load order matters - dependencies must be loaded before the client DLL
+ # See: https://github.com/microsoft/navcontainerhelper/blob/main/AppHandling/PsTestFunctions.ps1
+ # Fix for issue with Microsoft.Internal.AntiSSRF.dll v2.2+: https://github.com/microsoft/navcontainerhelper/pull/4063
+
+ # Check if we're running on .NET Core/5+/6+ (PowerShell 7+) vs .NET Framework (Windows PowerShell 5.1)
+ $isNetCore = $PSVersionTable.PSVersion.Major -ge 6
+
+ # Always ensure System.Threading.Tasks.Extensions is available - AntiSSRF.dll v2.2+ needs it
+ # regardless of .NET Framework or .NET Core
+ $threadingExtDll = Join-Path $PSScriptRoot "System.Threading.Tasks.Extensions.dll"
+ if (-not (Test-Path $threadingExtDll)) {
+ Write-Host "Downloading System.Threading.Tasks.Extensions dependency..."
+ $tempFolder = Join-Path ([System.IO.Path]::GetTempPath()) "ThreadingExt_$([Guid]::NewGuid().ToString().Substring(0,8))"
+ try {
+ New-Item -Path $tempFolder -ItemType Directory -Force | Out-Null
+ $nugetUrl = "https://www.nuget.org/api/v2/package/System.Threading.Tasks.Extensions/4.5.4"
+ $packageZip = Join-Path $tempFolder "package.zip"
+ Invoke-WebRequest -Uri $nugetUrl -OutFile $packageZip -UseBasicParsing
+ Expand-Archive -Path $packageZip -DestinationPath $tempFolder -Force
+ # Use netstandard2.0 version for broadest compatibility
+ $sourceDll = Join-Path $tempFolder "lib\netstandard2.0\System.Threading.Tasks.Extensions.dll"
+ if (Test-Path $sourceDll) {
+ Copy-Item -Path $sourceDll -Destination $PSScriptRoot -Force
+ Write-Host "Installed System.Threading.Tasks.Extensions"
+ }
+ }
+ finally {
+ if (Test-Path $tempFolder) {
+ Remove-Item -Path $tempFolder -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+
+ if ($isNetCore) {
+ # On .NET Core/5+/6+, we need to install WCF packages as they're not included by default
+ Write-Host "Running on .NET Core/.NET 5+, ensuring WCF dependencies are installed..."
+ Install-WcfDependencies -TargetPath $PSScriptRoot
+
+ # Load WCF dependencies first (order matters)
+ $dependencyDlls = @(
+ "System.Runtime.CompilerServices.Unsafe.dll",
+ "System.Threading.Tasks.Extensions.dll",
+ "System.Private.ServiceModel.dll",
+ "System.ServiceModel.Primitives.dll",
+ "System.ServiceModel.Http.dll"
+ )
+ foreach ($dll in $dependencyDlls) {
+ $dllPath = Join-Path $PSScriptRoot $dll
+ if (Test-Path $dllPath) {
+ try {
+ Add-Type -Path $dllPath -ErrorAction SilentlyContinue
+ } catch {
+ # Ignore errors for already loaded assemblies
+ }
+ }
+ }
+ }
+
+ # Now load the BC client dependencies in the correct order
+ # Wrap in try/catch to get detailed LoaderExceptions if it fails
+ try {
+ Add-Type -Path "$PSScriptRoot\NewtonSoft.Json.dll"
+
+ # Microsoft.Internal.AntiSSRF.dll v2.2+ requires System.Threading.Tasks.Extensions
+ # Use AssemblyResolve event handler to help the runtime find it
+ # See: https://github.com/microsoft/navcontainerhelper/pull/4063
+ $antiSSRFdll = Join-Path $PSScriptRoot "Microsoft.Internal.AntiSSRF.dll"
+ $threadingExtDll = Join-Path $PSScriptRoot "System.Threading.Tasks.Extensions.dll"
+
+ if ((Test-Path $antiSSRFdll) -and (Test-Path $threadingExtDll)) {
+ $Threading = [Reflection.Assembly]::LoadFile($threadingExtDll)
+ $onAssemblyResolve = [System.ResolveEventHandler] {
+ param($sender, $e)
+ if ($e.Name -like "System.Threading.Tasks.Extensions, Version=*, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51") {
+ return $Threading
+ }
+ return $null
+ }
+ [System.AppDomain]::CurrentDomain.add_AssemblyResolve($onAssemblyResolve)
+ try {
+ Add-Type -Path $antiSSRFdll
+ }
+ finally {
+ [System.AppDomain]::CurrentDomain.remove_AssemblyResolve($onAssemblyResolve)
+ }
+ }
+ elseif (Test-Path $antiSSRFdll) {
+ # Fall back to simple Add-Type if threading extensions not available
+ Add-Type -Path $antiSSRFdll
+ }
+
+ Add-Type -Path "$PSScriptRoot\Microsoft.Dynamics.Framework.UI.Client.dll"
+ }
+ catch [System.Reflection.ReflectionTypeLoadException] {
+ Write-Host "ReflectionTypeLoadException occurred while loading DLLs:"
+ Write-Host "Exception Message: $($_.Exception.Message)"
+ Write-Host "LoaderExceptions:"
+ foreach ($loaderException in $_.Exception.LoaderExceptions) {
+ if ($loaderException) {
+ Write-Host " - $($loaderException.Message)"
+ }
+ }
+ throw
+ }
+ catch {
+ Write-Host "Error loading DLLs: $($_.Exception.Message)"
+ Write-Host "Exception Type: $($_.Exception.GetType().FullName)"
+ if ($_.Exception.InnerException) {
+ Write-Host "Inner Exception: $($_.Exception.InnerException.Message)"
+ }
+ throw
+ }
+
+ $clientContextScriptPath = Join-Path $PSScriptRoot "ClientContext.ps1"
+ . "$clientContextScriptPath"
+}
+
+$script:TypesLoaded = $true
+$script:ActiveDirectoryDllsLoaded = $false
diff --git a/Actions/.Modules/TestRunner/Internal/Newtonsoft.Json.dll b/Actions/.Modules/TestRunner/Internal/Newtonsoft.Json.dll
new file mode 100644
index 0000000000..cb187aa1e4
Binary files /dev/null and b/Actions/.Modules/TestRunner/Internal/Newtonsoft.Json.dll differ
diff --git a/Actions/.Modules/TestRunner/Internal/System.ServiceModel.Primitives.dll b/Actions/.Modules/TestRunner/Internal/System.ServiceModel.Primitives.dll
new file mode 100644
index 0000000000..14aed6f97f
Binary files /dev/null and b/Actions/.Modules/TestRunner/Internal/System.ServiceModel.Primitives.dll differ
diff --git a/Actions/.Modules/TestRunner/Internal/TestFormHelpers.psm1 b/Actions/.Modules/TestRunner/Internal/TestFormHelpers.psm1
new file mode 100644
index 0000000000..f1d5df3d1b
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/TestFormHelpers.psm1
@@ -0,0 +1,237 @@
+# Test form UI control helpers.
+# These functions interact with the BC test tool page via the ClientContext.
+
+. "$PSScriptRoot\Constants.ps1"
+
+function Open-TestForm(
+ [int] $TestPage = $global:DefaultTestPage,
+ [ClientContext] $ClientContext
+)
+{
+ $form = $ClientContext.OpenForm($TestPage)
+ if (!$form)
+ {
+ throw "Cannot open page $TestPage. Verify if the test tool and test objects are imported and can be opened manually."
+ }
+
+ return $form;
+}
+
+function Set-TestCodeunits
+(
+ [string] $TestCodeunitsFilter,
+ [ClientContext] $ClientContext,
+ $Form
+)
+{
+ if(!$TestCodeunitsFilter)
+ {
+ return
+ }
+
+ $testCodeunitRangeFilterControl = $ClientContext.GetControlByName($Form, "TestCodeunitRangeFilter")
+ $ClientContext.SaveValue($testCodeunitRangeFilterControl, $TestCodeunitsFilter)
+}
+
+function Set-TestRunner
+(
+ [int] $TestRunnerId,
+ [ClientContext] $ClientContext,
+ $Form
+)
+{
+ if(!$TestRunnerId)
+ {
+ return
+ }
+
+ $testRunnerCodeunitIdControl = $ClientContext.GetControlByName($Form, "TestRunnerCodeunitId")
+ $ClientContext.SaveValue($testRunnerCodeunitIdControl, $TestRunnerId)
+}
+
+function Clear-TestResults
+(
+ [ClientContext] $ClientContext,
+ $Form
+)
+{
+ $ClientContext.InvokeAction($ClientContext.GetActionByName($Form, "ClearTestResults"))
+}
+
+function Set-ExtensionId
+(
+ [string] $ExtensionId,
+ [ClientContext] $ClientContext,
+ $Form
+)
+{
+ if(!$ExtensionId)
+ {
+ return
+ }
+
+ $extensionIdControl = $ClientContext.GetControlByName($Form, "ExtensionId")
+ $ClientContext.SaveValue($extensionIdControl, $ExtensionId)
+}
+
+function Set-RequiredTestIsolation {
+ param (
+ [ValidateSet('None','Disabled','Codeunit','Function')]
+ [string] $RequiredTestIsolation = "None",
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ $TestIsolationValues = @{
+ None = 0
+ Disabled = 1
+ Codeunit = 2
+ Function = 3
+ }
+ $testIsolationControl = $ClientContext.GetControlByName($Form, "RequiredTestIsolation")
+ $ClientContext.SaveValue($testIsolationControl, $TestIsolationValues[$RequiredTestIsolation])
+}
+
+function Set-TestType {
+ param (
+ [ValidateSet("UnitTest","IntegrationTest","Uncategorized")]
+ [string] $TestType,
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ $TypeValues = @{
+ UnitTest = 1
+ IntegrationTest = 2
+ Uncategorized = 3
+ }
+ $testTypeControl = $ClientContext.GetControlByName($Form, "TestType")
+ $ClientContext.SaveValue($testTypeControl, $TypeValues[$TestType])
+}
+
+function Set-TestSuite
+(
+ [string] $TestSuite = $script:DefaultTestSuite,
+ [ClientContext] $ClientContext,
+ $Form
+)
+{
+ $suiteControl = $ClientContext.GetControlByName($Form, "CurrentSuiteName")
+ $ClientContext.SaveValue($suiteControl, $TestSuite)
+}
+
+function Set-TestProcedures
+{
+ param (
+ [string] $Filter,
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ $Control = $ClientContext.GetControlByName($Form, "TestProcedureRangeFilter")
+ $ClientContext.SaveValue($Control, $Filter)
+}
+
+function Set-RunFalseOnDisabledTests
+(
+ [ClientContext] $ClientContext,
+ [array] $DisabledTests,
+ $Form
+)
+{
+ if(!$DisabledTests)
+ {
+ return
+ }
+
+ foreach($disabledTestMethod in $DisabledTests)
+ {
+ $testKey = $disabledTestMethod.codeunitName + "," + $disabledTestMethod.method
+ $removeTestMethodControl = $ClientContext.GetControlByName($Form, "DisableTestMethod")
+ $ClientContext.SaveValue($removeTestMethodControl, $testKey)
+ }
+}
+
+function Set-StabilityRun
+(
+ [bool] $StabilityRun,
+ [ClientContext] $ClientContext,
+ $Form
+)
+{
+ $stabilityRunControl = $ClientContext.GetControlByName($Form, "StabilityRun")
+ $ClientContext.SaveValue($stabilityRunControl, $StabilityRun)
+}
+
+function Set-CCTrackingType
+{
+ param (
+ [ValidateSet('Disabled', 'PerRun', 'PerCodeunit', 'PerTest')]
+ [string] $Value,
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ $TypeValues = @{
+ Disabled = 0
+ PerRun = 1
+ PerCodeunit=2
+ PerTest=3
+ }
+ $suiteControl = $ClientContext.GetControlByName($Form, "CCTrackingType")
+ $ClientContext.SaveValue($suiteControl, $TypeValues[$Value])
+}
+
+function Set-CCTrackAllSessions
+{
+ param (
+ [switch] $Value,
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ if($Value){
+ $suiteControl = $ClientContext.GetControlByName($Form, "CCTrackAllSessions");
+ $ClientContext.SaveValue($suiteControl, $Value)
+ }
+}
+
+function Set-CCExporterID
+{
+ param (
+ [string] $Value,
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ if($Value){
+ $suiteControl = $ClientContext.GetControlByName($Form, "CCExporterID");
+ $ClientContext.SaveValue($suiteControl, $Value)
+ }
+}
+
+function Set-CCProduceCodeCoverageMap
+{
+
+ param (
+ [ValidateSet('Disabled', 'PerCodeunit', 'PerTest')]
+ [string] $Value,
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ $TypeValues = @{
+ Disabled = 0
+ PerCodeunit = 1
+ PerTest=2
+ }
+ $suiteControl = $ClientContext.GetControlByName($Form, "CCMap")
+ $ClientContext.SaveValue($suiteControl, $TypeValues[$Value])
+}
+
+function Clear-CCResults
+{
+ param (
+ [ClientContext] $ClientContext,
+ $Form
+ )
+ $ClientContext.InvokeAction($ClientContext.GetActionByName($Form, "ClearCodeCoverage"))
+}
+
+Export-ModuleMember -Function Open-TestForm, Set-TestCodeunits, Set-TestRunner, Clear-TestResults, `
+ Set-ExtensionId, Set-RequiredTestIsolation, Set-TestType, Set-TestSuite, Set-TestProcedures, `
+ Set-RunFalseOnDisabledTests, Set-StabilityRun, Set-CCTrackingType, Set-CCTrackAllSessions, `
+ Set-CCExporterID, Set-CCProduceCodeCoverageMap, Clear-CCResults
diff --git a/Actions/.Modules/TestRunner/Internal/TestRunnerInternalForAIT.psm1 b/Actions/.Modules/TestRunner/Internal/TestRunnerInternalForAIT.psm1
new file mode 100644
index 0000000000..f98dcb5a85
--- /dev/null
+++ b/Actions/.Modules/TestRunner/Internal/TestRunnerInternalForAIT.psm1
@@ -0,0 +1,813 @@
+<#
+ Function to initialize the test runner with the necessary parameters. The parameters are mainly used to open the connection to the client session. The parameters are saved as script variables for further use.
+ This functions needs to be called before any other functions in this module.
+#>
+function Initialize-TestRunner(
+ [ValidateSet("PROD", "OnPrem")]
+ [string] $Environment,
+ [ValidateSet("AAD", "Windows", "NavUserPassword")]
+ [string] $AuthorizationType,
+ [switch] $DisableSSLVerification,
+ [pscredential] $Credential,
+ [pscredential] $Token,
+ [string] $EnvironmentName,
+ [string] $ServiceUrl,
+ [string] $ClientId,
+ [string] $RedirectUri,
+ [string] $AadTenantId,
+ [string] $APIHost,
+ [string] $ServerInstance,
+ [Nullable[guid]] $CompanyId,
+ [int] $ClientSessionTimeout = $script:DefaultClientSessionTimeout,
+ [int] $TransactionTimeout = $script:DefaultTransactionTimeout.TotalMinutes,
+ [string] $Culture = $script:DefaultCulture
+) {
+ Write-HostWithTimestamp "Initializing the AI Test Runner module..."
+
+ $script:DisableSSLVerification = $DisableSSLVerification
+
+ # Reset the script variables
+ $script:Environment = ''
+ $script:AuthorizationType = ''
+ $script:EnvironmentName = ''
+ $script:ClientId = ''
+ $script:ServiceUrl = ''
+ $script:APIHost = ''
+
+ $script:CompanyId = $CompanyId
+
+
+ # If -Environment is not specified then pick the default
+ if ($Environment -eq '') {
+ Write-Host "-Environment parameter is not provided. Defaulting to $script:DefaultEnvironment"
+
+ $script:Environment = $script:DefaultEnvironment
+ }
+ else {
+ $script:Environment = $Environment
+ }
+
+ # Depending on the Environment make sure necessary parameters are also specified
+ switch ($script:Environment) {
+ # PROD works only with AAD authorizatin type and OnPrem works on all 3 Authorization types
+ 'PROD' {
+ if ($AuthorizationType -ne 'AAD') {
+ throw "Only Authorization type 'AAD' can work in -Environment $Environment."
+ }
+ else {
+ if ($AuthorizationType -eq '') {
+ Write-Host "-AuthorizationType parameter is not provided. Defaulting to $script:DefaultAuthorizationType"
+ $script:AuthorizationType = $script:DefaultAuthorizationType
+ }
+ else {
+ $script:AuthorizationType = $AuthorizationType
+ }
+ }
+
+ if ($EnvironmentName -eq '') {
+ Write-Host "-EnvironmentName parameter is not provided. Defaulting to $script:DefaultEnvironmentName"
+ $script:EnvironmentName = $script:DefaultEnvironmentName
+ }
+ else {
+ $script:EnvironmentName = $EnvironmentName
+ }
+
+ if ($ClientId -eq '') {
+ Write-Error -Category InvalidArgument -Message 'ClientId is mandatory in the PROD environment'
+ }
+ else {
+ $script:ClientId = $ClientId
+ }
+ if ($RedirectUri -eq '') {
+ Write-Host "-RedirectUri parameter is not provided. Defaulting to $script:DefaultRedirectUri"
+ $script:RedirectUri = $script:DefaultRedirectUri
+ }
+ else {
+ $script:RedirectUri = $RedirectUri
+ }
+ if ($AadTenantId -eq '') {
+ Write-Error -Category InvalidArgument -Message 'AadTenantId is mandatory in the PROD environment'
+ }
+ else {
+ $script:AadTenantId = $AadTenantId
+ }
+
+ $script:AadTokenProvider = [AadTokenProvider]::new($script:AadTenantId, $script:ClientId, $script:RedirectUri)
+
+ if (!$script:AadTokenProvider) {
+ $example = @'
+
+ $UserName = 'USERNAME'
+ $Password = 'PASSWORD'
+ $securePassword = ConvertTo-SecureString $Password -AsPlainText -Force
+ $UserCredential = New-Object System.Management.Automation.PSCredential($UserName, $securePassword)
+
+ $script:AADTenantID = 'Guid like - 212415e1-054e-401b-ad32-3cdfa301b1d2'
+ $script:ClientId = 'Guid like 0a576aea-5e61-4153-8639-4c5fd5e7d1f6'
+ $script:RedirectUri = 'https://login.microsoftonline.com/common/oauth2/nativeclient'
+ $global:AadTokenProvider = [AadTokenProvider]::new($script:AADTenantID, $script:ClientId, $scrit:RedirectUri)
+'@
+ throw 'You need to initialize and set the $global:AadTokenProvider. Example: ' + $example
+ }
+ $tenantDomain = ''
+ if ($Token -ne $null) {
+ $tenantDomain = ($Token.UserName.Substring($Token.UserName.IndexOf('@') + 1))
+ }
+ else {
+ $tenantDomain = ($Credential.UserName.Substring($Credential.UserName.IndexOf('@') + 1))
+ }
+ $script:discoveryUrl = "https://businesscentral.dynamics.com/$tenantDomain/$EnvironmentName/deployment/url"
+
+ if ($ServiceUrl -eq '') {
+ $script:ServiceUrl = Get-SaaSServiceURL
+ Write-Host "ServiceUrl is not provided. Defaulting to $script:ServiceUrl"
+ }
+ else {
+ $script:ServiceUrl = $ServiceUrl
+ }
+
+ if ($APIHost -eq '') {
+ $script:APIHost = $script:DefaultSaaSAPIHost + '/' + $script:EnvironmentName
+ Write-Host "APIHost is not provided. Defaulting to $script:APIHost"
+ }
+ else {
+ $script:APIHost = $APIHost
+ }
+ }
+ 'OnPrem' {
+ if ($AuthorizationType -eq '') {
+ Write-Host "-AuthorizationType parameter is not provided. Defaulting to $script:DefaultAuthorizationType"
+ $script:AuthorizationType = $script:DefaultAuthorizationType
+ }
+ else {
+ $script:AuthorizationType = $AuthorizationType
+ }
+
+ # OnPrem, -ServiceUrl should be provided else default is selected. On other environments, the Service Urls are built
+ if ($ServiceUrl -eq '') {
+ Write-Host "Valid ServiceUrl is not provided. Defaulting to $script:DefaultServiceUrl"
+ $script:ServiceUrl = $script:DefaultServiceUrl
+ }
+ else {
+ $script:ServiceUrl = $ServiceUrl
+ }
+
+ if ($ServerInstance -eq '') {
+ Write-Host "ServerInstance is not provided. Defaulting to $script:DefaultServerInstance"
+ $script:ServerInstance = $script:DefaultServerInstance
+ }
+ else {
+ $script:ServerInstance = $ServerInstance
+ }
+
+ if ($APIHost -eq '') {
+ $script:APIHost = $script:DefaultOnPremAPIHost + '/' + "Navision_" + $script:ServerInstance
+ Write-Host "APIHost is not provided. Defaulting to $script:APIHost"
+ }
+ else {
+ $script:APIHost = $APIHost
+ }
+
+ $script:Tenant = GetTenantFromServiceUrl -Uri $script:ServiceUrl
+ }
+ }
+
+ switch ($script:AuthorizationType) {
+ # -Credential or -Token should be specified if authorization type is AAD.
+ "AAD" {
+ if ($null -eq $Credential -and $Token -eq $null) {
+ throw "Parameter -Credential or -Token should be defined when selecting 'AAD' authorization type."
+ }
+ if ($null -ne $Credential -and $Token -ne $null) {
+ throw "Specify only one parameter -Credential or -Token when selecting 'AAD' authorization type."
+ }
+ }
+ # -Credential should be specified if authorization type is NavUserPassword.
+ "NavUserPassword" {
+ if ($null -eq $Credential) {
+ throw "Parameter -Credential should be defined when selecting 'NavUserPassword' authorization type."
+ }
+ }
+ "Windows" {
+ if ($null -ne $Credential) {
+ throw "Parameter -Credential should not be defined when selecting 'Windows' authorization type."
+ }
+ }
+ }
+
+ $script:Credential = $Credential
+ $script:ClientSessionTimeout = $ClientSessionTimeout
+ $script:TransactionTimeout = [timespan]::FromMinutes($TransactionTimeout);
+ $script:Culture = $Culture;
+
+ Test-AITestToolkitConnection
+}
+
+function GetTenantFromServiceUrl([Uri]$Uri)
+{
+ # Extract the query string part of the URI
+ $queryString = [Uri]$Uri -replace '.*\?', ''
+ $params = @{}
+
+ $queryString -split '&' | ForEach-Object {
+ if ($_ -match '([^=]+)=(.*)') {
+ $params[$matches[1]] = $matches[2]
+ }
+ }
+
+ if($params['tenant'])
+ {
+ return $params['tenant']
+ }
+
+ return 'default'
+}
+
+# Test the connection to the AI Test Toolkit
+function Test-AITestToolkitConnection {
+ try {
+ Write-HostWithTimestamp "Testing the connection to the AI Test Toolkit..."
+
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -Credential $script:Credential -ServiceUrl $script:ServiceUrl -ClientSessionTimeout $script:ClientSessionTimeout -TransactionTimeout $script:TransactionTimeout -Culture $script:Culture
+
+ Write-HostWithTimestamp "Opening the Test Form $script:TestRunnerPage"
+ $form = Open-TestForm -TestPage $script:TestRunnerPage -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -ClientContext $clientContext
+
+ # There will be an exception if the form is not opened
+ Write-HostWithTimestamp "Successfully opened the Test Form $script:TestRunnerPage" -ForegroundColor Green
+
+ $clientContext.CloseForm($form)
+
+ # Check API connection
+ $APIEndpoint = Get-DefaultAPIEndpointForAITLogEntries
+
+ Write-HostWithTimestamp "Testing the connection to the AI Test Toolkit Log Entries API: $APIEndpoint"
+ Invoke-BCRestMethod -Uri $APIEndpoint
+ Write-HostWithTimestamp "Successfully connected to the AI Test Toolkit Log Entries API" -ForegroundColor Green
+
+ $APIEndpoint = Get-DefaultAPIEndpointForAITTestMethodLines
+
+ Write-HostWithTimestamp "Testing the connection to the AI Test Toolkit Test Method Lines API: $APIEndpoint"
+ Invoke-BCRestMethod -Uri $APIEndpoint
+ Write-HostWithTimestamp "Successfully connected to the AI Test Toolkit Test Method Lines API" -ForegroundColor Green
+ }
+ catch {
+ $scriptArgs = @{
+ AuthorizationType = $script:AuthorizationType
+ ServiceUrl = $script:ServiceUrl
+ APIHost = $script:APIHost
+ ClientSessionTimeout = $script:ClientSessionTimeout
+ TransactionTimeout = $script:TransactionTimeout
+ Culture = $script:Culture
+ TestRunnerPage = $script:TestRunnerPage
+ APIEndpoint = $script:APIEndpoint
+ }
+ Write-HostWithTimestamp "Exception occurred. Script arguments: $($scriptArgs | Out-String)"
+ throw $_.Exception.Message
+ }
+ finally {
+ if ($clientContext) {
+ $clientContext.Dispose()
+ }
+ }
+}
+
+# Reset the test suite pending tests
+function Reset-AITTestSuite {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [int] $ClientSessionTimeout = $script:ClientSessionTimeout,
+ [timespan] $TransactionTimeout = $script:TransactionTimeout
+ )
+
+ try {
+ Write-HostWithTimestamp "Opening test runner page: $script:TestRunnerPage"
+
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -Credential $script:Credential -ServiceUrl $script:ServiceUrl -ClientSessionTimeout $ClientSessionTimeout -TransactionTimeout $TransactionTimeout -Culture $script:Culture
+
+ $form = Open-TestForm -TestPage $script:TestRunnerPage -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -ClientContext $clientContext
+
+ $SelectSuiteControl = $clientContext.GetControlByName($form, "AIT Suite Code")
+ $clientContext.SaveValue($SelectSuiteControl, $SuiteCode);
+
+ Write-HostWithTimestamp "Resetting the test suite $SuiteCode"
+
+ $ResetAction = $clientContext.GetActionByName($form, "ResetTestSuite")
+ $clientContext.InvokeAction($ResetAction)
+ }
+ finally {
+ if ($clientContext) {
+ $clientContext.Dispose()
+ }
+ }
+}
+
+# Invoke the AI Test Suite
+function Invoke-AITSuite
+(
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [string] $SuiteLineNo,
+ [int] $ClientSessionTimeout = $script:ClientSessionTimeout,
+ [timespan] $TransactionTimeout = $script:TransactionTimeout
+) {
+ $NoOfPendingTests = 0
+ $TestResult = @()
+ do {
+ try {
+ Write-HostWithTimestamp "Opening test runner page: $script:TestRunnerPage"
+
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -Credential $script:Credential -ServiceUrl $script:ServiceUrl -ClientSessionTimeout $ClientSessionTimeout -TransactionTimeout $TransactionTimeout -Culture $script:Culture
+
+ $form = Open-TestForm -TestPage $script:TestRunnerPage -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -ClientContext $clientContext
+
+ $SelectSuiteControl = $clientContext.GetControlByName($form, "AIT Suite Code")
+ $clientContext.SaveValue($SelectSuiteControl, $SuiteCode);
+
+ if ($SuiteLineNo -ne '') {
+ $SelectSuiteLineControl = $clientContext.GetControlByName($form, "Line No. Filter")
+ $clientContext.SaveValue($SelectSuiteLineControl, $SuiteLineNo);
+ }
+
+ Invoke-NextTest -SuiteCode $SuiteCode -ClientContext $clientContext -Form $form
+
+ # Get the results for the last run
+ $TestResult += Get-AITSuiteTestResultInternal -SuiteCode $SuiteCode -TestRunVersion 0 | ConvertFrom-Json
+
+ $NoOfPendingTests = $clientContext.GetControlByName($form, "No. of Pending Tests")
+ $NoOfPendingTests = [int] $NoOfPendingTests.StringValue
+ }
+ catch {
+ $stackTraceText = $_.Exception.StackTrace + "Script stack trace: " + $_.ScriptStackTrace
+ $testResultError = @(
+ @{
+ aitCode = $SuiteCode
+ status = "Error"
+ message = $_.Exception.Message
+ errorCallStack = $stackTraceText
+ endTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ")
+ }
+ )
+ $TestResult += $testResultError
+ }
+ finally {
+ if ($clientContext) {
+ $clientContext.Dispose()
+ }
+ }
+ }
+ until ($NoOfPendingTests -eq 0)
+ return $TestResult
+}
+
+# Run the next test in the suite
+function Invoke-NextTest {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [string] $SuiteLineNo,
+ [Parameter(Mandatory = $true)]
+ [ClientContext] $clientContext,
+ [Parameter(Mandatory = $true)]
+ [ClientLogicalForm] $form
+ )
+ $NoOfPendingTests = [int] $clientContext.GetControlByName($form, "No. of Pending Tests").StringValue
+
+ if ($NoOfPendingTests -gt 0) {
+ $StartNextAction = $clientContext.GetActionByName($form, "RunNextTest")
+
+ $message = "Starting the next test in the suite $SuiteCode, Number of pending tests: $NoOfPendingTests"
+ if ($SuiteLineNo -ne '') {
+ $message += ", Filtering the suite line number: $SuiteLineNo"
+ }
+ Write-HostWithTimestamp $message
+
+ $clientContext.InvokeAction($StartNextAction)
+ }
+ else {
+ throw "There are no tests to run. Try resetting the test suite. Number of pending tests: $NoOfPendingTests"
+ }
+
+ $NewNoOfPendingTests = [int] $clientContext.GetControlByName($form, "No. of Pending Tests").StringValue
+ if ($NewNoOfPendingTests -eq $NoOfPendingTests) {
+ throw "There was an error running the test. Number of pending tests: $NewNoOfPendingTests"
+ }
+}
+
+# Get Suite Test Result for specified version
+# If version is not provided then get the latest version
+function Get-AITSuiteTestResultInternal {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [Int32] $TestRunVersion,
+ [Int32] $CodeunitId,
+ [string] $CodeunitName,
+ [string] $TestStatus,
+ [string] $ProcedureName
+ )
+
+ if ($TestRunVersion -lt 0) {
+ throw "TestRunVersion should be 0 or greater"
+ }
+
+ $APIEndpoint = Get-DefaultAPIEndpointForAITLogEntries
+
+ # if AIT suite version is not provided then get the latest version
+ if ($TestRunVersion -eq 0) {
+ # Odata to sort by version and get all the entries with highest version
+ $APIQuery = Build-LogEntryAPIFilter -SuiteCode $SuiteCode -TestRunVersion $TestRunVersion -CodeunitId $CodeunitId -CodeunitName $CodeunitName -TestStatus $TestStatus -ProcedureName $ProcedureName
+ $AITVersionAPI = $APIEndpoint + $APIQuery + "&`$orderby=version desc&`$top=1&`$select=version"
+
+ Write-HostWithTimestamp "Getting the latest version of the AIT Suite from $AITVersionAPI"
+ $AITApiResponse = Invoke-BCRestMethod -Uri $AITVersionAPI
+
+ $TestRunVersion = $AITApiResponse.value[0].version
+ }
+
+ $APIQuery = Build-LogEntryAPIFilter -SuiteCode $SuiteCode -TestRunVersion $TestRunVersion -CodeunitId $CodeunitId -CodeunitName $CodeunitName -TestStatus $TestStatus -ProcedureName $ProcedureName
+ $AITLogEntryAPI = $APIEndpoint + $APIQuery
+
+ Write-HostWithTimestamp "Getting the AIT Suite Test Results from $AITLogEntryAPI"
+ $AITLogEntries = Invoke-BCRestMethod -Uri $AITLogEntryAPI
+
+ # Convert the response to JSON
+
+ $AITLogEntriesJson = $AITLogEntries.value | ConvertTo-Json -Depth 100 -AsArray
+ return $AITLogEntriesJson
+}
+
+
+# Get Suite Test Result for specified version
+# If version is not provided then get the latest version
+function Get-AITSuiteEvaluationResultInternal {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [Int32] $SuiteLineNo,
+ [Int32] $TestRunVersion,
+ [string] $TestState
+ )
+
+ if ($TestRunVersion -lt 0) {
+ throw "TestRunVersion should be 0 or greater"
+ }
+
+ $APIEndpoint = Get-DefaultAPIEndpointForAITEvaluationLogEntries
+
+ Write-Host "Getting the AIT Suite Evaluation Results for Suite Code: $SuiteCode, Suite Line No: $SuiteLineNo, Test Run Version: $TestRunVersion, Test State: $TestState"
+
+ # if AIT suite version is not provided then get the latest version
+ if ($TestRunVersion -eq 0) {
+ # Odata to sort by version and get all the entries with highest version
+ $APIQuery = Build-LogEvaluationEntryAPIFilter -SuiteCode $SuiteCode -TestRunVersion $TestRunVersion -SuiteLineNo $SuiteLineNo -TestState $TestState
+ $AITVersionAPI = $APIEndpoint + $APIQuery + "&`$orderby=version desc&`$top=1&`$select=version"
+
+ Write-HostWithTimestamp "Getting the latest version of the AIT Suite from $AITVersionAPI"
+ $AITApiResponse = Invoke-BCRestMethod -Uri $AITVersionAPI
+
+ $TestRunVersion = $AITApiResponse.value[0].version
+ }
+
+ $APIQuery = Build-LogEvaluationEntryAPIFilter -SuiteCode $SuiteCode -TestRunVersion $TestRunVersion -SuiteLineNo $SuiteLineNo -TestState $TestState
+ $AITEvaluationLogEntryAPI = $APIEndpoint + $APIQuery
+
+ Write-HostWithTimestamp "Getting the AIT Suite Evaluation Results from $AITEvaluationLogEntryAPI"
+ $AITEvaluationLogEntries = Invoke-BCRestMethod -Uri $AITEvaluationLogEntryAPI
+
+ # Convert the response to JSON
+ $AITEvaluationLogEntriesJson = $AITEvaluationLogEntries.value | ConvertTo-Json -Depth 100 -AsArray
+ return $AITEvaluationLogEntriesJson
+}
+
+# Get Test Method Lines for a Suite
+function Get-AITSuiteTestMethodLinesInternal {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [Int32] $TestRunVersion,
+ [Int32] $CodeunitId,
+ [string] $CodeunitName,
+ [string] $TestStatus,
+ [string] $ProcedureName
+ )
+
+ if ($TestRunVersion -lt 0) {
+ throw "TestRunVersion should be 0 or greater"
+ }
+
+ $APIEndpoint = Get-DefaultAPIEndpointForAITTestMethodLines
+
+ $APIQuery = Build-TestMethodLineAPIFilter -SuiteCode $SuiteCode -TestRunVersion $TestRunVersion -CodeunitId $CodeunitId -CodeunitName $CodeunitName -TestStatus $TestStatus
+ $AITTestMethodLinesAPI = $APIEndpoint + $APIQuery
+
+ Write-HostWithTimestamp "Getting the Test Method Lines from $AITTestMethodLinesAPI"
+ $AITTestMethodLines = Invoke-BCRestMethod -Uri $AITTestMethodLinesAPI
+
+ # Convert the response to JSON
+ $AITTestMethodLinesJson = $AITTestMethodLines.value | ConvertTo-Json
+ return $AITTestMethodLinesJson
+}
+
+function Get-DefaultAPIEndpointForAITLogEntries {
+ $CompanyPath = ''
+ if ($script:CompanyId -ne [guid]::Empty -and $null -ne $script:CompanyId) {
+ $CompanyPath = '/companies(' + $script:CompanyId + ')'
+ }
+
+ $TenantParam = ''
+ if($script:Tenant)
+ {
+ $TenantParam = "tenant=$script:Tenant&"
+ }
+ $APIEndpoint = "$script:APIHost/api/microsoft/aiTestToolkit/v2.0$CompanyPath/aitTestLogEntries?$TenantParam"
+ Write-Host "APIEndpoint: $APIEndpoint"
+
+ return $APIEndpoint
+}
+
+function Get-DefaultAPIEndpointForAITTestMethodLines {
+ $CompanyPath = ''
+ if ($script:CompanyId -ne [guid]::Empty -and $null -ne $script:CompanyId) {
+ $CompanyPath = '/companies(' + $script:CompanyId + ')'
+ }
+
+ $TenantParam = ''
+ if($script:Tenant)
+ {
+ $TenantParam = "tenant=$script:Tenant&"
+ }
+
+ $APIEndpoint = "$script:APIHost/api/microsoft/aiTestToolkit/v2.0$CompanyPath/aitTestMethodLines?$TenantParam"
+
+ return $APIEndpoint
+}
+
+function Get-DefaultAPIEndpointForAITEvaluationLogEntries {
+ $CompanyPath = ''
+ if ($script:CompanyId -ne [guid]::Empty -and $null -ne $script:CompanyId) {
+ $CompanyPath = '/companies(' + $script:CompanyId + ')'
+ }
+
+ $TenantParam = ''
+ if($script:Tenant)
+ {
+ $TenantParam = "tenant=$script:Tenant&"
+ }
+ $APIEndpoint = "$script:APIHost/api/microsoft/aiTestToolkit/v2.0$CompanyPath/aitEvaluationLogEntries?$TenantParam"
+ Write-Host "APIEndpoint: $APIEndpoint"
+
+ return $APIEndpoint
+}
+
+function Build-LogEntryAPIFilter() {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [Int32] $TestRunVersion,
+ [Int32] $CodeunitId,
+ [string] $CodeunitName,
+ [string] $TestStatus,
+ [string] $ProcedureName
+ )
+
+ $filter = "`$filter=aitCode eq '" + $SuiteCode + "'"
+ if ($TestRunVersion -ne 0) {
+ $filter += " and version eq " + $TestRunVersion
+ }
+ if ($CodeunitId -ne 0) {
+ $filter += " and codeunitId eq " + $CodeunitId
+ }
+ if ($CodeunitName -ne '') {
+ $filter += " and codeunitName eq '" + $CodeunitName + "'"
+ }
+ if ($TestStatus -ne '') {
+ $filter += " and status eq '" + $TestStatus + "'"
+ }
+ if ($ProcedureName -ne '') {
+ $filter += " and procedureName eq '" + $ProcedureName + "'"
+ }
+
+ return $filter
+}
+
+function Build-TestMethodLineAPIFilter() {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [Int32] $TestRunVersion,
+ [Int32] $CodeunitId,
+ [string] $CodeunitName,
+ [string] $TestStatus,
+ [string] $ProcedureName
+ )
+
+ $filter = "`$filter=aitCode eq '" + $SuiteCode + "'"
+ if ($TestRunVersion -ne 0) {
+ $filter += " and version eq " + $TestRunVersion
+ }
+ if ($CodeunitId -ne 0) {
+ $filter += " and codeunitId eq " + $CodeunitId
+ }
+ if ($CodeunitName -ne '') {
+ $filter += " and codeunitName eq '" + $CodeunitName + "'"
+ }
+ if ($TestStatus -ne '') {
+ $filter += " and status eq '" + $TestStatus + "'"
+ }
+
+ return $filter
+}
+
+function Build-LogEvaluationEntryAPIFilter() {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $SuiteCode,
+ [Int32] $SuiteLineNo,
+ [Int32] $TestRunVersion,
+ [string] $TestState
+ )
+
+ $filter = "`$filter=aitCode eq '" + $SuiteCode + "'"
+ if ($TestRunVersion -ne 0) {
+ $filter += " and version eq " + $TestRunVersion
+ }
+ if ($SuiteLineNo -ne 0) {
+ $filter += " and aitTestMethodLineNo eq " + $SuiteLineNo
+ }
+ if ($TestState -ne '') {
+ $filter += " and state eq '" + $TestState + "'"
+ }
+
+ return $filter
+}
+
+# Upload the input dataset needed to run the AI Test Suite
+function Set-InputDatasetInternal {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $InputDatasetFilename,
+ [Parameter(Mandatory = $true)]
+ [string] $InputDataset,
+ [int] $ClientSessionTimeout = $script:ClientSessionTimeout,
+ [timespan] $TransactionTimeout = $script:TransactionTimeout
+ )
+ try {
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -Credential $script:Credential -ServiceUrl $script:ServiceUrl -ClientSessionTimeout $ClientSessionTimeout -TransactionTimeout $TransactionTimeout -Culture $script:Culture
+
+ $form = Open-TestForm -TestPage $script:TestRunnerPage -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -ClientContext $clientContext
+
+ $SelectSuiteControl = $clientContext.GetControlByName($form, "Input Dataset Filename")
+ $clientContext.SaveValue($SelectSuiteControl, $InputDatasetFilename);
+
+ Write-HostWithTimestamp "Uploading the Input Dataset $InputDatasetFilename"
+
+ $SelectSuiteControl = $clientContext.GetControlByName($form, "Input Dataset")
+ $clientContext.SaveValue($SelectSuiteControl, $InputDataset);
+
+ $validationResultsError = Get-FormError($form)
+ if ($validationResultsError.Count -gt 0) {
+ Write-HostWithTimestamp "There is an error uploading the Input Dataset: $InputDatasetFilename" -ForegroundColor Red
+ Write-HostWithTimestamp $validationResultsError -ForegroundColor Red
+ }
+
+ $clientContext.CloseForm($form)
+ }
+ finally {
+ if ($clientContext) {
+ $clientContext.Dispose()
+ }
+ }
+}
+
+#Upload the XML test suite definition needed to setup the AI Test Suite
+function Set-SuiteDefinitionInternal {
+ param (
+ [Parameter(Mandatory = $true)]
+ [xml] $SuiteDefinition,
+ [int] $ClientSessionTimeout = $script:ClientSessionTimeout,
+ [timespan] $TransactionTimeout = $script:TransactionTimeout
+ )
+ try {
+ $clientContext = Open-ClientSessionWithWait -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -Credential $script:Credential -ServiceUrl $script:ServiceUrl -ClientSessionTimeout $ClientSessionTimeout -TransactionTimeout $TransactionTimeout -Culture $script:Culture
+
+ $form = Open-TestForm -TestPage $script:TestRunnerPage -DisableSSLVerification:$script:DisableSSLVerification -AuthorizationType $script:AuthorizationType -ClientContext $clientContext
+
+ Write-HostWithTimestamp "Uploading the Suite Definition"
+ $SelectSuiteControl = $clientContext.GetControlByName($form, "Suite Definition")
+ $clientContext.SaveValue($SelectSuiteControl, $SuiteDefinition.OuterXml);
+
+ # Check if the suite definition is set correctly
+ $validationResultsError = Get-FormError($form)
+ if ($validationResultsError.Count -gt 0) {
+ throw $validationResultsError
+ }
+ $clientContext.CloseForm($form)
+ }
+ catch {
+ Write-HostWithTimestamp "`There is an error uploading the Suite Definition. Please check the Suite Definition XML:`n $($SuiteDefinition.OuterXml)" -ForegroundColor Red
+ if ($validationResultsError.Count -gt 0) {
+ throw $_.Exception.Message
+ }
+ else {
+ throw $_.Exception
+ }
+ }
+ finally {
+ if ($clientContext) {
+ $clientContext.Dispose()
+ }
+ }
+}
+
+function Get-FormError {
+ param (
+ [ClientLogicalForm]
+ $form
+ )
+ if ($form.HasValidatonResults -eq $true) {
+ $validationResults = $form.ValidationResults
+ $validationResultsError = @()
+ foreach ($validationResult in $validationResults | Where-Object { $_.Severity -eq "Error" }) {
+ $validationResultsError += "TestPage: $script:TestRunnerPage, Status: Error, Message: $($validationResult.Description), ErrorCallStack: $(Get-PSCallStack)"
+ }
+ return ($validationResultsError -join "`n")
+ }
+}
+
+function Invoke-BCRestMethod {
+ param (
+ [string]$Uri
+ )
+ switch ($script:AuthorizationType) {
+ "Windows" {
+ Invoke-RestMethod -Uri $Uri -Method Get -ContentType "application/json" -UseDefaultCredentials -AllowUnencryptedAuthentication
+ }
+ "NavUserPassword" {
+ Invoke-RestMethod -Uri $Uri -Method Get -ContentType "application/json" -Credential $script:Credential -AllowUnencryptedAuthentication
+ }
+ "AAD" {
+ $script:AadTokenProvider
+ if ($null -ne $script:AadTokenProvider) {
+ throw "You need to specify the AadTokenProvider for obtaining the token if using AAD authentication"
+ }
+
+ $token = $AadTokenProvider.GetToken($Credential)
+ $headers = @{
+ Authorization = "Bearer $token"
+ Accept = "application/json"
+ }
+ return Invoke-RestMethod -Uri $Uri -Method Get -Headers $headers
+ }
+ default {
+ Write-Error "Invalid authentication type specified. Use 'Windows', 'UserPassword', or 'AAD'."
+ }
+ }
+}
+
+function Write-HostWithTimestamp {
+ param (
+ [string] $Message
+ )
+ Write-Host "[$($script:Tenant) $(Get-Date)] $Message"
+}
+
+$script:DefaultEnvironment = "OnPrem"
+$script:DefaultAuthorizationType = 'Windows'
+$script:DefaultEnvironmentName = "sandbox"
+$script:DefaultServiceUrl = 'http://localhost:48900'
+$script:DefaultRedirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"
+$script:DefaultOnPremAPIHost = "http://localhost:7047"
+$script:DefaultSaaSAPIHost = "https://api.businesscentral.dynamics.com/v2.0"
+$script:DefaultServerInstance = "NAV"
+$script:DefaultClientSessionTimeout = 60;
+$script:DefaultTransactionTimeout = [timespan]::FromMinutes(60);
+$script:DefaultCulture = "en-US";
+
+$script:TestRunnerPage = '149042'
+$script:ClientAssembly1 = "Microsoft.Dynamics.Framework.UI.Client.dll"
+$script:ClientAssembly2 = "NewtonSoft.Json.dll"
+$script:ClientAssembly3 = "Microsoft.Internal.AntiSSRF.dll"
+
+if (!$script:TypesLoaded) {
+ Add-type -Path "$PSScriptRoot\Microsoft.Dynamics.Framework.UI.Client.dll"
+ Add-type -Path "$PSScriptRoot\NewtonSoft.Json.dll"
+ Add-type -Path "$PSScriptRoot\Microsoft.Internal.AntiSSRF.dll"
+
+ $alTestRunnerInternalPath = Join-Path $PSScriptRoot "ALTestRunnerInternal.psm1"
+ Import-Module "$alTestRunnerInternalPath"
+
+ $clientContextScriptPath = Join-Path $PSScriptRoot "ClientContext.ps1"
+ . "$clientContextScriptPath"
+
+ $aadTokenProviderScriptPath = Join-Path $PSScriptRoot "AadTokenProvider.ps1"
+ . "$aadTokenProviderScriptPath"
+}
+$script:TypesLoaded = $true;
+
+$ErrorActionPreference = "Stop"
+$script:AadTokenProvider = $null
+$script:Credential = $null
+
+Export-ModuleMember -Function Initialize-TestRunner, Reset-AITTestSuite, Invoke-AITSuite, Set-InputDatasetInternal, Set-SuiteDefinitionInternal, Get-AITSuiteTestResultInternal, Get-AITSuiteEvaluationResultInternal, Get-AITSuiteTestMethodLinesInternal
diff --git a/Actions/.Modules/TestRunner/TestResultFormatter.psm1 b/Actions/.Modules/TestRunner/TestResultFormatter.psm1
new file mode 100644
index 0000000000..51f77ad28e
--- /dev/null
+++ b/Actions/.Modules/TestRunner/TestResultFormatter.psm1
@@ -0,0 +1,246 @@
+<#
+.SYNOPSIS
+ Test result formatting utilities for converting test results to various output formats.
+.DESCRIPTION
+ This module provides functions to convert AL test run results into different XML formats
+ such as XUnit and JUnit. It can be extended to support additional formats as needed.
+#>
+
+<#
+.SYNOPSIS
+ Saves test results to a file in the specified format.
+.PARAMETER TestRunResultObject
+ The test run result object containing test execution data.
+.PARAMETER ResultsFilePath
+ The path where the results file should be saved.
+.PARAMETER Format
+ The output format. Supported values: 'XUnit', 'JUnit'. Default is 'JUnit'.
+.PARAMETER ExtensionId
+ Optional extension ID to include in JUnit output for proper test grouping.
+.PARAMETER AppName
+ Optional app name to include in JUnit output for proper test grouping.
+#>
+function Save-TestResults {
+ param(
+ [Parameter(Mandatory = $true)]
+ $TestRunResultObject,
+ [Parameter(Mandatory = $true)]
+ [string] $ResultsFilePath,
+ [ValidateSet('XUnit', 'JUnit')]
+ [string] $Format = 'JUnit',
+ [string] $ExtensionId = '',
+ [string] $AppName = ''
+ )
+
+ switch ($Format) {
+ 'XUnit' {
+ Save-ResultsAsXUnit -TestRunResultObject $TestRunResultObject -ResultsFilePath $ResultsFilePath
+ }
+ 'JUnit' {
+ Save-ResultsAsJUnit -TestRunResultObject $TestRunResultObject -ResultsFilePath $ResultsFilePath -ExtensionId $ExtensionId -AppName $AppName
+ }
+ }
+}
+
+<#
+.SYNOPSIS
+ Converts test results to XUnit format and saves to file.
+#>
+function Save-ResultsAsXUnit {
+ param(
+ [Parameter(Mandatory = $true)]
+ $TestRunResultObject,
+ [Parameter(Mandatory = $true)]
+ [string] $ResultsFilePath
+ )
+
+ [xml]$XUnitDoc = New-Object System.Xml.XmlDocument
+ $XUnitDoc.AppendChild($XUnitDoc.CreateXmlDeclaration("1.0", "UTF-8", $null)) | Out-Null
+ $XUnitAssemblies = $XUnitDoc.CreateElement("assemblies")
+ $XUnitDoc.AppendChild($XUnitAssemblies) | Out-Null
+
+ foreach ($testResult in $TestRunResultObject) {
+ $name = $testResult.name
+ $startTime = [datetime]($testResult.startTime)
+ $finishTime = [datetime]($testResult.finishTime)
+ $duration = $finishTime.Subtract($startTime)
+ $durationSeconds = [Math]::Round($duration.TotalSeconds, 3)
+
+ $XUnitAssembly = $XUnitDoc.CreateElement("assembly")
+ $XUnitAssemblies.AppendChild($XUnitAssembly) | Out-Null
+ $XUnitAssembly.SetAttribute("name", $name)
+ $XUnitAssembly.SetAttribute("x-code-unit", $testResult.codeUnit)
+ $XUnitAssembly.SetAttribute("test-framework", "PS Test Runner")
+ $XUnitAssembly.SetAttribute("run-date", $startTime.ToString("yyyy-MM-dd"))
+ $XUnitAssembly.SetAttribute("run-time", $startTime.ToString("HH:mm:ss"))
+ $XUnitAssembly.SetAttribute("total", 0)
+ $XUnitAssembly.SetAttribute("passed", 0)
+ $XUnitAssembly.SetAttribute("failed", 0)
+ $XUnitAssembly.SetAttribute("time", $durationSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+
+ $XUnitCollection = $XUnitDoc.CreateElement("collection")
+ $XUnitAssembly.AppendChild($XUnitCollection) | Out-Null
+ $XUnitCollection.SetAttribute("name", $name)
+ $XUnitCollection.SetAttribute("total", 0)
+ $XUnitCollection.SetAttribute("passed", 0)
+ $XUnitCollection.SetAttribute("failed", 0)
+ $XUnitCollection.SetAttribute("skipped", 0)
+ $XUnitCollection.SetAttribute("time", $durationSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+
+ foreach ($testMethod in $testResult.testResults) {
+ $testMethodName = $testMethod.method
+ $XUnitAssembly.SetAttribute("total", ([int]$XUnitAssembly.GetAttribute("total") + 1))
+ $XUnitCollection.SetAttribute("total", ([int]$XUnitCollection.GetAttribute("total") + 1))
+
+ $XUnitTest = $XUnitDoc.CreateElement("test")
+ $XUnitCollection.AppendChild($XUnitTest) | Out-Null
+ $XUnitTest.SetAttribute("name", $XUnitAssembly.GetAttribute("name") + ':' + $testMethodName)
+ $XUnitTest.SetAttribute("method", $testMethodName)
+
+ $methodStartTime = [datetime]($testMethod.startTime)
+ $methodFinishTime = [datetime]($testMethod.finishTime)
+ $methodDuration = $methodFinishTime.Subtract($methodStartTime)
+ $methodDurationSeconds = [Math]::Round($methodDuration.TotalSeconds, 3)
+ $XUnitTest.SetAttribute("time", $methodDurationSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+
+ switch ($testMethod.result) {
+ 2 { # Success
+ $XUnitAssembly.SetAttribute("passed", ([int]$XUnitAssembly.GetAttribute("passed") + 1))
+ $XUnitCollection.SetAttribute("passed", ([int]$XUnitCollection.GetAttribute("passed") + 1))
+ $XUnitTest.SetAttribute("result", "Pass")
+ }
+ 1 { # Failure
+ $XUnitAssembly.SetAttribute("failed", ([int]$XUnitAssembly.GetAttribute("failed") + 1))
+ $XUnitCollection.SetAttribute("failed", ([int]$XUnitCollection.GetAttribute("failed") + 1))
+ $XUnitTest.SetAttribute("result", "Fail")
+
+ $XUnitFailure = $XUnitDoc.CreateElement("failure")
+ $XUnitMessage = $XUnitDoc.CreateElement("message")
+ $XUnitMessage.InnerText = $testMethod.message
+ $XUnitFailure.AppendChild($XUnitMessage) | Out-Null
+ $XUnitStacktrace = $XUnitDoc.CreateElement("stack-trace")
+ $XUnitStacktrace.InnerText = $($testMethod.stackTrace).Replace(";", "`n")
+ $XUnitFailure.AppendChild($XUnitStacktrace) | Out-Null
+ $XUnitTest.AppendChild($XUnitFailure) | Out-Null
+ }
+ 3 { # Skipped
+ $XUnitCollection.SetAttribute("skipped", ([int]$XUnitCollection.GetAttribute("skipped") + 1))
+ $XUnitTest.SetAttribute("result", "Skip")
+ }
+ }
+ }
+ }
+
+ $XUnitDoc.Save($ResultsFilePath)
+}
+
+<#
+.SYNOPSIS
+ Converts test results to JUnit format and saves to file.
+.DESCRIPTION
+ Generates JUnit XML format compatible with AL-Go's AnalyzeTests action.
+ The format follows the standard JUnit schema with testsuites/testsuite/testcase structure.
+#>
+function Save-ResultsAsJUnit {
+ param(
+ [Parameter(Mandatory = $true)]
+ $TestRunResultObject,
+ [Parameter(Mandatory = $true)]
+ [string] $ResultsFilePath,
+ [string] $ExtensionId = '',
+ [string] $AppName = ''
+ )
+
+ [xml]$JUnitDoc = New-Object System.Xml.XmlDocument
+ $JUnitDoc.AppendChild($JUnitDoc.CreateXmlDeclaration("1.0", "UTF-8", $null)) | Out-Null
+ $JUnitTestSuites = $JUnitDoc.CreateElement("testsuites")
+ $JUnitDoc.AppendChild($JUnitTestSuites) | Out-Null
+
+ $hostname = $env:COMPUTERNAME
+ if (-not $hostname) { $hostname = "localhost" }
+
+ foreach ($testResult in $TestRunResultObject) {
+ $codeunitId = $testResult.codeUnit
+ $name = $testResult.name
+ $startTime = [datetime]($testResult.startTime)
+ $finishTime = [datetime]($testResult.finishTime)
+ $duration = $finishTime.Subtract($startTime)
+ $durationSeconds = [Math]::Round($duration.TotalSeconds, 3)
+
+ $JUnitTestSuite = $JUnitDoc.CreateElement("testsuite")
+ $JUnitTestSuites.AppendChild($JUnitTestSuite) | Out-Null
+ $JUnitTestSuite.SetAttribute("name", "$codeunitId $name")
+ $JUnitTestSuite.SetAttribute("timestamp", $startTime.ToString("s"))
+ $JUnitTestSuite.SetAttribute("hostname", $hostname)
+ $JUnitTestSuite.SetAttribute("time", $durationSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $JUnitTestSuite.SetAttribute("tests", 0)
+ $JUnitTestSuite.SetAttribute("failures", 0)
+ $JUnitTestSuite.SetAttribute("errors", 0)
+ $JUnitTestSuite.SetAttribute("skipped", 0)
+
+ # Add properties element for extensionId and appName (required by AnalyzeTests)
+ $JUnitProperties = $JUnitDoc.CreateElement("properties")
+ $JUnitTestSuite.AppendChild($JUnitProperties) | Out-Null
+
+ if ($ExtensionId) {
+ $property = $JUnitDoc.CreateElement("property")
+ $property.SetAttribute("name", "extensionId")
+ $property.SetAttribute("value", $ExtensionId)
+ $JUnitProperties.AppendChild($property) | Out-Null
+ }
+
+ if ($AppName) {
+ $property = $JUnitDoc.CreateElement("property")
+ $property.SetAttribute("name", "appName")
+ $property.SetAttribute("value", $AppName)
+ $JUnitProperties.AppendChild($property) | Out-Null
+ }
+
+ $totalTests = 0
+ $failedTests = 0
+ $skippedTests = 0
+
+ foreach ($testMethod in $testResult.testResults) {
+ $testMethodName = $testMethod.method
+ $totalTests++
+
+ $methodStartTime = [datetime]($testMethod.startTime)
+ $methodFinishTime = [datetime]($testMethod.finishTime)
+ $methodDuration = $methodFinishTime.Subtract($methodStartTime)
+ $methodDurationSeconds = [Math]::Round($methodDuration.TotalSeconds, 3)
+
+ $JUnitTestCase = $JUnitDoc.CreateElement("testcase")
+ $JUnitTestSuite.AppendChild($JUnitTestCase) | Out-Null
+ $JUnitTestCase.SetAttribute("classname", "$codeunitId $name")
+ $JUnitTestCase.SetAttribute("name", $testMethodName)
+ $JUnitTestCase.SetAttribute("time", $methodDurationSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+
+ switch ($testMethod.result) {
+ 2 { # Success
+ # No child element needed for success
+ }
+ 1 { # Failure
+ $failedTests++
+ $JUnitFailure = $JUnitDoc.CreateElement("failure")
+ $JUnitFailure.SetAttribute("message", $testMethod.message)
+ $stackTrace = $($testMethod.stackTrace).Replace(";", "`n")
+ $JUnitFailure.InnerText = $stackTrace
+ $JUnitTestCase.AppendChild($JUnitFailure) | Out-Null
+ }
+ 3 { # Skipped
+ $skippedTests++
+ $JUnitSkipped = $JUnitDoc.CreateElement("skipped")
+ $JUnitTestCase.AppendChild($JUnitSkipped) | Out-Null
+ }
+ }
+ }
+
+ $JUnitTestSuite.SetAttribute("tests", $totalTests)
+ $JUnitTestSuite.SetAttribute("failures", $failedTests)
+ $JUnitTestSuite.SetAttribute("skipped", $skippedTests)
+ }
+
+ $JUnitDoc.Save($ResultsFilePath)
+}
+
+Export-ModuleMember -Function Save-TestResults, Save-ResultsAsXUnit, Save-ResultsAsJUnit
diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json
index af08720d34..2c50a045d2 100644
--- a/Actions/.Modules/settings.schema.json
+++ b/Actions/.Modules/settings.schema.json
@@ -336,6 +336,33 @@
"doNotRunPageScriptingTests": {
"type": "boolean"
},
+ "enableCodeCoverage": {
+ "type": "boolean",
+ "description": "Enable code coverage tracking during test runs. Exports coverage data to Cobertura XML format. See https://aka.ms/ALGoSettings#enablecodecoverage"
+ },
+ "codeCoverageSetup": {
+ "type": "object",
+ "description": "Configuration for code coverage tracking. See https://aka.ms/ALGoSettings#codeCoverageSetup",
+ "properties": {
+ "trackingType": {
+ "type": "string",
+ "enum": ["PerRun", "PerCodeunit", "PerTest"],
+ "description": "Granularity of coverage tracking"
+ },
+ "produceCodeCoverageMap": {
+ "type": "string",
+ "enum": ["Disabled", "PerCodeunit", "PerTest"],
+ "description": "Granularity of coverage map output"
+ },
+ "excludeFilesPattern": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Glob patterns for files to exclude from coverage"
+ }
+ }
+ },
"doNotPublishApps": {
"type": "boolean"
},
diff --git a/Actions/BuildCodeCoverageSummary/BuildCodeCoverageSummary.ps1 b/Actions/BuildCodeCoverageSummary/BuildCodeCoverageSummary.ps1
new file mode 100644
index 0000000000..e8fd325f1d
--- /dev/null
+++ b/Actions/BuildCodeCoverageSummary/BuildCodeCoverageSummary.ps1
@@ -0,0 +1,52 @@
+Param(
+ [Parameter(HelpMessage = "Project to analyze coverage for", Mandatory = $false)]
+ [string] $project = '.'
+)
+
+. (Join-Path -Path $PSScriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve)
+. (Join-Path -Path $PSScriptRoot -ChildPath "CoverageReportGenerator.ps1" -Resolve)
+
+$coverageSummaryMD = ''
+$coverageDetailsMD = ''
+
+# Find Cobertura coverage file in .buildartifacts folder
+$coverageFile = Join-Path $ENV:GITHUB_WORKSPACE "$project/.buildartifacts/CodeCoverage/cobertura.xml"
+
+if (-not (Test-Path -Path $coverageFile -PathType Leaf)) {
+ Write-Host "No coverage file found at: $coverageFile"
+ Write-Host "Skipping coverage summary generation."
+ return
+}
+
+Write-Host "Processing coverage file: $coverageFile"
+
+# Generate coverage summary markdown
+$coverageResult = Get-CoverageSummaryMD -CoverageFile $coverageFile
+$coverageSummaryMD = $coverageResult.SummaryMD
+$coverageDetailsMD = $coverageResult.DetailsMD
+
+# Helper function to calculate byte size
+function GetStringByteSize($string) {
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($string)
+ return $bytes.Length
+}
+
+$titleSize = GetStringByteSize("## Code Coverage`n`n")
+$summarySize = GetStringByteSize("$($coverageSummaryMD.Replace("\n","`n"))`n`n")
+$detailsSize = GetStringByteSize("$($coverageDetailsMD.Replace("\n","`n"))`n`n")
+
+# GitHub job summaries are limited to just under 1MB
+if ($coverageSummaryMD) {
+ if ($titleSize + $summarySize -gt (1MB - 4)) {
+ $coverageSummaryMD = "Coverage summary size exceeds GitHub summary capacity. Download **CodeCoverage** artifact to see details."
+ $summarySize = GetStringByteSize($coverageSummaryMD)
+ }
+ if ($titleSize + $summarySize + $detailsSize -gt (1MB - 4)) {
+ # Truncate details if too long
+ $coverageDetailsMD = "Coverage details truncated due to size limits."
+ }
+
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value "## Code Coverage`n`n"
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value "$($coverageSummaryMD.Replace("\n","`n"))`n`n"
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value "$($coverageDetailsMD.Replace("\n","`n"))`n`n"
+}
diff --git a/Actions/BuildCodeCoverageSummary/CoverageReportGenerator.ps1 b/Actions/BuildCodeCoverageSummary/CoverageReportGenerator.ps1
new file mode 100644
index 0000000000..6d964ad82c
--- /dev/null
+++ b/Actions/BuildCodeCoverageSummary/CoverageReportGenerator.ps1
@@ -0,0 +1,519 @@
+<#
+.SYNOPSIS
+ Generates coverage report markdown from Cobertura XML
+.DESCRIPTION
+ Parses Cobertura coverage XML and generates GitHub-flavored markdown
+ summaries and detailed reports for display in job summaries.
+#>
+
+$statusHigh = " :green_circle:" # >= 80%
+$statusMedium = " :yellow_circle:" # >= 50%
+$statusLow = " :red_circle:" # < 50%
+
+$mdHelperPath = Join-Path -Path $PSScriptRoot -ChildPath "..\MarkDownHelper.psm1"
+Import-Module $mdHelperPath
+
+<#
+.SYNOPSIS
+ Gets a status icon based on coverage percentage
+.PARAMETER Coverage
+ Coverage percentage (0-100)
+.OUTPUTS
+ Status icon string
+#>
+function Get-CoverageStatusIcon {
+ param(
+ [Parameter(Mandatory = $true)]
+ [double]$Coverage
+ )
+
+ if ($Coverage -ge 80) { return $statusHigh }
+ elseif ($Coverage -ge 50) { return $statusMedium }
+ else { return $statusLow }
+}
+
+<#
+.SYNOPSIS
+ Formats a coverage percentage for display
+.PARAMETER LineRate
+ Line rate from Cobertura (0-1)
+.OUTPUTS
+ Formatted percentage string with icon
+#>
+function Format-CoveragePercent {
+ param(
+ [Parameter(Mandatory = $true)]
+ [double]$LineRate
+ )
+
+ $percent = [math]::Round($LineRate * 100, 1)
+ $icon = Get-CoverageStatusIcon -Coverage $percent
+ return "$percent%$icon"
+}
+
+<#
+.SYNOPSIS
+ Creates a visual coverage bar using Unicode characters
+.PARAMETER Coverage
+ Coverage percentage (0-100)
+.PARAMETER Width
+ Bar width in characters (default 10)
+.OUTPUTS
+ Coverage bar string
+#>
+function New-CoverageBar {
+ param(
+ [Parameter(Mandatory = $true)]
+ [double]$Coverage,
+
+ [Parameter(Mandatory = $false)]
+ [int]$Width = 10
+ )
+
+ $filled = [math]::Floor($Coverage / 100 * $Width)
+ $empty = $Width - $filled
+
+ # Using ASCII-compatible characters for GitHub
+ $bar = ("#" * $filled) + ("-" * $empty)
+ return "``[$bar]``"
+}
+
+<#
+.SYNOPSIS
+ Extracts Area and Module paths from a filename
+.PARAMETER Filename
+ Source file path (e.g., "src/System Application/App/Email/src/Email.Codeunit.al")
+.PARAMETER AppRoots
+ Optional array of known app root paths (relative, forward-slash separated).
+ When provided, the area is the matching app root and the module is the
+ first subdirectory under it. Falls back to depth-based heuristic if empty.
+.OUTPUTS
+ Hashtable with Area and Module paths
+#>
+function Get-ModuleFromFilename {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Filename,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$AppRoots = @()
+ )
+
+ $normalizedFilename = $Filename.Replace('\', '/')
+
+ # Try to match against known app roots (longest match first)
+ if ($AppRoots.Count -gt 0) {
+ $matchedRoot = $AppRoots | Sort-Object { $_.Length } -Descending | Where-Object {
+ $normalizedFilename.StartsWith($_.Replace('\', '/') + '/', [System.StringComparison]::OrdinalIgnoreCase)
+ } | Select-Object -First 1
+
+ if ($matchedRoot) {
+ $area = $matchedRoot.Replace('\', '/')
+ $remainder = $normalizedFilename.Substring($area.Length + 1)
+ $parts = $remainder -split '/'
+ $module = if ($parts.Count -ge 1 -and $parts[0]) { "$area/$($parts[0])" } else { $area }
+ return @{ Area = $area; Module = $module }
+ }
+ }
+
+ # Fallback: use path depth heuristic
+ $parts = $normalizedFilename -split '/'
+
+ $area = if ($parts.Count -ge 3) {
+ "$($parts[0])/$($parts[1])/$($parts[2])"
+ } else {
+ $normalizedFilename
+ }
+
+ $module = if ($parts.Count -ge 4) {
+ "$($parts[0])/$($parts[1])/$($parts[2])/$($parts[3])"
+ } else {
+ $area
+ }
+
+ return @{
+ Area = $area
+ Module = $module
+ }
+}
+
+<#
+.SYNOPSIS
+ Aggregates coverage data by module and area
+.PARAMETER Coverage
+ Coverage data from Read-CoberturaFile
+.OUTPUTS
+ Hashtable with AreaData containing module aggregations
+#>
+function Get-ModuleCoverageData {
+ param(
+ [Parameter(Mandatory = $true)]
+ [hashtable]$Coverage,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$AppRoots = @()
+ )
+
+ $moduleData = @{}
+
+ foreach ($package in $Coverage.Packages) {
+ foreach ($class in $package.Classes) {
+ $paths = Get-ModuleFromFilename -Filename $class.Filename -AppRoots $AppRoots
+ $module = $paths.Module
+ $area = $paths.Area
+
+ if (-not $moduleData.ContainsKey($module)) {
+ $moduleData[$module] = @{
+ Area = $area
+ ModuleName = if ($module -ne $area) { $module.Replace("$area/", "") } else { $module }
+ TotalLines = 0
+ CoveredLines = 0
+ Objects = 0
+ }
+ }
+
+ $moduleData[$module].Objects++
+ $moduleData[$module].TotalLines += $class.LinesTotal
+ $moduleData[$module].CoveredLines += $class.LinesCovered
+ }
+ }
+
+ # Group modules by area
+ $areaData = @{}
+ foreach ($mod in $moduleData.Keys) {
+ $area = $moduleData[$mod].Area
+ if (-not $areaData.ContainsKey($area)) {
+ $areaData[$area] = @{
+ Modules = @{}
+ AllZero = $true
+ TotalLines = 0
+ CoveredLines = 0
+ Objects = 0
+ }
+ }
+ $areaData[$area].Modules[$mod] = $moduleData[$mod]
+ $areaData[$area].TotalLines += $moduleData[$mod].TotalLines
+ $areaData[$area].CoveredLines += $moduleData[$mod].CoveredLines
+ $areaData[$area].Objects += $moduleData[$mod].Objects
+ if ($moduleData[$mod].CoveredLines -gt 0) {
+ $areaData[$area].AllZero = $false
+ }
+ }
+
+ return $areaData
+}
+
+<#
+.SYNOPSIS
+ Parses Cobertura XML and returns coverage data
+.PARAMETER CoverageFile
+ Path to the Cobertura XML file
+.OUTPUTS
+ Hashtable with overall stats and per-class coverage
+#>
+function Read-CoberturaFile {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$CoverageFile
+ )
+
+ if (-not (Test-Path $CoverageFile)) {
+ throw "Coverage file not found: $CoverageFile"
+ }
+
+ [xml]$xml = Get-Content -Path $CoverageFile -Encoding UTF8
+
+ $coverage = $xml.coverage
+
+ $result = @{
+ LineRate = [double]$coverage.'line-rate'
+ BranchRate = [double]$coverage.'branch-rate'
+ LinesCovered = [int]$coverage.'lines-covered'
+ LinesValid = [int]$coverage.'lines-valid'
+ Timestamp = $coverage.timestamp
+ Packages = @()
+ }
+
+ # Handle empty packages element (strict mode compatible)
+ $packagesNode = $coverage.SelectSingleNode('packages')
+ if (-not $packagesNode -or -not $packagesNode.HasChildNodes) {
+ return $result
+ }
+
+ foreach ($package in $packagesNode.package) {
+ $packageData = @{
+ Name = $package.name
+ LineRate = [double]$package.'line-rate'
+ Classes = @()
+ }
+
+ # Handle empty classes element (strict mode compatible)
+ $classesNode = $package.SelectSingleNode('classes')
+ if (-not $classesNode -or -not $classesNode.HasChildNodes) {
+ $result.Packages += $packageData
+ continue
+ }
+
+ foreach ($class in $classesNode.class) {
+ $methods = @()
+
+ # Handle empty methods element (strict mode compatible)
+ $methodsNode = $class.SelectSingleNode('methods')
+ if ($methodsNode -and $methodsNode.HasChildNodes) {
+ foreach ($method in $methodsNode.method) {
+ # Handle empty lines element in method (strict mode compatible)
+ $methodLinesNode = $method.SelectSingleNode('lines')
+ $methodLines = @()
+ if ($methodLinesNode -and $methodLinesNode.HasChildNodes) {
+ $methodLines = @($methodLinesNode.line)
+ }
+ $methodCovered = @($methodLines | Where-Object { [int]$_.hits -gt 0 }).Count
+ $methodTotal = $methodLines.Count
+
+ $methods += @{
+ Name = $method.name
+ LineRate = [double]$method.'line-rate'
+ LinesCovered = $methodCovered
+ LinesTotal = $methodTotal
+ }
+ }
+ }
+
+ # Handle lines element (strict mode compatible)
+ $linesNode = $class.SelectSingleNode('lines')
+ $classLines = @()
+ if ($linesNode -and $linesNode.HasChildNodes) {
+ $classLines = @($linesNode.line)
+ }
+ $classCovered = @($classLines | Where-Object { [int]$_.hits -gt 0 }).Count
+ $classTotal = $classLines.Count
+
+ $packageData.Classes += @{
+ Name = $class.name
+ Filename = $class.filename
+ LineRate = [double]$class.'line-rate'
+ LinesCovered = $classCovered
+ LinesTotal = $classTotal
+ Methods = $methods
+ Lines = $classLines
+ }
+ }
+
+ $result.Packages += $packageData
+ }
+
+ return $result
+}
+
+<#
+.SYNOPSIS
+ Generates markdown summary from coverage data
+.PARAMETER CoverageFile
+ Path to the Cobertura XML file
+.OUTPUTS
+ Hashtable with SummaryMD and DetailsMD strings
+#>
+function Get-CoverageSummaryMD {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$CoverageFile
+ )
+
+ try {
+ $coverage = Read-CoberturaFile -CoverageFile $CoverageFile
+ }
+ catch {
+ Write-Host "Error reading coverage file: $_"
+ return @{
+ SummaryMD = ""
+ DetailsMD = ""
+ }
+ }
+
+ # Try to read stats JSON for external code info
+ $statsFile = [System.IO.Path]::ChangeExtension($CoverageFile, '.stats.json')
+ $stats = $null
+ if (Test-Path $statsFile) {
+ try {
+ $stats = Get-Content -Path $statsFile -Encoding UTF8 | ConvertFrom-Json
+ }
+ catch {
+ Write-Host "Warning: Could not read stats file: $_"
+ }
+ }
+
+ $summarySb = [System.Text.StringBuilder]::new()
+ $detailsSb = [System.Text.StringBuilder]::new()
+
+ # Overall summary
+ $overallPercent = [math]::Round($coverage.LineRate * 100, 1)
+ $overallIcon = Get-CoverageStatusIcon -Coverage $overallPercent
+ $overallBar = New-CoverageBar -Coverage $overallPercent -Width 20
+
+ $summarySb.AppendLine("### Overall Coverage: $overallPercent%$overallIcon") | Out-Null
+ $summarySb.AppendLine("") | Out-Null
+ $summarySb.AppendLine("$overallBar **$($coverage.LinesCovered)** of **$($coverage.LinesValid)** lines covered") | Out-Null
+ $summarySb.AppendLine("") | Out-Null
+
+ # External code section (code executed but no source available)
+ # Safely check for property existence (strict mode compatible)
+ $hasExcludedStats = $stats -and ($stats | Get-Member -Name 'ExcludedObjectCount' -MemberType NoteProperty) -and $stats.ExcludedObjectCount -gt 0
+ if ($hasExcludedStats) {
+ $summarySb.AppendLine("### External Code Executed") | Out-Null
+ $summarySb.AppendLine("") | Out-Null
+ $summarySb.AppendLine(":information_source: **$($stats.ExcludedObjectCount)** objects executed from external apps (no source available)") | Out-Null
+ $summarySb.AppendLine("") | Out-Null
+ $summarySb.AppendLine("- Lines executed: **$($stats.ExcludedLinesExecuted)**") | Out-Null
+ $summarySb.AppendLine("- Total hits: **$($stats.ExcludedTotalHits)**") | Out-Null
+ $summarySb.AppendLine("") | Out-Null
+ }
+
+ # Coverage threshold legend
+ $summarySb.AppendLine(":green_circle: ≥80% :yellow_circle: ≥50% :red_circle: <50%") | Out-Null
+ $summarySb.AppendLine("") | Out-Null
+
+ # Per-module coverage breakdown (aggregated from objects)
+ if ($coverage.Packages.Count -gt 0) {
+ # Use app source paths from stats for dynamic module detection
+ $appRoots = @()
+ $hasAppSourcePaths = $stats -and ($stats | Get-Member -Name 'AppSourcePaths' -MemberType NoteProperty) -and $stats.AppSourcePaths
+ if ($hasAppSourcePaths) {
+ $appRoots = @($stats.AppSourcePaths)
+ }
+ $areaData = Get-ModuleCoverageData -Coverage $coverage -AppRoots $appRoots
+
+ # Separate areas into those with coverage and those without
+ $areasWithCoverage = @($areaData.GetEnumerator() | Where-Object { -not $_.Value.AllZero } | Sort-Object { $_.Value.CoveredLines } -Descending)
+ $areasWithoutCoverage = @($areaData.GetEnumerator() | Where-Object { $_.Value.AllZero } | Sort-Object { $_.Value.Objects } -Descending)
+
+ # Build module-level table for areas with coverage (collapsible)
+ if ($areasWithCoverage.Count -gt 0) {
+ $totalModules = ($areasWithCoverage | ForEach-Object { $_.Value.Modules.Count } | Measure-Object -Sum).Sum
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine("Coverage by Module ($($areasWithCoverage.Count) areas, $totalModules modules with coverage)
") | Out-Null
+ $detailsSb.AppendLine("") | Out-Null
+
+ $headers = @("Module;left", "Coverage;right", "Lines;right", "Objects;right", "Bar;left")
+ $rows = [System.Collections.ArrayList]@()
+
+ foreach ($area in $areasWithCoverage) {
+ # Add area header row
+ $areaPct = if ($area.Value.TotalLines -gt 0) { [math]::Round($area.Value.CoveredLines / $area.Value.TotalLines * 100, 1) } else { 0 }
+ $areaIcon = Get-CoverageStatusIcon -Coverage $areaPct
+ $areaBar = New-CoverageBar -Coverage $areaPct -Width 10
+
+ $areaRow = @(
+ "**$($area.Key)**",
+ "**$areaPct%$areaIcon**",
+ "**$($area.Value.CoveredLines)/$($area.Value.TotalLines)**",
+ "**$($area.Value.Objects)**",
+ $areaBar
+ )
+ $rows.Add($areaRow) | Out-Null
+
+ # Add module rows (indented)
+ $sortedModules = $area.Value.Modules.GetEnumerator() | Sort-Object { $_.Value.CoveredLines } -Descending
+ foreach ($mod in $sortedModules) {
+ $modPct = if ($mod.Value.TotalLines -gt 0) { [math]::Round($mod.Value.CoveredLines / $mod.Value.TotalLines * 100, 1) } else { 0 }
+ $modIcon = Get-CoverageStatusIcon -Coverage $modPct
+ $modBar = New-CoverageBar -Coverage $modPct -Width 10
+
+ $modRow = @(
+ " $($mod.Value.ModuleName)",
+ "$modPct%$modIcon",
+ "$($mod.Value.CoveredLines)/$($mod.Value.TotalLines)",
+ "$($mod.Value.Objects)",
+ $modBar
+ )
+ $rows.Add($modRow) | Out-Null
+ }
+ }
+
+ try {
+ $table = Build-MarkdownTable -Headers $headers -Rows $rows
+ $detailsSb.AppendLine($table) | Out-Null
+ }
+ catch {
+ $detailsSb.AppendLine("Failed to generate module coverage table") | Out-Null
+ }
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine(" ") | Out-Null
+ $detailsSb.AppendLine("") | Out-Null
+ }
+
+ # Show collapsed areas (all 0% coverage) in a separate section
+ if ($areasWithoutCoverage.Count -gt 0) {
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine("Areas with no coverage data ($($areasWithoutCoverage.Count) areas)
") | Out-Null
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine("These areas had no lines executed during tests:") | Out-Null
+ $detailsSb.AppendLine("") | Out-Null
+
+ $zeroHeaders = @("Area;left", "Objects;right", "Lines;right")
+ $zeroRows = [System.Collections.ArrayList]@()
+
+ foreach ($area in $areasWithoutCoverage) {
+ $zeroRow = @(
+ $area.Key,
+ $area.Value.Objects.ToString(),
+ $area.Value.TotalLines.ToString()
+ )
+ $zeroRows.Add($zeroRow) | Out-Null
+ }
+
+ try {
+ $zeroTable = Build-MarkdownTable -Headers $zeroHeaders -Rows $zeroRows
+ $detailsSb.AppendLine($zeroTable) | Out-Null
+ }
+ catch {
+ $detailsSb.AppendLine("Failed to generate table") | Out-Null
+ }
+
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine(" ") | Out-Null
+ }
+
+ $detailsSb.AppendLine("") | Out-Null
+ }
+
+ # External objects section (collapsible)
+ # Use Get-Member to safely check if ExcludedObjects property exists (strict mode compatible)
+ $hasExcludedObjects = $stats -and ($stats | Get-Member -Name 'ExcludedObjects' -MemberType NoteProperty) -and $stats.ExcludedObjects -and @($stats.ExcludedObjects).Count -gt 0
+ if ($hasExcludedObjects) {
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine("External Objects Executed (no source available)
") | Out-Null
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine("These objects were executed during tests but their source code was not found in the workspace:") | Out-Null
+ $detailsSb.AppendLine("") | Out-Null
+
+ $extHeaders = @("Object Type;left", "Object ID;right", "Lines Executed;right", "Total Hits;right")
+ $extRows = [System.Collections.ArrayList]@()
+
+ foreach ($obj in ($stats.ExcludedObjects | Sort-Object -Property TotalHits -Descending)) {
+ $extRow = @(
+ $obj.ObjectType,
+ $obj.ObjectId.ToString(),
+ $obj.LinesExecuted.ToString(),
+ $obj.TotalHits.ToString()
+ )
+ $extRows.Add($extRow) | Out-Null
+ }
+
+ try {
+ $extTable = Build-MarkdownTable -Headers $extHeaders -Rows $extRows
+ $detailsSb.AppendLine($extTable) | Out-Null
+ }
+ catch {
+ $detailsSb.AppendLine("Failed to generate external objects table") | Out-Null
+ }
+
+ $detailsSb.AppendLine("") | Out-Null
+ $detailsSb.AppendLine(" ") | Out-Null
+ }
+
+ return @{
+ SummaryMD = $summarySb.ToString()
+ DetailsMD = $detailsSb.ToString()
+ }
+}
diff --git a/Actions/BuildCodeCoverageSummary/README.md b/Actions/BuildCodeCoverageSummary/README.md
new file mode 100644
index 0000000000..d578d41717
--- /dev/null
+++ b/Actions/BuildCodeCoverageSummary/README.md
@@ -0,0 +1,61 @@
+# Build Code Coverage Summary
+
+Generates a GitHub Job Summary with code coverage visualization from Cobertura XML files.
+
+## Usage
+
+```yaml
+- name: Build Code Coverage Summary
+ uses: microsoft/AL-Go/Actions/BuildCodeCoverageSummary@main
+ with:
+ shell: powershell
+ project: '.'
+```
+
+## Inputs
+
+| Name | Description | Required | Default |
+|------|-------------|----------|---------|
+| `shell` | Shell to run the action in (powershell or pwsh) | No | `powershell` |
+| `project` | Project folder to analyze coverage for | No | `.` |
+
+## Output
+
+The action generates a GitHub Job Summary containing:
+
+### Overall Coverage Summary
+
+- Overall coverage percentage with visual indicator
+- Coverage bar showing filled/unfilled proportion
+- Lines covered vs total lines
+
+### Coverage by Object Table
+
+- Object name (e.g., `Codeunit.50100`)
+- Source filename
+- Coverage percentage with status icon
+- Lines covered/total
+- Visual coverage bar
+
+### Method-level Details (Collapsible)
+
+- Per-method coverage breakdown
+- Organized by object/class
+
+## Coverage Status Icons
+
+| Icon | Coverage Level |
+|------|---------------|
+| 🟢 | >= 80% (Good) |
+| 🟡 | >= 50% (Needs Improvement) |
+| 🔴 | < 50% (Low) |
+
+## Prerequisites
+
+This action expects a Cobertura XML file at:
+
+```
+{project}/.buildartifacts/CodeCoverage/cobertura.xml
+```
+
+The coverage file is generated by the `RunPipeline` action when code coverage is enabled.
diff --git a/Actions/BuildCodeCoverageSummary/action.yaml b/Actions/BuildCodeCoverageSummary/action.yaml
new file mode 100644
index 0000000000..da6b6d50aa
--- /dev/null
+++ b/Actions/BuildCodeCoverageSummary/action.yaml
@@ -0,0 +1,25 @@
+name: Build Code Coverage Summary
+author: Microsoft Corporation
+inputs:
+ shell:
+ description: Shell in which you want to run the action (powershell or pwsh)
+ required: false
+ default: powershell
+ project:
+ description: Project to analyze coverage for
+ required: false
+ default: '.'
+runs:
+ using: composite
+ steps:
+ - name: run
+ shell: ${{ inputs.shell }}
+ env:
+ _project: ${{ inputs.project }}
+ run: |
+ ${{ github.action_path }}/../Invoke-AlGoAction.ps1 -ActionName "BuildCodeCoverageSummary" -Action {
+ ${{ github.action_path }}/BuildCodeCoverageSummary.ps1 -project $ENV:_project
+ }
+branding:
+ icon: bar-chart-2
+ color: green
diff --git a/Actions/CalculateArtifactNames/CalculateArtifactNames.ps1 b/Actions/CalculateArtifactNames/CalculateArtifactNames.ps1
index b2e38fc345..bf041b9fce 100644
--- a/Actions/CalculateArtifactNames/CalculateArtifactNames.ps1
+++ b/Actions/CalculateArtifactNames/CalculateArtifactNames.ps1
@@ -1,56 +1,56 @@
-Param(
- [Parameter(HelpMessage = "Name of the built project", Mandatory = $true)]
- [string] $project,
- [Parameter(HelpMessage = "Build mode used when building the artifacts", Mandatory = $true)]
- [string] $buildMode,
- [Parameter(HelpMessage = "Suffix to add to the artifacts names", Mandatory = $false)]
- [string] $suffix
-)
-
-function Set-OutputVariable([string] $name, [string] $value) {
- Write-Host "Assigning $value to $name"
- Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "$name=$value"
-}
-
-$settings = $env:Settings | ConvertFrom-Json
-
-if ($project -eq ".") {
- $project = $settings.repoName
-}
-
-$branchName = $ENV:GITHUB_HEAD_REF
-# $ENV:GITHUB_HEAD_REF is specified only for pull requests, so if it is not specified, use GITHUB_REF_NAME
-if (!$branchName) {
- $branchName = $ENV:GITHUB_REF_NAME
-}
-
-$branchName = $branchName.Replace('\', '_').Replace('/', '_')
-$projectName = $project.Replace('\', '_').Replace('/', '_')
-
-# If the buildmode is default, then we don't want to add it to the artifact name
-if ($buildMode -eq 'Default') {
- $buildMode = ''
-}
-Set-OutputVariable -name "BuildMode" -value $buildMode
-
-if ($suffix) {
- # Add the date to the suffix
- $suffix = "$suffix-$([DateTime]::UtcNow.ToString('yyyyMMdd'))"
-}
-else {
- $repoVersion = [System.Version]$settings.repoVersion
- $appBuild = $settings.appBuild
- if ($appBuild -eq -1) {
- $appBuild = $repoVersion.Build
- if ($repoVersion.Build -eq -1) {
- $appBuild = 0
- }
- }
- $suffix = "$($repoVersion.Major).$($repoVersion.Minor).$($appBuild).$($settings.appRevision)"
-}
-
-'Apps', 'Dependencies', 'TestApps', 'TestResults', 'BcptTestResults', 'PageScriptingTestResults', 'PageScriptingTestResultDetails', 'BuildOutput', 'ContainerEventLog', 'PowerPlatformSolution', 'ErrorLogs' | ForEach-Object {
- $name = "$($_)ArtifactsName"
- $value = "$($projectName)-$($branchName)-$buildMode$_-$suffix"
- Set-OutputVariable -name $name -value $value
-}
+Param(
+ [Parameter(HelpMessage = "Name of the built project", Mandatory = $true)]
+ [string] $project,
+ [Parameter(HelpMessage = "Build mode used when building the artifacts", Mandatory = $true)]
+ [string] $buildMode,
+ [Parameter(HelpMessage = "Suffix to add to the artifacts names", Mandatory = $false)]
+ [string] $suffix
+)
+
+function Set-OutputVariable([string] $name, [string] $value) {
+ Write-Host "Assigning $value to $name"
+ Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "$name=$value"
+}
+
+$settings = $env:Settings | ConvertFrom-Json
+
+if ($project -eq ".") {
+ $project = $settings.repoName
+}
+
+$branchName = $ENV:GITHUB_HEAD_REF
+# $ENV:GITHUB_HEAD_REF is specified only for pull requests, so if it is not specified, use GITHUB_REF_NAME
+if (!$branchName) {
+ $branchName = $ENV:GITHUB_REF_NAME
+}
+
+$branchName = $branchName.Replace('\', '_').Replace('/', '_')
+$projectName = $project.Replace('\', '_').Replace('/', '_')
+
+# If the buildmode is default, then we don't want to add it to the artifact name
+if ($buildMode -eq 'Default') {
+ $buildMode = ''
+}
+Set-OutputVariable -name "BuildMode" -value $buildMode
+
+if ($suffix) {
+ # Add the date to the suffix
+ $suffix = "$suffix-$([DateTime]::UtcNow.ToString('yyyyMMdd'))"
+}
+else {
+ $repoVersion = [System.Version]$settings.repoVersion
+ $appBuild = $settings.appBuild
+ if ($appBuild -eq -1) {
+ $appBuild = $repoVersion.Build
+ if ($repoVersion.Build -eq -1) {
+ $appBuild = 0
+ }
+ }
+ $suffix = "$($repoVersion.Major).$($repoVersion.Minor).$($appBuild).$($settings.appRevision)"
+}
+
+'Apps', 'Dependencies', 'TestApps', 'TestResults', 'BcptTestResults', 'PageScriptingTestResults', 'PageScriptingTestResultDetails', 'BuildOutput', 'ContainerEventLog', 'PowerPlatformSolution', 'ErrorLogs', 'CodeCoverage' | ForEach-Object {
+ $name = "$($_)ArtifactsName"
+ $value = "$($projectName)-$($branchName)-$buildMode$_-$suffix"
+ Set-OutputVariable -name $name -value $value
+}
diff --git a/Actions/CalculateArtifactNames/action.yaml b/Actions/CalculateArtifactNames/action.yaml
index c6e605e386..28bf36a1fe 100644
--- a/Actions/CalculateArtifactNames/action.yaml
+++ b/Actions/CalculateArtifactNames/action.yaml
@@ -1,71 +1,74 @@
-name: Calculate Artifact Names
-author: Microsoft Corporation
-inputs:
- shell:
- description: Shell in which you want to run the action (powershell or pwsh)
- required: false
- default: powershell
- project:
- description: Name of the built project
- required: true
- buildMode:
- description: Build mode used when building the artifacts
- required: true
- suffix:
- description: Suffix to add to the artifacts names
- required: false
- default: ''
-outputs:
- AppsArtifactsName:
- description: Artifacts name for Apps
- value: ${{ steps.calculateartifactnames.outputs.AppsArtifactsName }}
- PowerPlatformSolutionArtifactsName:
- description: Artifacts name for PowerPlatform Solution
- value: ${{ steps.calculateartifactnames.outputs.PowerPlatformSolutionArtifactsName }}
- DependenciesArtifactsName:
- description: Artifacts name for Dependencies
- value: ${{ steps.calculateartifactnames.outputs.DependenciesArtifactsName }}
- TestAppsArtifactsName:
- description: Artifacts name for TestApps
- value: ${{ steps.calculateartifactnames.outputs.TestAppsArtifactsName }}
- TestResultsArtifactsName:
- description: Artifacts name for TestResults
- value: ${{ steps.calculateartifactnames.outputs.TestResultsArtifactsName }}
- BcptTestResultsArtifactsName:
- description: Artifacts name for BcptTestResults
- value: ${{ steps.calculateartifactnames.outputs.BcptTestResultsArtifactsName }}
- PageScriptingTestResultsArtifactsName:
- description: Artifacts name for PageScriptingTestResults
- value: ${{ steps.calculateartifactnames.outputs.PageScriptingTestResultsArtifactsName }}
- PageScriptingTestResultDetailsArtifactsName:
- description: Artifacts name for PageScriptingTestResultDetails
- value: ${{ steps.calculateartifactnames.outputs.PageScriptingTestResultDetailsArtifactsName }}
- BuildOutputArtifactsName:
- description: Artifacts name for BuildOutput
- value: ${{ steps.calculateartifactnames.outputs.BuildOutputArtifactsName }}
- ContainerEventLogArtifactsName:
- description: Artifacts name for ContainerEventLog
- value: ${{ steps.calculateartifactnames.outputs.ContainerEventLogArtifactsName }}
- ErrorLogsArtifactsName:
- description: Artifacts name for ErrorLogs
- value: ${{ steps.calculateartifactnames.outputs.ErrorLogsArtifactsName }}
- BuildMode:
- description: Build mode used when building the artifacts
- value: ${{ steps.calculateartifactnames.outputs.BuildMode }}
-runs:
- using: composite
- steps:
- - name: run
- shell: ${{ inputs.shell }}
- id: calculateartifactnames
- env:
- _project: ${{ inputs.project }}
- _buildMode: ${{ inputs.buildMode }}
- _suffix: ${{ inputs.suffix }}
- run: |
- ${{ github.action_path }}/../Invoke-AlGoAction.ps1 -ActionName "CalculateArtifactNames" -Action {
- ${{ github.action_path }}/CalculateArtifactNames.ps1 -project $ENV:_project -buildMode $ENV:_buildMode -suffix $ENV:_suffix
- }
-branding:
- icon: terminal
- color: blue
+name: Calculate Artifact Names
+author: Microsoft Corporation
+inputs:
+ shell:
+ description: Shell in which you want to run the action (powershell or pwsh)
+ required: false
+ default: powershell
+ project:
+ description: Name of the built project
+ required: true
+ buildMode:
+ description: Build mode used when building the artifacts
+ required: true
+ suffix:
+ description: Suffix to add to the artifacts names
+ required: false
+ default: ''
+outputs:
+ AppsArtifactsName:
+ description: Artifacts name for Apps
+ value: ${{ steps.calculateartifactnames.outputs.AppsArtifactsName }}
+ PowerPlatformSolutionArtifactsName:
+ description: Artifacts name for PowerPlatform Solution
+ value: ${{ steps.calculateartifactnames.outputs.PowerPlatformSolutionArtifactsName }}
+ DependenciesArtifactsName:
+ description: Artifacts name for Dependencies
+ value: ${{ steps.calculateartifactnames.outputs.DependenciesArtifactsName }}
+ TestAppsArtifactsName:
+ description: Artifacts name for TestApps
+ value: ${{ steps.calculateartifactnames.outputs.TestAppsArtifactsName }}
+ TestResultsArtifactsName:
+ description: Artifacts name for TestResults
+ value: ${{ steps.calculateartifactnames.outputs.TestResultsArtifactsName }}
+ BcptTestResultsArtifactsName:
+ description: Artifacts name for BcptTestResults
+ value: ${{ steps.calculateartifactnames.outputs.BcptTestResultsArtifactsName }}
+ PageScriptingTestResultsArtifactsName:
+ description: Artifacts name for PageScriptingTestResults
+ value: ${{ steps.calculateartifactnames.outputs.PageScriptingTestResultsArtifactsName }}
+ PageScriptingTestResultDetailsArtifactsName:
+ description: Artifacts name for PageScriptingTestResultDetails
+ value: ${{ steps.calculateartifactnames.outputs.PageScriptingTestResultDetailsArtifactsName }}
+ BuildOutputArtifactsName:
+ description: Artifacts name for BuildOutput
+ value: ${{ steps.calculateartifactnames.outputs.BuildOutputArtifactsName }}
+ ContainerEventLogArtifactsName:
+ description: Artifacts name for ContainerEventLog
+ value: ${{ steps.calculateartifactnames.outputs.ContainerEventLogArtifactsName }}
+ ErrorLogsArtifactsName:
+ description: Artifacts name for ErrorLogs
+ value: ${{ steps.calculateartifactnames.outputs.ErrorLogsArtifactsName }}
+ CodeCoverageArtifactsName:
+ description: Artifacts name for CodeCoverage
+ value: ${{ steps.calculateartifactnames.outputs.CodeCoverageArtifactsName }}
+ BuildMode:
+ description: Build mode used when building the artifacts
+ value: ${{ steps.calculateartifactnames.outputs.BuildMode }}
+runs:
+ using: composite
+ steps:
+ - name: run
+ shell: ${{ inputs.shell }}
+ id: calculateartifactnames
+ env:
+ _project: ${{ inputs.project }}
+ _buildMode: ${{ inputs.buildMode }}
+ _suffix: ${{ inputs.suffix }}
+ run: |
+ ${{ github.action_path }}/../Invoke-AlGoAction.ps1 -ActionName "CalculateArtifactNames" -Action {
+ ${{ github.action_path }}/CalculateArtifactNames.ps1 -project $ENV:_project -buildMode $ENV:_buildMode -suffix $ENV:_suffix
+ }
+branding:
+ icon: terminal
+ color: blue
diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1
index 3482ce5d0c..0bfeddf729 100644
--- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1
+++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1
@@ -264,6 +264,10 @@ function ModifyBuildWorkflows {
if ($codeAnalysisUpload) {
$postProcessNeeds += @('CodeAnalysisUpload')
}
+ $mergeCoverage = $yaml.Get('jobs:/MergeCoverage:/')
+ if ($mergeCoverage) {
+ $postProcessNeeds += @('MergeCoverage')
+ }
if ($postProcess) {
$postProcess.Replace('needs:', "needs: [ $($postProcessNeeds -join ', ') ]")
$yaml.Replace('jobs:/PostProcess:/', $postProcess.content)
diff --git a/Actions/MergeCoverageSummaries/CoberturaMerger.psm1 b/Actions/MergeCoverageSummaries/CoberturaMerger.psm1
new file mode 100644
index 0000000000..a71314aaec
--- /dev/null
+++ b/Actions/MergeCoverageSummaries/CoberturaMerger.psm1
@@ -0,0 +1,293 @@
+<#
+.SYNOPSIS
+ Merges multiple Cobertura XML coverage files into a single consolidated report
+.DESCRIPTION
+ Reads cobertura.xml files from multiple build jobs, unions coverage data by
+ filename+line, and produces a single merged cobertura.xml. For duplicate lines
+ (same file, same line number), takes the maximum hits across all inputs.
+#>
+
+<#
+.SYNOPSIS
+ Merges multiple Cobertura XML files into one
+.PARAMETER CoberturaFiles
+ Array of paths to cobertura.xml files
+.PARAMETER OutputPath
+ Path where the merged cobertura.xml should be written
+.OUTPUTS
+ Merged coverage statistics object
+#>
+function Merge-CoberturaFiles {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$CoberturaFiles,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+ )
+
+ Write-Host "Merging $($CoberturaFiles.Count) Cobertura files..."
+
+ # Parse all files and collect line data per class
+ # Key: filename -> line number -> { hits, branch }
+ $classData = @{} # filename -> @{ name, lines = @{} }
+ $packageData = @{} # package name -> set of filenames
+
+ foreach ($file in $CoberturaFiles) {
+ if (-not (Test-Path $file)) {
+ Write-Warning "Cobertura file not found: $file"
+ continue
+ }
+
+ Write-Host " Reading: $file"
+ try {
+ [xml]$xml = Get-Content -Path $file -Encoding UTF8 -ErrorAction Stop
+ }
+ catch {
+ Write-Warning "Failed to read or parse $file`: $($_.Exception.Message)"
+ continue
+ }
+
+ $packagesNode = $xml.coverage.SelectSingleNode('packages')
+ if (-not $packagesNode -or -not $packagesNode.HasChildNodes) { continue }
+
+ foreach ($package in $packagesNode.package) {
+ $pkgName = $package.name
+
+ $classesNode = $package.SelectSingleNode('classes')
+ if (-not $classesNode -or -not $classesNode.HasChildNodes) { continue }
+
+ foreach ($class in $classesNode.class) {
+ $filename = $class.filename
+ $className = $class.name
+
+ if (-not $classData.ContainsKey($filename)) {
+ $classData[$filename] = @{
+ Name = $className
+ Package = $pkgName
+ Lines = @{}
+ }
+ }
+
+ # Track which package this class belongs to
+ if (-not $packageData.ContainsKey($pkgName)) {
+ $packageData[$pkgName] = [System.Collections.Generic.HashSet[string]]::new()
+ }
+ $packageData[$pkgName].Add($filename) | Out-Null
+
+ # Merge lines: take max hits for each line number
+ $linesNode = $class.SelectSingleNode('lines')
+ if ($linesNode -and $linesNode.HasChildNodes) {
+ foreach ($line in $linesNode.line) {
+ $lineNum = [int]$line.number
+ $hits = [int]$line.hits
+ $branch = $line.branch
+
+ if ($classData[$filename].Lines.ContainsKey($lineNum)) {
+ $existing = $classData[$filename].Lines[$lineNum]
+ if ($hits -gt $existing.Hits) {
+ $classData[$filename].Lines[$lineNum].Hits = $hits
+ }
+ } else {
+ $classData[$filename].Lines[$lineNum] = @{
+ Hits = $hits
+ Branch = $branch
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Write-Host " Merged to $($classData.Count) unique classes"
+
+ # Build merged XML
+ $xml = New-Object System.Xml.XmlDocument
+ $declaration = $xml.CreateXmlDeclaration("1.0", "UTF-8", $null)
+ $xml.AppendChild($declaration) | Out-Null
+
+ # Calculate overall stats
+ $totalLines = 0
+ $coveredLines = 0
+
+ foreach ($cls in $classData.Values) {
+ $totalLines += $cls.Lines.Count
+ $coveredLines += @($cls.Lines.Values | Where-Object { $_.Hits -gt 0 }).Count
+ }
+
+ $lineRate = if ($totalLines -gt 0) { [math]::Round($coveredLines / $totalLines, 4) } else { 0 }
+
+ $coverage = $xml.CreateElement("coverage")
+ $coverage.SetAttribute("line-rate", $lineRate.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $coverage.SetAttribute("branch-rate", "0")
+ $coverage.SetAttribute("lines-covered", $coveredLines.ToString())
+ $coverage.SetAttribute("lines-valid", $totalLines.ToString())
+ $coverage.SetAttribute("branches-covered", "0")
+ $coverage.SetAttribute("branches-valid", "0")
+ $coverage.SetAttribute("complexity", "0")
+ $coverage.SetAttribute("version", "1.0")
+ $coverage.SetAttribute("timestamp", [DateTimeOffset]::Now.ToUnixTimeSeconds().ToString())
+ $xml.AppendChild($coverage) | Out-Null
+
+ $sources = $xml.CreateElement("sources")
+ $source = $xml.CreateElement("source")
+ $source.InnerText = "."
+ $sources.AppendChild($source) | Out-Null
+ $coverage.AppendChild($sources) | Out-Null
+
+ $packagesElement = $xml.CreateElement("packages")
+ $coverage.AppendChild($packagesElement) | Out-Null
+
+ # Group classes by package
+ $packageGroups = @{}
+ foreach ($filename in $classData.Keys) {
+ $pkgName = $classData[$filename].Package
+ if (-not $packageGroups.ContainsKey($pkgName)) {
+ $packageGroups[$pkgName] = @()
+ }
+ $packageGroups[$pkgName] += $filename
+ }
+
+ foreach ($pkgName in $packageGroups.Keys | Sort-Object) {
+ $pkgTotalLines = 0
+ $pkgCoveredLines = 0
+
+ $package = $xml.CreateElement("package")
+ $classes = $xml.CreateElement("classes")
+
+ foreach ($filename in $packageGroups[$pkgName] | Sort-Object) {
+ $cls = $classData[$filename]
+
+ $clsTotalLines = $cls.Lines.Count
+ $clsCoveredLines = @($cls.Lines.Values | Where-Object { $_.Hits -gt 0 }).Count
+ $clsLineRate = if ($clsTotalLines -gt 0) { [math]::Round($clsCoveredLines / $clsTotalLines, 4) } else { 0 }
+
+ $classElement = $xml.CreateElement("class")
+ $classElement.SetAttribute("name", $cls.Name)
+ $classElement.SetAttribute("filename", $filename)
+ $classElement.SetAttribute("line-rate", $clsLineRate.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $classElement.SetAttribute("branch-rate", "0")
+ $classElement.SetAttribute("complexity", "0")
+
+ # Methods (omitted in merged output — per-job detail is sufficient)
+ $methods = $xml.CreateElement("methods")
+ $classElement.AppendChild($methods) | Out-Null
+
+ # Lines
+ $linesElement = $xml.CreateElement("lines")
+ foreach ($lineNum in $cls.Lines.Keys | Sort-Object { [int]$_ }) {
+ $lineData = $cls.Lines[$lineNum]
+ $lineElement = $xml.CreateElement("line")
+ $lineElement.SetAttribute("number", $lineNum.ToString())
+ $lineElement.SetAttribute("hits", $lineData.Hits.ToString())
+ $branchValue = if ($lineData.Branch) { $lineData.Branch } else { "false" }
+ $lineElement.SetAttribute("branch", $branchValue)
+ $linesElement.AppendChild($lineElement) | Out-Null
+ }
+ $classElement.AppendChild($linesElement) | Out-Null
+ $classes.AppendChild($classElement) | Out-Null
+
+ $pkgTotalLines += $clsTotalLines
+ $pkgCoveredLines += $clsCoveredLines
+ }
+
+ $pkgLineRate = if ($pkgTotalLines -gt 0) { [math]::Round($pkgCoveredLines / $pkgTotalLines, 4) } else { 0 }
+ $package.SetAttribute("name", $pkgName)
+ $package.SetAttribute("line-rate", $pkgLineRate.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $package.SetAttribute("branch-rate", "0")
+ $package.SetAttribute("complexity", "0")
+ $package.AppendChild($classes) | Out-Null
+ $packagesElement.AppendChild($package) | Out-Null
+ }
+
+ # Save
+ $outputDir = Split-Path $OutputPath -Parent
+ if ($outputDir -and -not (Test-Path $outputDir)) {
+ New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
+ }
+ $xml.Save($OutputPath)
+
+ $coveragePercent = if ($totalLines -gt 0) {
+ [math]::Round(($coveredLines / $totalLines) * 100, 2)
+ } else { 0 }
+
+ Write-Host "`n=== Merged Coverage Summary ==="
+ Write-Host " Input files: $($CoberturaFiles.Count)"
+ Write-Host " Classes: $($classData.Count)"
+ Write-Host " Total lines: $totalLines"
+ Write-Host " Covered lines: $coveredLines"
+ Write-Host " Coverage: $coveragePercent%"
+ Write-Host "================================`n"
+
+ return [PSCustomObject]@{
+ TotalLines = $totalLines
+ CoveredLines = $coveredLines
+ CoveragePercent = $coveragePercent
+ LineRate = $lineRate
+ ClassCount = $classData.Count
+ PackageCount = $packageGroups.Count
+ InputFileCount = $CoberturaFiles.Count
+ }
+}
+
+<#
+.SYNOPSIS
+ Merges stats.json files from multiple coverage runs
+.PARAMETER StatsFiles
+ Array of paths to cobertura.stats.json files
+.OUTPUTS
+ Merged stats object with combined AppSourcePaths and excluded object data
+#>
+function Merge-CoverageStats {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$StatsFiles
+ )
+
+ $allAppSourcePaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ $totalExcludedObjects = 0
+ $totalExcludedLinesExecuted = 0
+ $totalExcludedTotalHits = 0
+
+ foreach ($file in $StatsFiles) {
+ if (-not (Test-Path $file)) { continue }
+ try {
+ $stats = Get-Content -Path $file -Encoding UTF8 | ConvertFrom-Json
+
+ # Collect unique app source paths
+ if ($stats | Get-Member -Name 'AppSourcePaths' -MemberType NoteProperty) {
+ foreach ($path in $stats.AppSourcePaths) {
+ $allAppSourcePaths.Add($path) | Out-Null
+ }
+ }
+
+ # Sum excluded object stats
+ if ($stats | Get-Member -Name 'ExcludedObjectCount' -MemberType NoteProperty) {
+ $totalExcludedObjects += $stats.ExcludedObjectCount
+ }
+ if ($stats | Get-Member -Name 'ExcludedLinesExecuted' -MemberType NoteProperty) {
+ $totalExcludedLinesExecuted += $stats.ExcludedLinesExecuted
+ }
+ if ($stats | Get-Member -Name 'ExcludedTotalHits' -MemberType NoteProperty) {
+ $totalExcludedTotalHits += $stats.ExcludedTotalHits
+ }
+ } catch {
+ Write-Warning "Could not parse stats file $file`: $($_.Exception.Message)"
+ }
+ }
+
+ return [PSCustomObject]@{
+ AppSourcePaths = @($allAppSourcePaths)
+ ExcludedObjectCount = $totalExcludedObjects
+ ExcludedLinesExecuted = $totalExcludedLinesExecuted
+ ExcludedTotalHits = $totalExcludedTotalHits
+ }
+}
+
+Export-ModuleMember -Function @(
+ 'Merge-CoberturaFiles',
+ 'Merge-CoverageStats'
+)
diff --git a/Actions/MergeCoverageSummaries/MergeCoverageSummaries.ps1 b/Actions/MergeCoverageSummaries/MergeCoverageSummaries.ps1
new file mode 100644
index 0000000000..8c741bc3a6
--- /dev/null
+++ b/Actions/MergeCoverageSummaries/MergeCoverageSummaries.ps1
@@ -0,0 +1,107 @@
+Param(
+ [Parameter(HelpMessage = "Path containing downloaded coverage artifacts", Mandatory = $true)]
+ [string] $coveragePath,
+ [Parameter(HelpMessage = "Path to source code checkout", Mandatory = $false)]
+ [string] $sourcePath = ''
+)
+
+. (Join-Path -Path $PSScriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve)
+. (Join-Path -Path $PSScriptRoot -ChildPath "..\BuildCodeCoverageSummary\CoverageReportGenerator.ps1" -Resolve)
+Import-Module (Join-Path -Path $PSScriptRoot -ChildPath "CoberturaMerger.psm1" -Resolve) -Force -DisableNameChecking
+
+if (-not (Test-Path $coveragePath)) {
+ Write-Host "No coverage artifacts found at: $coveragePath"
+ Write-Host "Skipping coverage merge."
+ return
+}
+
+# Find all cobertura.xml files in subdirectories (each artifact is in its own subfolder)
+$coberturaFiles = @(Get-ChildItem -Path $coveragePath -Filter "cobertura.xml" -Recurse -File)
+
+if ($coberturaFiles.Count -eq 0) {
+ Write-Host "No cobertura.xml files found under: $coveragePath"
+ return
+}
+
+Write-Host "Found $($coberturaFiles.Count) coverage file(s) to merge:"
+$coberturaFiles | ForEach-Object {
+ $artifactName = (Split-Path (Split-Path $_.FullName -Parent) -Leaf)
+ Write-Host " [$artifactName] $($_.FullName)"
+}
+
+if ($coberturaFiles.Count -eq 1) {
+ Write-Host "Only one coverage file found, using it directly (no merge needed)"
+ $mergedFile = $coberturaFiles[0].FullName
+} else {
+ # Merge all cobertura files
+ $mergedOutputDir = Join-Path $coveragePath "_merged"
+ $mergedFile = Join-Path $mergedOutputDir "cobertura.xml"
+
+ Merge-CoberturaFiles `
+ -CoberturaFiles ($coberturaFiles.FullName) `
+ -OutputPath $mergedFile | Out-Null
+}
+
+# Merge stats.json files for metadata (app source paths, excluded objects)
+$statsFiles = @(Get-ChildItem -Path $coveragePath -Filter "cobertura.stats.json" -Recurse -File)
+$mergedStats = $null
+if ($statsFiles.Count -gt 0) {
+ $mergedStats = Merge-CoverageStats -StatsFiles ($statsFiles.FullName)
+
+ # Save merged stats alongside merged cobertura
+ $mergedStatsFile = [System.IO.Path]::ChangeExtension($mergedFile, '.stats.json')
+ $mergedStats | ConvertTo-Json -Depth 10 | Set-Content -Path $mergedStatsFile -Encoding UTF8
+ Write-Host "Saved merged stats to: $mergedStatsFile"
+}
+
+# Generate consolidated coverage summary
+Write-Host "`nGenerating consolidated coverage summary..."
+$coverageResult = Get-CoverageSummaryMD -CoverageFile $mergedFile
+
+if ($coverageResult.SummaryMD) {
+ # Helper function to calculate byte size
+ function GetStringByteSize($string) {
+ return [System.Text.Encoding]::UTF8.GetBytes($string).Length
+ }
+
+ $header = "## :bar_chart: Code Coverage - Consolidated`n`n"
+ $inputInfo = ":information_source: Merged from **$($coberturaFiles.Count)** build job(s)`n`n"
+
+ # Warn if build had failures (some jobs may not have produced coverage data)
+ $incompleteWarning = ""
+ if ($env:BUILD_RESULT -eq 'failure') {
+ $incompleteWarning = "> :warning: **Incomplete coverage data** - some build jobs failed and did not produce coverage results. Actual coverage may be higher than reported.`n`n"
+ OutputWarning -message "Coverage data is incomplete - some build jobs failed and did not produce coverage results."
+ }
+ $headerSize = GetStringByteSize($header)
+ $inputInfoSize = GetStringByteSize($inputInfo)
+ $warningSize = GetStringByteSize($incompleteWarning)
+ $summarySize = GetStringByteSize($coverageResult.SummaryMD)
+ $detailsSize = GetStringByteSize($coverageResult.DetailsMD)
+
+ # GitHub job summaries are limited to just under 1MB
+ $coverageSummaryMD = $coverageResult.SummaryMD
+ $coverageDetailsMD = $coverageResult.DetailsMD
+
+ if ($headerSize + $inputInfoSize + $warningSize + $summarySize -gt (1MB - 4)) {
+ $coverageSummaryMD = "Coverage summary size exceeds GitHub summary capacity."
+ $summarySize = GetStringByteSize($coverageSummaryMD)
+ }
+ if ($headerSize + $inputInfoSize + $warningSize + $summarySize + $detailsSize -gt (1MB - 4)) {
+ $coverageDetailsMD = "Coverage details truncated due to size limits."
+ }
+
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value $header
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value $inputInfo
+ if ($incompleteWarning) {
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value $incompleteWarning
+ }
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value "$($coverageSummaryMD.Replace("\n","`n"))`n`n"
+ Add-Content -Encoding UTF8 -Path $ENV:GITHUB_STEP_SUMMARY -Value "$($coverageDetailsMD.Replace("\n","`n"))`n`n"
+
+ Write-Host "Coverage summary written to GITHUB_STEP_SUMMARY"
+}
+
+# Set output
+Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "mergedCoverageFile=$mergedFile"
+Write-Host "Merged coverage file: $mergedFile"
diff --git a/Actions/MergeCoverageSummaries/README.md b/Actions/MergeCoverageSummaries/README.md
new file mode 100644
index 0000000000..7c8382d051
--- /dev/null
+++ b/Actions/MergeCoverageSummaries/README.md
@@ -0,0 +1,96 @@
+# Merge Code Coverage Summaries
+
+Merges multiple Cobertura XML coverage files from different build jobs into a single consolidated coverage report and generates a GitHub Job Summary with the combined results.
+
+## Usage
+
+```yaml
+- name: Merge Coverage Summaries
+ uses: microsoft/AL-Go/Actions/MergeCoverageSummaries@main
+ with:
+ shell: powershell
+ coveragePath: '.coverage-inputs'
+ sourcePath: '.'
+```
+
+## Inputs
+
+| Name | Description | Required | Default |
+|------|-------------|----------|---------|
+| `shell` | Shell to run the action in (powershell or pwsh) | No | `powershell` |
+| `coveragePath` | Path containing downloaded coverage artifacts (each in a subfolder) | Yes | |
+| `sourcePath` | Path to the source code checkout (for app root path resolution) | No | `''` |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| `mergedCoverageFile` | Path to the merged Cobertura XML file |
+
+## How It Works
+
+1. **Finds Coverage Files**: Recursively searches `coveragePath` for all `cobertura.xml` files
+1. **Merges Coverage Data**:
+ - Combines coverage from multiple files
+ - Takes maximum hit count when same line appears in multiple files
+ - Recalculates overall statistics
+1. **Merges Metadata**: Consolidates `.stats.json` files (app source paths, excluded objects)
+1. **Generates Summary**: Creates consolidated GitHub Job Summary with:
+ - Overall coverage percentage with visual indicator
+ - Information about number of build jobs merged
+ - Warning if some jobs failed (incomplete coverage)
+ - Coverage by module/object table
+ - Method-level details (collapsible)
+
+## Coverage Status Icons
+
+| Icon | Coverage Level |
+|------|---------------|
+| 🟢 | >= 80% (Good) |
+| 🟡 | >= 50% (Needs Improvement) |
+| 🔴 | < 50% (Low) |
+
+## Prerequisites
+
+This action expects coverage artifacts to be downloaded with each artifact in its own subfolder, for example:
+
+```
+.coverage-inputs/
+ ├─ Project1-main-CodeCoverage-1.0.0.0/
+ │ └─ cobertura.xml
+ ├─ Project2-main-CodeCoverage-1.0.0.0/
+ │ └─ cobertura.xml
+ └─ ...
+```
+
+The coverage files are generated by the `RunPipeline` action when code coverage is enabled and published as build artifacts.
+
+## Example Workflow Integration
+
+```yaml
+MergeCoverage:
+ needs: [ Build ]
+ if: (!cancelled())
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Download Coverage Artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: '*CodeCoverage*'
+ path: .coverage-inputs
+
+ - name: Merge Coverage
+ uses: microsoft/AL-Go/Actions/MergeCoverageSummaries@main
+ with:
+ coveragePath: '.coverage-inputs'
+ sourcePath: '.'
+
+ - name: Publish Merged Coverage
+ uses: actions/upload-artifact@v4
+ with:
+ name: MergedCodeCoverage
+ path: .coverage-inputs/_merged
+```
diff --git a/Actions/MergeCoverageSummaries/action.yaml b/Actions/MergeCoverageSummaries/action.yaml
new file mode 100644
index 0000000000..0278b6f49f
--- /dev/null
+++ b/Actions/MergeCoverageSummaries/action.yaml
@@ -0,0 +1,34 @@
+name: Merge Code Coverage Summaries
+author: Microsoft Corporation
+inputs:
+ shell:
+ description: Shell in which you want to run the action (powershell or pwsh)
+ required: false
+ default: powershell
+ coveragePath:
+ description: Path containing downloaded coverage artifacts (each in a subfolder)
+ required: true
+ sourcePath:
+ description: Path to the source code checkout (for app root path resolution)
+ required: false
+ default: ''
+outputs:
+ mergedCoverageFile:
+ description: Path to the merged Cobertura XML file
+ value: ${{ steps.merge.outputs.mergedCoverageFile }}
+runs:
+ using: composite
+ steps:
+ - name: run
+ shell: ${{ inputs.shell }}
+ id: merge
+ env:
+ _coveragePath: ${{ inputs.coveragePath }}
+ _sourcePath: ${{ inputs.sourcePath }}
+ run: |
+ ${{ github.action_path }}/../Invoke-AlGoAction.ps1 -ActionName "MergeCoverageSummaries" -Action {
+ ${{ github.action_path }}/MergeCoverageSummaries.ps1 -coveragePath $ENV:_coveragePath -sourcePath $ENV:_sourcePath
+ }
+branding:
+ icon: bar-chart-2
+ color: blue
diff --git a/Actions/RunPipeline/RunPipeline.ps1 b/Actions/RunPipeline/RunPipeline.ps1
index 730882629a..4898e6efac 100644
--- a/Actions/RunPipeline/RunPipeline.ps1
+++ b/Actions/RunPipeline/RunPipeline.ps1
@@ -14,7 +14,9 @@ Param(
[Parameter(HelpMessage = "RunId of the baseline workflow run", Mandatory = $false)]
[string] $baselineWorkflowRunId = '0',
[Parameter(HelpMessage = "SHA of the baseline workflow run", Mandatory = $false)]
- [string] $baselineWorkflowSHA = ''
+ [string] $baselineWorkflowSHA = '',
+ [Parameter(HelpMessage = "Dependencies of the built project in compressed JSON format", Mandatory = $false)]
+ [string] $projectDependenciesJson = '{}'
)
$containerBaseFolder = $null
@@ -26,6 +28,10 @@ try {
DownloadAndImportBcContainerHelper
Import-Module (Join-Path -Path $PSScriptRoot -ChildPath "..\DetermineProjectsToBuild\DetermineProjectsToBuild.psm1" -Resolve) -DisableNameChecking
+ # Import Code Coverage module for ALTestRunner functionality
+ # This makes Run-AlTests available globally for custom RunTestsInBcContainer overrides
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath "..\.Modules\TestRunner\ALTestRunner.psm1" -Resolve) -Force -DisableNameChecking
+
if ($isWindows) {
Assert-DockerIsRunning
# Pull docker image in the background
@@ -461,6 +467,202 @@ try {
}
}
+ # Add RunTestsInBcContainer override to use ALTestRunner with code coverage support
+ if ($settings.enableCodeCoverage) {
+ # Read codeCoverageSetup settings with defaults
+ $codeCoverageSetup = if ($settings.PSObject.Properties.Name -contains 'codeCoverageSetup') { $settings.codeCoverageSetup } else { $null }
+ $ccSetup = @{}
+ if ($codeCoverageSetup) {
+ $codeCoverageSetup.PSObject.Properties | ForEach-Object { $ccSetup[$_.Name] = $_.Value }
+ }
+ $ccTrackingType = if ($ccSetup['trackingType']) { $ccSetup['trackingType'] } else { 'PerRun' }
+ $ccProduceMap = if ($ccSetup['produceCodeCoverageMap']) { $ccSetup['produceCodeCoverageMap'] } else { 'PerCodeunit' }
+ [string[]]$ccExcludePatterns = @()
+ if ($ccSetup['excludeFilesPattern']) { $ccExcludePatterns = @($ccSetup['excludeFilesPattern']) }
+ if ($ccExcludePatterns.Count -gt 0) {
+ Write-Host "Code coverage exclude patterns: $($ccExcludePatterns -join ', ')"
+ }
+
+ if ($runAlPipelineParams.Keys -notcontains 'RunTestsInBcContainer') {
+ Write-Host "Adding RunTestsInBcContainer override with code coverage support"
+
+ # Capture variables for use in scriptblock
+ $ccBuildArtifactFolder = $buildArtifactFolder
+ $ccTrackingTypeCapture = $ccTrackingType
+ $ccProduceMapCapture = $ccProduceMap
+
+ $runAlPipelineParams += @{
+ "RunTestsInBcContainer" = {
+ Param([Hashtable]$parameters)
+
+ $containerName = $parameters.containerName
+ $credential = $parameters.credential
+ $extensionId = $parameters.extensionId
+ $appName = $parameters.appName
+
+ # Handle both JUnit and XUnit result file names
+ $resultsFilePath = $null
+ $resultsFormat = 'JUnit'
+ if ($parameters.JUnitResultFileName) {
+ $resultsFilePath = $parameters.JUnitResultFileName
+ $resultsFormat = 'JUnit'
+ } elseif ($parameters.XUnitResultFileName) {
+ $resultsFilePath = $parameters.XUnitResultFileName
+ $resultsFormat = 'XUnit'
+ }
+
+ # Handle append mode for result file accumulation across test apps
+ $appendToResults = $false
+ $tempResultsFilePath = $null
+ if ($resultsFilePath -and ($parameters.AppendToJUnitResultFile -or $parameters.AppendToXUnitResultFile)) {
+ $appendToResults = $true
+ $tempResultsFilePath = Join-Path ([System.IO.Path]::GetDirectoryName($resultsFilePath)) "TempTestResults_$([Guid]::NewGuid().ToString('N')).xml"
+ }
+
+ # Get container web client URL for connecting from host
+ $containerConfig = Get-BcContainerServerConfiguration -ContainerName $containerName
+ $publicWebBaseUrl = $containerConfig.PublicWebBaseUrl
+ if (-not $publicWebBaseUrl) {
+ # Fallback to constructing URL from container name
+ $publicWebBaseUrl = "http://$($containerName):80/BC/"
+ }
+ # Ensure tenant parameter is included (required for client services connection)
+ $tenant = if ($parameters.tenant) { $parameters.tenant } else { "default" }
+ if ($publicWebBaseUrl -notlike "*tenant=*") {
+ if ($publicWebBaseUrl.Contains("?")) {
+ $serviceUrl = "$publicWebBaseUrl&tenant=$tenant"
+ } else {
+ $serviceUrl = "$($publicWebBaseUrl.TrimEnd('/'))/?tenant=$tenant"
+ }
+ } else {
+ $serviceUrl = $publicWebBaseUrl
+ }
+ Write-Host "Using ServiceUrl: $serviceUrl"
+
+ # Code coverage output path
+ $codeCoverageOutputPath = Join-Path $ccBuildArtifactFolder "CodeCoverage"
+ if (-not (Test-Path $codeCoverageOutputPath)) {
+ New-Item -Path $codeCoverageOutputPath -ItemType Directory | Out-Null
+ }
+ Write-Host "Code coverage output path: $codeCoverageOutputPath"
+
+ # Run tests with ALTestRunner from the host
+ $testRunParams = @{
+ ServiceUrl = $serviceUrl
+ Credential = $credential
+ AutorizationType = 'NavUserPassword'
+ TestSuite = if ($parameters.testSuite) { $parameters.testSuite } else { 'DEFAULT' }
+ Detailed = $true
+ DisableSSLVerification = $true
+ ResultsFormat = $resultsFormat
+ CodeCoverageTrackingType = $ccTrackingTypeCapture
+ ProduceCodeCoverageMap = $ccProduceMapCapture
+ CodeCoverageOutputPath = $codeCoverageOutputPath
+ CodeCoverageFilePrefix = "CodeCoverage_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
+ }
+
+ if ($extensionId) {
+ $testRunParams.ExtensionId = $extensionId
+ }
+
+ if ($appName) {
+ $testRunParams.AppName = $appName
+ }
+
+ if ($resultsFilePath) {
+ $testRunParams.ResultsFilePath = if ($appendToResults) { $tempResultsFilePath } else { $resultsFilePath }
+ $testRunParams.SaveResultFile = $true
+ }
+
+ # Forward optional pipeline parameters
+ if ($parameters.disabledTests) {
+ $testRunParams.DisabledTests = $parameters.disabledTests
+ }
+ if ($parameters.testCodeunitRange) {
+ $testRunParams.TestCodeunitsRange = $parameters.testCodeunitRange
+ }
+ elseif ($parameters.testCodeunit -and $parameters.testCodeunit -ne "*") {
+ $testRunParams.TestCodeunitsRange = $parameters.testCodeunit
+ }
+ if ($parameters.testFunction -and $parameters.testFunction -ne "*") {
+ $testRunParams.TestProcedureRange = $parameters.testFunction
+ }
+ if ($parameters.requiredTestIsolation) {
+ $testRunParams.RequiredTestIsolation = $parameters.requiredTestIsolation
+ }
+ if ($parameters.testType) {
+ $testRunParams.TestType = $parameters.testType
+ }
+ if ($parameters.testRunnerCodeunitId) {
+ # Map BCApps test runner codeunit IDs to Run-AlTests TestIsolation values
+ # 130450 = Codeunit isolation (default), 130451 = Disabled isolation
+ $testRunParams.TestIsolation = if ($parameters.testRunnerCodeunitId -eq "130451") { "Disabled" } else { "Codeunit" }
+ }
+
+ Run-AlTests @testRunParams
+
+ # Determine which file to check for this app's results
+ $checkResultsFile = if ($appendToResults) { $tempResultsFilePath } else { $resultsFilePath }
+ $testsPassed = $true
+
+ if ($checkResultsFile -and (Test-Path $checkResultsFile)) {
+ # Parse results to determine pass/fail
+ try {
+ [xml]$testResults = Get-Content $checkResultsFile
+ if ($testResults.testsuites) {
+ $failures = 0; $errors = 0
+ if ($testResults.testsuites.testsuite) {
+ foreach ($ts in $testResults.testsuites.testsuite) {
+ if ($ts.failures) { $failures += [int]$ts.failures }
+ if ($ts.errors) { $errors += [int]$ts.errors }
+ }
+ }
+ $testsPassed = ($failures -eq 0 -and $errors -eq 0)
+ }
+ elseif ($testResults.assemblies) {
+ $failed = if ($testResults.assemblies.assembly.failed) { [int]$testResults.assemblies.assembly.failed } else { 0 }
+ $testsPassed = ($failed -eq 0)
+ }
+ }
+ catch {
+ Write-Host "Warning: Could not parse test results file: $_"
+ }
+
+ # Merge this app's results into the consolidated file if append mode
+ if ($appendToResults) {
+ if (-not (Test-Path $resultsFilePath)) {
+ Copy-Item -Path $tempResultsFilePath -Destination $resultsFilePath
+ }
+ else {
+ try {
+ [xml]$source = Get-Content $tempResultsFilePath
+ [xml]$target = Get-Content $resultsFilePath
+ $rootElement = if ($resultsFormat -eq 'JUnit') { 'testsuites' } else { 'assemblies' }
+ foreach ($node in $source.$rootElement.ChildNodes) {
+ if ($node.NodeType -eq 'Element') {
+ $imported = $target.ImportNode($node, $true)
+ $target.$rootElement.AppendChild($imported) | Out-Null
+ }
+ }
+ $target.Save($resultsFilePath)
+ }
+ catch {
+ Write-Host "Warning: Could not merge test results, copying instead: $_"
+ Copy-Item -Path $tempResultsFilePath -Destination $resultsFilePath -Force
+ }
+ }
+ Remove-Item $tempResultsFilePath -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ return $testsPassed
+ }.GetNewClosure()
+ }
+ } else {
+ OutputWarning -message "enableCodeCoverage is set to true, but a custom RunTestsInBcContainer override was found. The custom override will be used and code coverage data may not be collected. To use the built-in code coverage support, remove your custom RunTestsInBcContainer override."
+ }
+ }
+
"enableTaskScheduler",
"assignPremiumPlan",
"doNotBuildTests",
@@ -499,6 +701,11 @@ try {
$runAlPipelineParams["preprocessorsymbols"] += $settings.preprocessorSymbols
}
+ # Set environment variable for buildArtifactFolder so custom override scripts can access it
+ # This is needed for code coverage support in repos with custom RunTestsInBcContainer overrides
+ $env:AL_GO_BUILD_ARTIFACT_FOLDER = $buildArtifactFolder
+ Write-Host "Build artifact folder: $buildArtifactFolder"
+
Write-Host "Invoke Run-AlPipeline with buildmode $buildMode"
Run-AlPipeline @runAlPipelineParams `
-accept_insiderEula `
@@ -557,6 +764,104 @@ try {
Copy-Item -Path $containerEventLogFile -Destination $destFolder -Force -ErrorAction SilentlyContinue
}
+ # Process code coverage files to Cobertura format
+ if ($settings.enableCodeCoverage) {
+ $codeCoveragePath = Join-Path $buildArtifactFolder "CodeCoverage"
+ if (Test-Path $codeCoveragePath) {
+ $coverageFiles = @(Get-ChildItem -Path $codeCoveragePath -Filter "*.dat" -File -ErrorAction SilentlyContinue)
+ if ($coverageFiles.Count -gt 0) {
+ Write-Host "Processing $($coverageFiles.Count) code coverage file(s) to Cobertura format..."
+ try {
+ $coverageProcessorModule = Join-Path $PSScriptRoot "..\.Modules\TestRunner\CoverageProcessor\CoverageProcessor.psm1"
+ Import-Module $coverageProcessorModule -Force -DisableNameChecking
+
+ $coberturaOutputPath = Join-Path $codeCoveragePath "cobertura.xml"
+
+ # Resolve app source paths for coverage denominator
+ # Collect appFolders from this project + parent projects (dependency chain)
+ # This ensures test-only projects measure coverage against the correct app source
+ $sourcePath = $ENV:GITHUB_WORKSPACE
+ $appSourcePaths = @()
+
+ # Add current project's app folders
+ if ($settings.appFolders -and $settings.appFolders.Count -gt 0) {
+ foreach ($folder in $settings.appFolders) {
+ $absPath = Join-Path $projectPath $folder
+ if (Test-Path $absPath) {
+ $appSourcePaths += @((Resolve-Path $absPath).Path)
+ }
+ }
+ Write-Host "Project app folders ($($appSourcePaths.Count) resolved):"
+ $appSourcePaths | ForEach-Object { Write-Host " $_" }
+ }
+
+ # Walk project dependencies to collect parent projects' app folders
+ try {
+ $projectDeps = $projectDependenciesJson | ConvertFrom-Json
+ $parentProjects = @()
+ if ($projectDeps -and $project -and $projectDeps.PSObject.Properties.Name -contains $project) {
+ $parentProjects = @($projectDeps.$project)
+ }
+ if ($parentProjects.Count -gt 0) {
+ Write-Host "Resolving app folders from $($parentProjects.Count) parent project(s): $($parentProjects -join ', ')"
+ foreach ($parentProject in $parentProjects) {
+ $parentSettings = ReadSettings -project $parentProject -baseFolder $baseFolder
+ ResolveProjectFolders -baseFolder $baseFolder -project $parentProject -projectSettings ([ref] $parentSettings)
+ $parentProjectPath = Join-Path $baseFolder $parentProject
+ if ($parentSettings.appFolders -and $parentSettings.appFolders.Count -gt 0) {
+ foreach ($folder in $parentSettings.appFolders) {
+ $absPath = Join-Path $parentProjectPath $folder
+ if (Test-Path $absPath) {
+ $resolved = (Resolve-Path $absPath).Path
+ if ($appSourcePaths -notcontains $resolved) {
+ $appSourcePaths += @($resolved)
+ Write-Host " + $resolved (from $parentProject)"
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ OutputWarning -message "Could not resolve project dependencies for coverage: $($_.Exception.Message)"
+ }
+
+ if ($appSourcePaths.Count -eq 0) {
+ Write-Host "No app source paths resolved, scanning entire workspace for source files"
+ } else {
+ Write-Host "Coverage source: $($appSourcePaths.Count) app folder(s) resolved"
+ }
+ Write-Host "Source path root: $sourcePath"
+
+ if ($coverageFiles.Count -eq 1) {
+ # Single coverage file
+ $coverageStats = Convert-BCCoverageToCobertura `
+ -CoverageFilePath $coverageFiles[0].FullName `
+ -SourcePath $sourcePath `
+ -AppSourcePaths $appSourcePaths `
+ -ExcludePatterns $ccExcludePatterns `
+ -OutputPath $coberturaOutputPath
+ } else {
+ # Multiple coverage files - merge them
+ $coverageStats = Merge-BCCoverageToCobertura `
+ -CoverageFiles ($coverageFiles.FullName) `
+ -SourcePath $sourcePath `
+ -AppSourcePaths $appSourcePaths `
+ -ExcludePatterns $ccExcludePatterns `
+ -OutputPath $coberturaOutputPath
+ }
+
+ if ($coverageStats) {
+ Write-Host "Code coverage: $($coverageStats.CoveragePercent)% ($($coverageStats.CoveredLines)/$($coverageStats.TotalLines) lines)"
+ }
+ }
+ catch {
+ OutputWarning -message "Failed to process code coverage to Cobertura format: $($_.Exception.Message)"
+ }
+ }
+ }
+ }
+
# check for new warnings
Import-Module (Join-Path $PSScriptRoot ".\CheckForWarningsUtils.psm1" -Resolve) -DisableNameChecking
diff --git a/Actions/RunPipeline/action.yaml b/Actions/RunPipeline/action.yaml
index cd5003f7b4..f75a4a2f54 100644
--- a/Actions/RunPipeline/action.yaml
+++ b/Actions/RunPipeline/action.yaml
@@ -37,6 +37,10 @@ inputs:
description: SHA of the baseline workflow run
required: false
default: ''
+ projectDependenciesJson:
+ description: Dependencies of the built project in compressed JSON format
+ required: false
+ default: '{}'
runs:
using: composite
steps:
@@ -51,9 +55,10 @@ runs:
_installTestAppsJson: ${{ inputs.installTestAppsJson }}
_baselineWorkflowRunId: ${{ inputs.baselineWorkflowRunId }}
_baselineWorkflowSHA: ${{ inputs.baselineWorkflowSHA }}
+ _projectDependenciesJson: ${{ inputs.projectDependenciesJson }}
run: |
${{ github.action_path }}/../Invoke-AlGoAction.ps1 -ActionName "RunPipeline" -Action {
- ${{ github.action_path }}/RunPipeline.ps1 -token $ENV:_token -artifact $ENV:_artifact -project $ENV:_project -buildMode $ENV:_buildMode -installAppsJson $ENV:_installAppsJson -installTestAppsJson $ENV:_installTestAppsJson -baselineWorkflowRunId $ENV:_baselineWorkflowRunId -baselineWorkflowSHA $ENV:_baselineWorkflowSHA
+ ${{ github.action_path }}/RunPipeline.ps1 -token $ENV:_token -artifact $ENV:_artifact -project $ENV:_project -buildMode $ENV:_buildMode -installAppsJson $ENV:_installAppsJson -installTestAppsJson $ENV:_installTestAppsJson -baselineWorkflowRunId $ENV:_baselineWorkflowRunId -baselineWorkflowSHA $ENV:_baselineWorkflowSHA -projectDependenciesJson $ENV:_projectDependenciesJson
}
branding:
icon: terminal
diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 5de2a39ad8..b46904f895 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -1,3 +1,13 @@
+### Code Coverage (Preview)
+
+AL-Go now supports collecting code coverage data during test runs. Enable it by setting `enableCodeCoverage` to `true` in your AL-Go settings file. When enabled, AL-Go uses the AL Test Runner to execute tests and collect line-level coverage data, which is output in Cobertura XML format as a build artifact.
+
+> **Note:** This feature is work-in-progress and is not guaranteed to work in all scenarios and setups yet. If you encounter issues, please disable the setting and report the problem.
+
+If you have a custom `RunTestsInBcContainer.ps1` override, a warning will be emitted when code coverage is enabled, as the custom override will take precedence and may not support code coverage collection.
+
+Read more at [enableCodeCoverage](https://aka.ms/ALGoSettings#enableCodeCoverage) and [Code Coverage](Scenarios/CodeCoverage.md).
+
### Improving error detection and build reliability when downloading project dependencies
The `DownloadProjectDependencies` action now downloads app files from URLs specified in the `installApps` and `installTestApps` settings upfront, rather than validating URLs at build time. This change provides:
diff --git a/Scenarios/CodeCoverage.md b/Scenarios/CodeCoverage.md
new file mode 100644
index 0000000000..bfc3ecf5b2
--- /dev/null
+++ b/Scenarios/CodeCoverage.md
@@ -0,0 +1,69 @@
+# Collect Code Coverage
+
+> **Preview:** This feature is work-in-progress and is not guaranteed to work in all scenarios and setups yet. If you encounter issues, disable the setting and report the problem.
+
+AL-Go for GitHub supports collecting code coverage data during test runs. When enabled, the pipeline uses the AL Test Runner to execute tests and collect line-level coverage information, which is output as a Cobertura XML file in the build artifacts.
+
+## Enabling Code Coverage
+
+Add the following to your `.AL-Go/settings.json` or `.github/AL-Go-Settings.json`:
+
+```json
+{
+ "enableCodeCoverage": true
+}
+```
+
+Read more about settings at [Settings](settings.md#enableCodeCoverage).
+
+## Advanced Configuration
+
+Use the `codeCoverageSetup` object to customize coverage behavior:
+
+```json
+{
+ "enableCodeCoverage": true,
+ "codeCoverageSetup": {
+ "excludeFilesPattern": ["*.PermissionSet.al", "*.PermissionSetExtension.al"],
+ "trackingType": "PerRun",
+ "produceCodeCoverageMap": "PerCodeunit"
+ }
+}
+```
+
+| Property | Description | Default |
+|---|---|---|
+| `excludeFilesPattern` | Array of glob patterns for files to exclude from the coverage denominator. Patterns are matched against both the file name and relative path. Example: `["*.PermissionSet.al"]` excludes all permission set files. | `[]` |
+| `trackingType` | Coverage tracking granularity: `PerRun`, `PerCodeunit`, or `PerTest`. | `PerRun` |
+| `produceCodeCoverageMap` | Code coverage map granularity: `Disabled`, `PerCodeunit`, or `PerTest`. | `PerCodeunit` |
+
+Read more about settings at [Settings](settings.md#codeCoverageSetup).
+
+## Output
+
+The coverage output is available in the build artifacts under the `CodeCoverage` folder:
+
+- **`cobertura.xml`** — Coverage data in Cobertura XML format, suitable for integration with coverage visualization tools.
+- **`.dat` files** — Raw coverage data from the AL Test Runner.
+
+## Limitations
+
+- **Custom `RunTestsInBcContainer` overrides:** If your repository has a custom `RunTestsInBcContainer.ps1` override in the `.AL-Go` folder, it will take precedence over the built-in code coverage override. A warning will be emitted in the build log. To collect code coverage with a custom override, your script must use `Run-AlTests` (imported automatically by AL-Go) with the appropriate code coverage parameters.
+- **Work-in-progress:** The AL Test Runner is a new component and may not support all test configurations that the standard BcContainerHelper test runner supports. If you experience test failures or missing test results after enabling code coverage, disable the setting and report the issue.
+
+## Using Code Coverage with a Custom Override
+
+If you need a custom `RunTestsInBcContainer.ps1` override and also want code coverage, your script can call `Run-AlTests` directly. The module is imported automatically by AL-Go at pipeline startup. Key parameters for code coverage:
+
+```powershell
+Run-AlTests @{
+ ServiceUrl = $serviceUrl
+ Credential = $credential
+ CodeCoverageTrackingType = 'PerRun'
+ ProduceCodeCoverageMap = 'PerCodeunit'
+ CodeCoverageOutputPath = $codeCoverageOutputPath
+ # ... other test parameters
+}
+```
+
+See the [AL-Go Settings](settings.md) documentation for the full list of available pipeline override scripts.
diff --git a/Scenarios/settings.md b/Scenarios/settings.md
index 53573420a8..c3820300af 100644
--- a/Scenarios/settings.md
+++ b/Scenarios/settings.md
@@ -109,6 +109,8 @@ The repository settings are only read from the repository settings file (.github
| configPackages.country | An array of configuration packages to be applied to the build container for country **country** before running tests. Configuration packages can be the relative path within the project or it can be STANDARD, EXTENDED or EVALUATION for the rapidstart packages, which comes with Business Central. | [ ] |
| installOnlyReferencedApps | By default, only the apps referenced in the dependency chain of your apps will be installed when inspecting the settings: InstallApps, InstallTestApps and appDependencyProbingPath. If you change this setting to false, all apps found will be installed. | true |
| enableCodeCop | If enableCodeCop is set to true, the CI/CD workflow will enable the CodeCop analyzer when building. | false |
+| enableCodeCoverage | PREVIEW: If enableCodeCoverage is set to true, the CI/CD workflow will collect code coverage data during test runs using the AL Test Runner. Coverage results are output in Cobertura XML format. **Note:** This feature is work-in-progress and may not work in all scenarios and setups. When enabled, AL-Go replaces the standard test runner with a built-in override. If you have a custom `RunTestsInBcContainer.ps1` override, a warning will be emitted as it may not be compatible. See [Code Coverage](CodeCoverage.md) for more details. | false |
+| codeCoverageSetup | PREVIEW: An object with additional code coverage configuration. Only used when `enableCodeCoverage` is `true`. Properties: `excludeFilesPattern` (string array of glob patterns to exclude from coverage, e.g. `["*.PermissionSet.al"]`), `trackingType` (coverage tracking granularity: `PerRun`, `PerCodeunit`, or `PerTest`, default: `PerRun`), `produceCodeCoverageMap` (`Disabled`, `PerCodeunit`, or `PerTest`, default: `PerCodeunit`). See [Code Coverage](CodeCoverage.md) for more details. | { } |
| enableUICop | If enableUICop is set to true, the CI/CD workflow will enable the UICop analyzer when building. | false |
| customCodeCops | CustomCodeCops is an array of paths or URLs to custom Code Cop DLLs you want to enable when building. | [ ] |
| enableCodeAnalyzersOnTestApps | If enableCodeAnalyzersOnTestApps is set to true, the code analyzers will be enabled when building test apps as well. | false |
@@ -432,7 +434,7 @@ Note that changes to AL-Go for GitHub or Run-AlPipeline functionality in the fut
| InstallBcAppFromAppSource.ps1 | Install apps from AppSource specified by the $parameters hashtable |
| SignBcContainerApp.ps1 | Sign apps specified by the $parameters hashtable|
| ImportTestDataInBcContainer.ps1 | If this function is provided, it is expected to insert the test data needed for running tests |
-| RunTestsInBcContainer.ps1 | Run the tests specified by the $parameters hashtable |
+| RunTestsInBcContainer.ps1 | Run the tests specified by the $parameters hashtable. **Note:** When [enableCodeCoverage](settings.md#enableCodeCoverage) is set to true, AL-Go provides a built-in override for this script to enable code coverage collection. If you provide your own override, a warning will be emitted as it may not include code coverage support. |
| GetBcContainerAppRuntimePackage.ps1 | Get the runtime package specified by the $parameters hashtable |
| GetBcContainerEventLog.ps1 | Get the eventlog based on the $parameters hashtable |
| RemoveBcContainer.ps1 | Cleanup based on the $parameters hashtable |
diff --git a/Templates/AppSource App/.github/workflows/CICD.yaml b/Templates/AppSource App/.github/workflows/CICD.yaml
index ca95fbd3e1..459cbd2e78 100644
--- a/Templates/AppSource App/.github/workflows/CICD.yaml
+++ b/Templates/AppSource App/.github/workflows/CICD.yaml
@@ -237,6 +237,38 @@ jobs:
sarif_file: '${{ github.workspace }}/ErrorLogs/output.sarif.json'
category: "ALCodeAnalysis"
+ MergeCoverage:
+ needs: [ Initialization, Build ]
+ if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped' || needs.Build.result == 'failure')
+ runs-on: [ windows-latest ]
+ name: Merge Code Coverage
+ env:
+ BUILD_RESULT: ${{ needs.Build.result }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Download coverage artifacts
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ pattern: '*CodeCoverage*'
+ path: '${{ github.workspace }}/.coverage-inputs/'
+
+ - name: Merge Coverage Summaries
+ if: hashFiles('.coverage-inputs/**/cobertura.xml') != ''
+ uses: microsoft/AL-Go-Actions/MergeCoverageSummaries@main
+ with:
+ shell: powershell
+ coveragePath: '${{ github.workspace }}/.coverage-inputs'
+
+ - name: Publish merged coverage artifact
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ if: hashFiles('.coverage-inputs/_merged/cobertura.xml') != ''
+ with:
+ name: MergedCodeCoverage
+ path: '${{ github.workspace }}/.coverage-inputs/_merged/'
+ if-no-files-found: ignore
+
DeployALDoc:
needs: [ Initialization, Build ]
if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.generateALDocArtifact == 1 && github.ref_name == 'main'
@@ -402,7 +434,7 @@ jobs:
artifacts: '.artifacts'
PostProcess:
- needs: [ Initialization, Build, Deploy, Deliver, DeployALDoc, CodeAnalysisUpload ]
+ needs: [ Initialization, Build, Deploy, Deliver, DeployALDoc, CodeAnalysisUpload, MergeCoverage ]
if: (!cancelled())
runs-on: [ windows-latest ]
steps:
diff --git a/Templates/AppSource App/.github/workflows/PullRequestHandler.yaml b/Templates/AppSource App/.github/workflows/PullRequestHandler.yaml
index 4550c61206..fda796790f 100644
--- a/Templates/AppSource App/.github/workflows/PullRequestHandler.yaml
+++ b/Templates/AppSource App/.github/workflows/PullRequestHandler.yaml
@@ -152,8 +152,42 @@ jobs:
ref: ${{ format('refs/pull/{0}/head', github.event.pull_request.number) }}
sha: ${{ github.event.pull_request.head.sha }}
- StatusCheck:
+ MergeCoverage:
needs: [ Initialization, Build ]
+ if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped' || needs.Build.result == 'failure')
+ runs-on: [ windows-latest ]
+ name: Merge Code Coverage
+ env:
+ BUILD_RESULT: ${{ needs.Build.result }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ ref: ${{ format('refs/pull/{0}/head', github.event.pull_request.number) }}
+
+ - name: Download coverage artifacts
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ pattern: '*CodeCoverage*'
+ path: '${{ github.workspace }}/.coverage-inputs/'
+
+ - name: Merge Coverage Summaries
+ if: hashFiles('.coverage-inputs/**/cobertura.xml') != ''
+ uses: microsoft/AL-Go-Actions/MergeCoverageSummaries@main
+ with:
+ shell: powershell
+ coveragePath: '${{ github.workspace }}/.coverage-inputs'
+
+ - name: Publish merged coverage artifact
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ if: hashFiles('.coverage-inputs/_merged/cobertura.xml') != ''
+ with:
+ name: MergedCodeCoverage
+ path: '${{ github.workspace }}/.coverage-inputs/_merged/'
+ if-no-files-found: ignore
+
+ StatusCheck:
+ needs: [ Initialization, Build, MergeCoverage ]
if: (!cancelled())
runs-on: [ windows-latest ]
name: Pull Request Status Check
diff --git a/Templates/AppSource App/.github/workflows/_BuildALGoProject.yaml b/Templates/AppSource App/.github/workflows/_BuildALGoProject.yaml
index 79f563753d..48a3c0de08 100644
--- a/Templates/AppSource App/.github/workflows/_BuildALGoProject.yaml
+++ b/Templates/AppSource App/.github/workflows/_BuildALGoProject.yaml
@@ -182,6 +182,7 @@ jobs:
installTestAppsJson: ${{ steps.DownloadProjectDependencies.outputs.DownloadedTestApps }}
baselineWorkflowRunId: ${{ inputs.baselineWorkflowRunId }}
baselineWorkflowSHA: ${{ inputs.baselineWorkflowSHA }}
+ projectDependenciesJson: ${{ inputs.projectDependenciesJson }}
- name: Sign
id: sign
@@ -253,6 +254,14 @@ jobs:
path: '${{ inputs.project }}/.buildartifacts/TestResults.xml'
if-no-files-found: ignore
+ - name: Publish artifacts - code coverage
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ if: (success() || failure()) && (hashFiles(format('{0}/.buildartifacts/CodeCoverage/*',inputs.project)) != '')
+ with:
+ name: ${{ steps.calculateArtifactsNames.outputs.CodeCoverageArtifactsName }}
+ path: '${{ inputs.project }}/.buildartifacts/CodeCoverage/'
+ if-no-files-found: ignore
+
- name: Publish artifacts - bcpt test results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: (success() || failure()) && (hashFiles(format('{0}/.buildartifacts/bcptTestResults.json',inputs.project)) != '')
@@ -313,6 +322,13 @@ jobs:
project: ${{ inputs.project }}
testType: "pageScripting"
+ - name: Build Code Coverage Summary
+ if: (success() || failure()) && (hashFiles(format('{0}/.buildartifacts/CodeCoverage/cobertura.xml',inputs.project)) != '')
+ uses: microsoft/AL-Go-Actions/BuildCodeCoverageSummary@main
+ with:
+ shell: ${{ inputs.shell }}
+ project: ${{ inputs.project }}
+
- name: Cleanup
if: always() && steps.DetermineBuildProject.outputs.BuildIt == 'True'
uses: microsoft/AL-Go-Actions/PipelineCleanup@main
diff --git a/Templates/Per Tenant Extension/.github/workflows/CICD.yaml b/Templates/Per Tenant Extension/.github/workflows/CICD.yaml
index c26c9d9b85..8a333396d1 100644
--- a/Templates/Per Tenant Extension/.github/workflows/CICD.yaml
+++ b/Templates/Per Tenant Extension/.github/workflows/CICD.yaml
@@ -251,6 +251,38 @@ jobs:
sarif_file: '${{ github.workspace }}/ErrorLogs/output.sarif.json'
category: "ALCodeAnalysis"
+ MergeCoverage:
+ needs: [ Initialization, Build ]
+ if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped' || needs.Build.result == 'failure')
+ runs-on: [ windows-latest ]
+ name: Merge Code Coverage
+ env:
+ BUILD_RESULT: ${{ needs.Build.result }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Download coverage artifacts
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ pattern: '*CodeCoverage*'
+ path: '${{ github.workspace }}/.coverage-inputs/'
+
+ - name: Merge Coverage Summaries
+ if: hashFiles('.coverage-inputs/**/cobertura.xml') != ''
+ uses: microsoft/AL-Go-Actions/MergeCoverageSummaries@main
+ with:
+ shell: powershell
+ coveragePath: '${{ github.workspace }}/.coverage-inputs'
+
+ - name: Publish merged coverage artifact
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ if: hashFiles('.coverage-inputs/_merged/cobertura.xml') != ''
+ with:
+ name: MergedCodeCoverage
+ path: '${{ github.workspace }}/.coverage-inputs/_merged/'
+ if-no-files-found: ignore
+
DeployALDoc:
needs: [ Initialization, Build ]
if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.generateALDocArtifact == 1 && github.ref_name == 'main'
@@ -416,7 +448,7 @@ jobs:
artifacts: '.artifacts'
PostProcess:
- needs: [ Initialization, Build, BuildPP, Deploy, Deliver, DeployALDoc, CodeAnalysisUpload ]
+ needs: [ Initialization, Build, BuildPP, Deploy, Deliver, DeployALDoc, CodeAnalysisUpload, MergeCoverage ]
if: (!cancelled())
runs-on: [ windows-latest ]
steps:
diff --git a/Templates/Per Tenant Extension/.github/workflows/PullRequestHandler.yaml b/Templates/Per Tenant Extension/.github/workflows/PullRequestHandler.yaml
index 4550c61206..fda796790f 100644
--- a/Templates/Per Tenant Extension/.github/workflows/PullRequestHandler.yaml
+++ b/Templates/Per Tenant Extension/.github/workflows/PullRequestHandler.yaml
@@ -152,8 +152,42 @@ jobs:
ref: ${{ format('refs/pull/{0}/head', github.event.pull_request.number) }}
sha: ${{ github.event.pull_request.head.sha }}
- StatusCheck:
+ MergeCoverage:
needs: [ Initialization, Build ]
+ if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped' || needs.Build.result == 'failure')
+ runs-on: [ windows-latest ]
+ name: Merge Code Coverage
+ env:
+ BUILD_RESULT: ${{ needs.Build.result }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ ref: ${{ format('refs/pull/{0}/head', github.event.pull_request.number) }}
+
+ - name: Download coverage artifacts
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ pattern: '*CodeCoverage*'
+ path: '${{ github.workspace }}/.coverage-inputs/'
+
+ - name: Merge Coverage Summaries
+ if: hashFiles('.coverage-inputs/**/cobertura.xml') != ''
+ uses: microsoft/AL-Go-Actions/MergeCoverageSummaries@main
+ with:
+ shell: powershell
+ coveragePath: '${{ github.workspace }}/.coverage-inputs'
+
+ - name: Publish merged coverage artifact
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ if: hashFiles('.coverage-inputs/_merged/cobertura.xml') != ''
+ with:
+ name: MergedCodeCoverage
+ path: '${{ github.workspace }}/.coverage-inputs/_merged/'
+ if-no-files-found: ignore
+
+ StatusCheck:
+ needs: [ Initialization, Build, MergeCoverage ]
if: (!cancelled())
runs-on: [ windows-latest ]
name: Pull Request Status Check
diff --git a/Templates/Per Tenant Extension/.github/workflows/_BuildALGoProject.yaml b/Templates/Per Tenant Extension/.github/workflows/_BuildALGoProject.yaml
index 79f563753d..48a3c0de08 100644
--- a/Templates/Per Tenant Extension/.github/workflows/_BuildALGoProject.yaml
+++ b/Templates/Per Tenant Extension/.github/workflows/_BuildALGoProject.yaml
@@ -182,6 +182,7 @@ jobs:
installTestAppsJson: ${{ steps.DownloadProjectDependencies.outputs.DownloadedTestApps }}
baselineWorkflowRunId: ${{ inputs.baselineWorkflowRunId }}
baselineWorkflowSHA: ${{ inputs.baselineWorkflowSHA }}
+ projectDependenciesJson: ${{ inputs.projectDependenciesJson }}
- name: Sign
id: sign
@@ -253,6 +254,14 @@ jobs:
path: '${{ inputs.project }}/.buildartifacts/TestResults.xml'
if-no-files-found: ignore
+ - name: Publish artifacts - code coverage
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ if: (success() || failure()) && (hashFiles(format('{0}/.buildartifacts/CodeCoverage/*',inputs.project)) != '')
+ with:
+ name: ${{ steps.calculateArtifactsNames.outputs.CodeCoverageArtifactsName }}
+ path: '${{ inputs.project }}/.buildartifacts/CodeCoverage/'
+ if-no-files-found: ignore
+
- name: Publish artifacts - bcpt test results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: (success() || failure()) && (hashFiles(format('{0}/.buildartifacts/bcptTestResults.json',inputs.project)) != '')
@@ -313,6 +322,13 @@ jobs:
project: ${{ inputs.project }}
testType: "pageScripting"
+ - name: Build Code Coverage Summary
+ if: (success() || failure()) && (hashFiles(format('{0}/.buildartifacts/CodeCoverage/cobertura.xml',inputs.project)) != '')
+ uses: microsoft/AL-Go-Actions/BuildCodeCoverageSummary@main
+ with:
+ shell: ${{ inputs.shell }}
+ project: ${{ inputs.project }}
+
- name: Cleanup
if: always() && steps.DetermineBuildProject.outputs.BuildIt == 'True'
uses: microsoft/AL-Go-Actions/PipelineCleanup@main
diff --git a/Tests/CalculateArtifactNames.Test.ps1 b/Tests/CalculateArtifactNames.Test.ps1
index 2e38f03e12..b883769ddb 100644
--- a/Tests/CalculateArtifactNames.Test.ps1
+++ b/Tests/CalculateArtifactNames.Test.ps1
@@ -1,164 +1,165 @@
-Get-Module TestActionsHelper | Remove-Module -Force
-Import-Module (Join-Path $PSScriptRoot 'TestActionsHelper.psm1')
-$errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0
-
-Describe 'CalculateArtifactNames Action Tests' {
-
- BeforeAll {
- $actionName = "CalculateArtifactNames"
- $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve
- $scriptName = "$actionName.ps1"
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'scriptPath', Justification = 'False positive.')]
- $scriptPath = Join-Path $scriptRoot $scriptName
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')]
- $actionScript = GetActionScript -scriptRoot $scriptRoot -scriptName $scriptName
-
- $env:Settings = '{ "appBuild": 123, "repoVersion": "22.0", "appRevision": 0,"repoName": "AL-Go"}'
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'project', Justification = 'False positive.')]
- $project = "ALGOProject"
- }
-
- BeforeEach {
- $env:GITHUB_OUTPUT = [System.IO.Path]::GetTempFileName()
- $env:GITHUB_ENV = [System.IO.Path]::GetTempFileName()
-
- Write-Host $env:GITHUB_OUTPUT
- Write-Host $env:GITHUB_ENV
- }
-
-
- It 'should include buildmode name in artifact name if buildmode is not default' {
- $buildMode = "Clean"
- $env:GITHUB_HEAD_REF = "main"
- & $scriptPath `
- -project $project `
- -buildMode $buildMode
-
- $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
- $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-main-CleanApps-22.0.123.0"
- $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-main-CleanDependencies-22.0.123.0"
- $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-main-CleanTestApps-22.0.123.0"
- $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-main-CleanTestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-main-CleanBcptTestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-main-CleanPageScriptingTestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-main-CleanPageScriptingTestResultDetails-22.0.123.0"
- $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-main-CleanBuildOutput-22.0.123.0"
- $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-main-CleanContainerEventLog-22.0.123.0"
- $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-main-CleanErrorLogs-22.0.123.0"
- $generatedOutPut | Should -Contain "BuildMode=Clean"
-
- }
-
- It 'should not include buildmode name in artifact name if buildmode is default' {
- $buildMode = "Default"
- $env:GITHUB_HEAD_REF = "main"
- & $scriptPath `
- -project $project `
- -buildMode $buildMode
-
- $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
- $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-main-Apps-22.0.123.0"
- $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-main-Dependencies-22.0.123.0"
- $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-main-TestApps-22.0.123.0"
- $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-main-TestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-main-BcptTestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-main-PageScriptingTestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-main-PageScriptingTestResultDetails-22.0.123.0"
- $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-main-BuildOutput-22.0.123.0"
- $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-main-ContainerEventLog-22.0.123.0"
- $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-main-ErrorLogs-22.0.123.0"
- }
-
- It 'should escape slashes and backslashes in artifact name' {
- $buildMode = "Default"
- $env:GITHUB_HEAD_REF = "releases/1.0"
- & $scriptPath `
- -project $project `
- -buildMode $buildMode
-
- $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
- $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-releases_1.0-Apps-22.0.123.0"
- $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-releases_1.0-Dependencies-22.0.123.0"
- $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-releases_1.0-TestApps-22.0.123.0"
- $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-releases_1.0-TestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-releases_1.0-BcptTestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResults-22.0.123.0"
- $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResultDetails-22.0.123.0"
- $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-releases_1.0-BuildOutput-22.0.123.0"
- $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-releases_1.0-ContainerEventLog-22.0.123.0"
- $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-releases_1.0-ErrorLogs-22.0.123.0"
- }
-
- It 'should use the specified suffix if provided' {
- $buildMode = "Default"
- $env:GITHUB_HEAD_REF = "releases/1.0"
- $suffix = "Current"
- & $scriptPath `
- -project $project `
- -buildMode $buildMode `
- -suffix $suffix
-
- # In rare cases, when this test is run at the end of the day, the date will change between the time the script is run and the time the test is run.
- $currentDate = [DateTime]::UtcNow.ToString('yyyyMMdd')
-
- $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
- $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-releases_1.0-Apps-Current-$currentDate"
- $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-releases_1.0-Dependencies-Current-$currentDate"
- $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-releases_1.0-TestApps-Current-$currentDate"
- $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-releases_1.0-TestResults-Current-$currentDate"
- $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-releases_1.0-BcptTestResults-Current-$currentDate"
- $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResults-Current-$currentDate"
- $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResultDetails-Current-$currentDate"
- $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-releases_1.0-BuildOutput-Current-$currentDate"
- $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-releases_1.0-ContainerEventLog-Current-$currentDate"
- $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-releases_1.0-ErrorLogs-Current-$currentDate"
- }
-
- It 'handles special characters in project name' {
- $project = "ALGOProject_øåæ"
- $buildMode = "Default"
- $env:GITHUB_HEAD_REF = "releases/1.0"
- $suffix = "Current"
- & $scriptPath `
- -project $project `
- -buildMode $buildMode `
- -suffix $suffix
-
- # In rare cases, when this test is run at the end of the day, the date will change between the time the script is run and the time the test is run.
- $currentDate = [DateTime]::UtcNow.ToString('yyyyMMdd')
-
- $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
- $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject_øåæ-releases_1.0-Apps-Current-$currentDate"
- $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject_øåæ-releases_1.0-Dependencies-Current-$currentDate"
- $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject_øåæ-releases_1.0-TestApps-Current-$currentDate"
- $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject_øåæ-releases_1.0-TestResults-Current-$currentDate"
- $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject_øåæ-releases_1.0-BcptTestResults-Current-$currentDate"
- $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject_øåæ-releases_1.0-PageScriptingTestResults-Current-$currentDate"
- $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject_øåæ-releases_1.0-PageScriptingTestResultDetails-Current-$currentDate"
- $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject_øåæ-releases_1.0-BuildOutput-Current-$currentDate"
- $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject_øåæ-releases_1.0-ContainerEventLog-Current-$currentDate"
- $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject_øåæ-releases_1.0-ErrorLogs-Current-$currentDate"
- }
-
- It 'Compile Action' {
- Invoke-Expression $actionScript
- }
-
- It 'Test action.yaml matches script' {
- $outputs = [ordered]@{
- "AppsArtifactsName" = "Artifacts name for Apps"
- "PowerPlatformSolutionArtifactsName" = "Artifacts name for PowerPlatform Solution"
- "DependenciesArtifactsName" = "Artifacts name for Dependencies"
- "TestAppsArtifactsName" = "Artifacts name for TestApps"
- "TestResultsArtifactsName" = "Artifacts name for TestResults"
- "BcptTestResultsArtifactsName" = "Artifacts name for BcptTestResults"
- "PageScriptingTestResultsArtifactsName" = "Artifacts name for PageScriptingTestResults"
- "PageScriptingTestResultDetailsArtifactsName" = "Artifacts name for PageScriptingTestResultDetails"
- "BuildOutputArtifactsName" = "Artifacts name for BuildOutput"
- "ContainerEventLogArtifactsName" = "Artifacts name for ContainerEventLog"
- "ErrorLogsArtifactsName" = "Artifacts name for ErrorLogs"
- "BuildMode" = "Build mode used when building the artifacts"
- }
- YamlTest -scriptRoot $scriptRoot -actionName $actionName -actionScript $actionScript -outputs $outputs
- }
-}
+Get-Module TestActionsHelper | Remove-Module -Force
+Import-Module (Join-Path $PSScriptRoot 'TestActionsHelper.psm1')
+$errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0
+
+Describe 'CalculateArtifactNames Action Tests' {
+
+ BeforeAll {
+ $actionName = "CalculateArtifactNames"
+ $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve
+ $scriptName = "$actionName.ps1"
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'scriptPath', Justification = 'False positive.')]
+ $scriptPath = Join-Path $scriptRoot $scriptName
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')]
+ $actionScript = GetActionScript -scriptRoot $scriptRoot -scriptName $scriptName
+
+ $env:Settings = '{ "appBuild": 123, "repoVersion": "22.0", "appRevision": 0,"repoName": "AL-Go"}'
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'project', Justification = 'False positive.')]
+ $project = "ALGOProject"
+ }
+
+ BeforeEach {
+ $env:GITHUB_OUTPUT = [System.IO.Path]::GetTempFileName()
+ $env:GITHUB_ENV = [System.IO.Path]::GetTempFileName()
+
+ Write-Host $env:GITHUB_OUTPUT
+ Write-Host $env:GITHUB_ENV
+ }
+
+
+ It 'should include buildmode name in artifact name if buildmode is not default' {
+ $buildMode = "Clean"
+ $env:GITHUB_HEAD_REF = "main"
+ & $scriptPath `
+ -project $project `
+ -buildMode $buildMode
+
+ $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
+ $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-main-CleanApps-22.0.123.0"
+ $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-main-CleanDependencies-22.0.123.0"
+ $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-main-CleanTestApps-22.0.123.0"
+ $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-main-CleanTestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-main-CleanBcptTestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-main-CleanPageScriptingTestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-main-CleanPageScriptingTestResultDetails-22.0.123.0"
+ $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-main-CleanBuildOutput-22.0.123.0"
+ $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-main-CleanContainerEventLog-22.0.123.0"
+ $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-main-CleanErrorLogs-22.0.123.0"
+ $generatedOutPut | Should -Contain "BuildMode=Clean"
+
+ }
+
+ It 'should not include buildmode name in artifact name if buildmode is default' {
+ $buildMode = "Default"
+ $env:GITHUB_HEAD_REF = "main"
+ & $scriptPath `
+ -project $project `
+ -buildMode $buildMode
+
+ $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
+ $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-main-Apps-22.0.123.0"
+ $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-main-Dependencies-22.0.123.0"
+ $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-main-TestApps-22.0.123.0"
+ $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-main-TestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-main-BcptTestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-main-PageScriptingTestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-main-PageScriptingTestResultDetails-22.0.123.0"
+ $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-main-BuildOutput-22.0.123.0"
+ $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-main-ContainerEventLog-22.0.123.0"
+ $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-main-ErrorLogs-22.0.123.0"
+ }
+
+ It 'should escape slashes and backslashes in artifact name' {
+ $buildMode = "Default"
+ $env:GITHUB_HEAD_REF = "releases/1.0"
+ & $scriptPath `
+ -project $project `
+ -buildMode $buildMode
+
+ $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
+ $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-releases_1.0-Apps-22.0.123.0"
+ $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-releases_1.0-Dependencies-22.0.123.0"
+ $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-releases_1.0-TestApps-22.0.123.0"
+ $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-releases_1.0-TestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-releases_1.0-BcptTestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResults-22.0.123.0"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResultDetails-22.0.123.0"
+ $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-releases_1.0-BuildOutput-22.0.123.0"
+ $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-releases_1.0-ContainerEventLog-22.0.123.0"
+ $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-releases_1.0-ErrorLogs-22.0.123.0"
+ }
+
+ It 'should use the specified suffix if provided' {
+ $buildMode = "Default"
+ $env:GITHUB_HEAD_REF = "releases/1.0"
+ $suffix = "Current"
+ & $scriptPath `
+ -project $project `
+ -buildMode $buildMode `
+ -suffix $suffix
+
+ # In rare cases, when this test is run at the end of the day, the date will change between the time the script is run and the time the test is run.
+ $currentDate = [DateTime]::UtcNow.ToString('yyyyMMdd')
+
+ $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
+ $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject-releases_1.0-Apps-Current-$currentDate"
+ $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject-releases_1.0-Dependencies-Current-$currentDate"
+ $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject-releases_1.0-TestApps-Current-$currentDate"
+ $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject-releases_1.0-TestResults-Current-$currentDate"
+ $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject-releases_1.0-BcptTestResults-Current-$currentDate"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResults-Current-$currentDate"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject-releases_1.0-PageScriptingTestResultDetails-Current-$currentDate"
+ $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject-releases_1.0-BuildOutput-Current-$currentDate"
+ $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject-releases_1.0-ContainerEventLog-Current-$currentDate"
+ $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject-releases_1.0-ErrorLogs-Current-$currentDate"
+ }
+
+ It 'handles special characters in project name' {
+ $project = "ALGOProject_øåæ"
+ $buildMode = "Default"
+ $env:GITHUB_HEAD_REF = "releases/1.0"
+ $suffix = "Current"
+ & $scriptPath `
+ -project $project `
+ -buildMode $buildMode `
+ -suffix $suffix
+
+ # In rare cases, when this test is run at the end of the day, the date will change between the time the script is run and the time the test is run.
+ $currentDate = [DateTime]::UtcNow.ToString('yyyyMMdd')
+
+ $generatedOutPut = Get-Content $env:GITHUB_OUTPUT -Encoding UTF8
+ $generatedOutPut | Should -Contain "AppsArtifactsName=ALGOProject_øåæ-releases_1.0-Apps-Current-$currentDate"
+ $generatedOutPut | Should -Contain "DependenciesArtifactsName=ALGOProject_øåæ-releases_1.0-Dependencies-Current-$currentDate"
+ $generatedOutPut | Should -Contain "TestAppsArtifactsName=ALGOProject_øåæ-releases_1.0-TestApps-Current-$currentDate"
+ $generatedOutPut | Should -Contain "TestResultsArtifactsName=ALGOProject_øåæ-releases_1.0-TestResults-Current-$currentDate"
+ $generatedOutPut | Should -Contain "BcptTestResultsArtifactsName=ALGOProject_øåæ-releases_1.0-BcptTestResults-Current-$currentDate"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultsArtifactsName=ALGOProject_øåæ-releases_1.0-PageScriptingTestResults-Current-$currentDate"
+ $generatedOutPut | Should -Contain "PageScriptingTestResultDetailsArtifactsName=ALGOProject_øåæ-releases_1.0-PageScriptingTestResultDetails-Current-$currentDate"
+ $generatedOutPut | Should -Contain "BuildOutputArtifactsName=ALGOProject_øåæ-releases_1.0-BuildOutput-Current-$currentDate"
+ $generatedOutPut | Should -Contain "ContainerEventLogArtifactsName=ALGOProject_øåæ-releases_1.0-ContainerEventLog-Current-$currentDate"
+ $generatedOutPut | Should -Contain "ErrorLogsArtifactsName=ALGOProject_øåæ-releases_1.0-ErrorLogs-Current-$currentDate"
+ }
+
+ It 'Compile Action' {
+ Invoke-Expression $actionScript
+ }
+
+ It 'Test action.yaml matches script' {
+ $outputs = [ordered]@{
+ "AppsArtifactsName" = "Artifacts name for Apps"
+ "PowerPlatformSolutionArtifactsName" = "Artifacts name for PowerPlatform Solution"
+ "DependenciesArtifactsName" = "Artifacts name for Dependencies"
+ "TestAppsArtifactsName" = "Artifacts name for TestApps"
+ "TestResultsArtifactsName" = "Artifacts name for TestResults"
+ "BcptTestResultsArtifactsName" = "Artifacts name for BcptTestResults"
+ "PageScriptingTestResultsArtifactsName" = "Artifacts name for PageScriptingTestResults"
+ "PageScriptingTestResultDetailsArtifactsName" = "Artifacts name for PageScriptingTestResultDetails"
+ "BuildOutputArtifactsName" = "Artifacts name for BuildOutput"
+ "ContainerEventLogArtifactsName" = "Artifacts name for ContainerEventLog"
+ "ErrorLogsArtifactsName" = "Artifacts name for ErrorLogs"
+ "CodeCoverageArtifactsName" = "Artifacts name for CodeCoverage"
+ "BuildMode" = "Build mode used when building the artifacts"
+ }
+ YamlTest -scriptRoot $scriptRoot -actionName $actionName -actionScript $actionScript -outputs $outputs
+ }
+}
diff --git a/Tests/CodeCoverage/ALSourceParser.Test.ps1 b/Tests/CodeCoverage/ALSourceParser.Test.ps1
new file mode 100644
index 0000000000..7148a57d31
--- /dev/null
+++ b/Tests/CodeCoverage/ALSourceParser.Test.ps1
@@ -0,0 +1,269 @@
+BeforeAll {
+ . (Join-Path $PSScriptRoot "..\..\Actions\AL-Go-Helper.ps1" -Resolve)
+ Import-Module (Join-Path $PSScriptRoot "..\..\Actions\.Modules\TestRunner\CoverageProcessor\ALSourceParser.psm1" -Resolve) -Force
+
+ $script:testDataPath = Join-Path $PSScriptRoot "TestData\ALFiles"
+}
+
+Describe "ALSourceParser - Get-ALObjectMap" {
+ Context "Parse AL source files" {
+ It "Should parse codeunit object" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+
+ $objectMap.Keys | Should -Contain "Codeunit.50100"
+ }
+
+ It "Should parse page object" {
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+
+ $objectMap.Keys | Should -Contain "Page.50001"
+ }
+
+ It "Should extract object properties" {
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+ $codeunit = $objectMap["Codeunit.50100"]
+
+ $codeunit.ObjectType | Should -Be "Codeunit"
+ $codeunit.ObjectId | Should -Be 50100
+ $codeunit.FilePath | Should -Match "sample-codeunit.al"
+ }
+
+ It "Should identify procedures" {
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+ $codeunit = $objectMap["Codeunit.50100"]
+
+ $codeunit.Procedures | Should -Not -BeNullOrEmpty
+ $codeunit.Procedures.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should calculate executable lines" {
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+ $codeunit = $objectMap["Codeunit.50100"]
+
+ $codeunit.ExecutableLineNumbers | Should -Not -BeNullOrEmpty
+ $codeunit.ExecutableLineNumbers.Count | Should -BeGreaterThan 0
+ }
+ }
+}
+
+Describe "ALSourceParser - Get-ALProcedures" {
+ Context "Procedure detection" {
+ It "Should find procedures in AL code" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+
+ $procedures | Should -Not -BeNullOrEmpty
+ $procedures.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should identify procedure names" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+ $procedureNames = $procedures | ForEach-Object { $_.Name }
+
+ $procedureNames | Should -Contain "TestProcedure1"
+ $procedureNames | Should -Contain "TestProcedure2"
+ }
+
+ It "Should capture procedure line ranges" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+ $firstProc = $procedures[0]
+
+ $firstProc.StartLine | Should -BeGreaterThan 0
+ $firstProc.EndLine | Should -BeGreaterThan $firstProc.StartLine
+ }
+
+ It "Should detect procedure modifiers" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+ # Check that the "local procedure DoSomething" is detected
+ $localProcNames = $procedures | ForEach-Object { $_.Name }
+ $localProcNames | Should -Contain "DoSomething"
+ }
+ }
+
+ Context "Complex codeunit parsing" {
+ It "Should handle multiple procedures" {
+ $complexFile = Join-Path $script:testDataPath "complex-codeunit.al"
+ $content = Get-Content -Path $complexFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+
+ $procedures.Count | Should -BeGreaterThan 2
+ }
+
+ It "Should handle nested code blocks" {
+ $complexFile = Join-Path $script:testDataPath "complex-codeunit.al"
+ $content = Get-Content -Path $complexFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+ # Should successfully parse without errors
+ $procedures | Should -Not -BeNullOrEmpty
+ }
+ }
+}
+
+Describe "ALSourceParser - Get-ALExecutableLines" {
+ Context "Executable line detection" {
+ It "Should identify executable lines" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $result = Get-ALExecutableLines -Content $content
+
+ $result.ExecutableLineNumbers | Should -Not -BeNullOrEmpty
+ $result.ExecutableLineNumbers.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should exclude comment lines" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $result = Get-ALExecutableLines -Content $content
+ $lines = Get-Content -Path $codeunitFile
+
+ # Find a line that's definitely a comment
+ $commentLineNum = 0
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ if ($lines[$i].Trim() -match '^//') {
+ $commentLineNum = $i + 1
+ break
+ }
+ }
+
+ if ($commentLineNum -gt 0) {
+ $result.ExecutableLineNumbers | Should -Not -Contain $commentLineNum
+ }
+ }
+
+ It "Should exclude empty lines" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $result = Get-ALExecutableLines -Content $content
+ $allLines = ($content -split "`n").Count
+
+ # Executable lines should be less than total lines (some empty/comments)
+ $result.ExecutableLineNumbers.Count | Should -BeLessThan $allLines
+ }
+
+ It "Should include assignment statements" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $result = Get-ALExecutableLines -Content $content
+
+ # Should have found the assignment "myVar := 10;"
+ $result.ExecutableLineNumbers.Count | Should -BeGreaterThan 5
+ }
+
+ It "Should include control flow statements" {
+ $complexFile = Join-Path $script:testDataPath "complex-codeunit.al"
+ $content = Get-Content -Path $complexFile -Raw
+
+ $result = Get-ALExecutableLines -Content $content
+
+ # Complex file has repeat/until, if statements
+ $result.ExecutableLineNumbers.Count | Should -BeGreaterThan 10
+ }
+ }
+
+ Context "Non-executable line exclusion" {
+ It "Should exclude var blocks" {
+ $complexFile = Join-Path $script:testDataPath "complex-codeunit.al"
+ $content = Get-Content -Path $complexFile -Raw
+ $lines = Get-Content -Path $complexFile
+
+ $result = Get-ALExecutableLines -Content $content
+
+ # Find var declaration line
+ $varLineNum = 0
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ if ($lines[$i].Trim() -match '^\s*var\s*$') {
+ $varLineNum = $i + 1
+ break
+ }
+ }
+
+ if ($varLineNum -gt 0) {
+ $result.ExecutableLineNumbers | Should -Not -Contain $varLineNum
+ }
+ }
+
+ It "Should handle begin/end keywords properly" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $result = Get-ALExecutableLines -Content $content
+
+ # Just verify we got some executable lines
+ $result.ExecutableLineNumbers.Count | Should -BeGreaterThan 0
+ }
+ }
+}
+
+Describe "ALSourceParser - Find-ProcedureForLine" {
+ Context "Line to procedure mapping" {
+ It "Should find procedure for given line" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+ $testLine = $procedures[0].StartLine + 2 # Line within first procedure
+
+ $foundProc = Find-ProcedureForLine -Procedures $procedures -LineNo $testLine
+
+ $foundProc | Should -Not -BeNullOrEmpty
+ $foundProc.Name | Should -Be $procedures[0].Name
+ }
+
+ It "Should return null for line outside procedures" {
+ $codeunitFile = Join-Path $script:testDataPath "sample-codeunit.al"
+ $content = Get-Content -Path $codeunitFile -Raw
+
+ $procedures = Get-ALProcedures -Content $content
+ $lineBeforeProcs = 1 # Header line
+
+ $foundProc = Find-ProcedureForLine -Procedures $procedures -LineNo $lineBeforeProcs
+
+ $foundProc | Should -BeNullOrEmpty
+ }
+ }
+}
+
+Describe "ALSourceParser - Integration" {
+ Context "Full object parsing workflow" {
+ It "Should parse multiple AL files in directory" {
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+
+ $objectMap.Keys.Count | Should -BeGreaterThan 1
+ }
+
+ It "Should handle different object types" {
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+
+ $objectTypes = $objectMap.Values | ForEach-Object { $_.ObjectType } | Select-Object -Unique
+ $objectTypes | Should -Contain "Codeunit"
+ $objectTypes | Should -Contain "Page"
+ }
+
+ It "Should provide complete source info" {
+ $objectMap = Get-ALObjectMap -SourcePath $script:testDataPath
+ $codeunit = $objectMap["Codeunit.50100"]
+
+ $codeunit | Should -Not -BeNullOrEmpty
+ $codeunit.Procedures | Should -Not -BeNullOrEmpty
+ $codeunit.ExecutableLineNumbers | Should -Not -BeNullOrEmpty
+ }
+ }
+}
diff --git a/Tests/CodeCoverage/BCCoverageParser.Test.ps1 b/Tests/CodeCoverage/BCCoverageParser.Test.ps1
new file mode 100644
index 0000000000..73f832f2d7
--- /dev/null
+++ b/Tests/CodeCoverage/BCCoverageParser.Test.ps1
@@ -0,0 +1,206 @@
+BeforeAll {
+ . (Join-Path $PSScriptRoot "..\..\Actions\AL-Go-Helper.ps1" -Resolve)
+ Import-Module (Join-Path $PSScriptRoot "..\..\Actions\.Modules\TestRunner\CoverageProcessor\BCCoverageParser.psm1" -Resolve) -Force
+
+ $script:testDataPath = Join-Path $PSScriptRoot "TestData\CoverageFiles"
+}
+
+Describe "BCCoverageParser - CSV Format" {
+ Context "Valid CSV coverage file" {
+ It "Should parse CSV coverage file successfully" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $result = Read-BCCoverageCsvFile -Path $csvFile
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should parse all coverage lines from CSV" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $result = Read-BCCoverageCsvFile -Path $csvFile
+
+ # Should have 11 lines (based on sample data)
+ $result.Count | Should -Be 11
+ }
+
+ It "Should correctly parse coverage status" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $result = Read-BCCoverageCsvFile -Path $csvFile
+
+ $coveredLines = $result | Where-Object { $_.CoverageStatusName -eq 'Covered' }
+ $notCoveredLines = $result | Where-Object { $_.CoverageStatusName -eq 'NotCovered' }
+
+ # Count actual covered vs not covered from test data
+ $coveredLines.Count | Should -Be 9
+ $notCoveredLines.Count | Should -Be 2
+ }
+
+ It "Should correctly parse hit counts" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $result = Read-BCCoverageCsvFile -Path $csvFile
+
+ $firstLine = $result[0]
+ $firstLine.Hits | Should -Be 5
+
+ $highHitLine = $result | Where-Object { $_.ObjectId -eq '50000' } | Select-Object -First 1
+ $highHitLine.Hits | Should -Be 100
+ }
+
+ It "Should correctly parse object types" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $result = Read-BCCoverageCsvFile -Path $csvFile
+
+ $codeunits = $result | Where-Object { $_.ObjectType -eq 'Codeunit' }
+ $tables = $result | Where-Object { $_.ObjectType -eq 'Table' }
+ $pages = $result | Where-Object { $_.ObjectType -eq 'Page' }
+
+ $codeunits.Count | Should -Be 8
+ $tables.Count | Should -Be 2
+ $pages.Count | Should -Be 1
+ }
+ }
+
+ Context "Empty and malformed files" {
+ It "Should handle empty CSV file gracefully" {
+ $emptyFile = Join-Path $script:testDataPath "empty-coverage.dat"
+ $result = Read-BCCoverageCsvFile -Path $emptyFile
+
+ # Empty file returns empty array, not null
+ $result.Count | Should -Be 0
+ }
+ }
+}
+
+Describe "BCCoverageParser - XML Format" {
+ Context "Valid XML coverage file" {
+ It "Should parse XML coverage file successfully" {
+ $xmlFile = Join-Path $script:testDataPath "sample-coverage.xml"
+ $result = Read-BCCoverageXmlFile -Path $xmlFile
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should parse all objects from XML" {
+ $xmlFile = Join-Path $script:testDataPath "sample-coverage.xml"
+ $result = Read-BCCoverageXmlFile -Path $xmlFile
+
+ # Should have 8 lines total from 2 codeunits
+ $result.Count | Should -Be 8
+ }
+
+ It "Should correctly map object types from XML" {
+ $xmlFile = Join-Path $script:testDataPath "sample-coverage.xml"
+ $result = Read-BCCoverageXmlFile -Path $xmlFile
+
+ $allCodeunits = $result | Where-Object { $_.ObjectType -eq 'Codeunit' }
+ $allCodeunits.Count | Should -Be 8
+ }
+
+ It "Should correctly parse hit counts from XML" {
+ $xmlFile = Join-Path $script:testDataPath "sample-coverage.xml"
+ $result = Read-BCCoverageXmlFile -Path $xmlFile
+
+ $hitCounts = $result | Select-Object -ExpandProperty Hits
+ $hitCounts | Should -Contain 5
+ $hitCounts | Should -Contain 10
+ $hitCounts | Should -Contain 0
+ }
+
+ It "Should correctly identify covered vs not covered lines" {
+ $xmlFile = Join-Path $script:testDataPath "sample-coverage.xml"
+ $result = Read-BCCoverageXmlFile -Path $xmlFile
+
+ $covered = $result | Where-Object { $_.CoverageStatusName -eq 'Covered' }
+ $notCovered = $result | Where-Object { $_.CoverageStatusName -eq 'NotCovered' }
+
+ $covered.Count | Should -Be 6
+ $notCovered.Count | Should -Be 2
+ }
+ }
+}
+
+Describe "BCCoverageParser - Auto Detection" {
+ It "Should auto-detect CSV format" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $result = Read-BCCoverageFile -Path $csvFile
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Count | Should -Be 11
+ }
+
+ It "Should auto-detect XML format" {
+ $xmlFile = Join-Path $script:testDataPath "sample-coverage.xml"
+ $result = Read-BCCoverageFile -Path $xmlFile
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Count | Should -Be 8
+ }
+}
+
+Describe "BCCoverageParser - Grouping and Statistics" {
+ Context "Group-CoverageByObject" {
+ It "Should group coverage by object" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $rawData = Read-BCCoverageCsvFile -Path $csvFile
+ $grouped = Group-CoverageByObject -CoverageEntries $rawData
+
+ $grouped.Keys.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should create correct object keys" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $rawData = Read-BCCoverageCsvFile -Path $csvFile
+ $grouped = Group-CoverageByObject -CoverageEntries $rawData
+
+ $grouped.Keys | Should -Contain 'Codeunit.50100'
+ $grouped.Keys | Should -Contain 'Codeunit.50101'
+ $grouped.Keys | Should -Contain 'Table.50000'
+ }
+
+ It "Should group all lines for each object" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $rawData = Read-BCCoverageCsvFile -Path $csvFile
+ $grouped = Group-CoverageByObject -CoverageEntries $rawData
+
+ $codeunit1 = $grouped['Codeunit.50100']
+ $codeunit1.Lines.Count | Should -Be 5
+
+ $codeunit2 = $grouped['Codeunit.50101']
+ $codeunit2.Lines.Count | Should -Be 3
+ }
+ }
+
+ Context "Get-CoverageStatistics" {
+ It "Should calculate coverage percentage" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $rawData = Read-BCCoverageCsvFile -Path $csvFile
+ $stats = Get-CoverageStatistics -CoverageEntries $rawData
+
+ $stats.CoveragePercent | Should -BeGreaterThan 0
+ $stats.CoveragePercent | Should -BeLessOrEqual 100
+ }
+
+ It "Should count total and covered lines" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $rawData = Read-BCCoverageCsvFile -Path $csvFile
+ $stats = Get-CoverageStatistics -CoverageEntries $rawData
+
+ $stats.TotalLines | Should -Be 11
+ # 8 with status 0 (Covered) + 0 with status 2 (PartiallyCovered) = 8 covered
+ # But let's check the actual data
+ $covered = ($rawData | Where-Object { $_.IsCovered }).Count
+ $stats.CoveredLines | Should -Be $covered
+ }
+
+ It "Should calculate line rate" {
+ $csvFile = Join-Path $script:testDataPath "sample-coverage.dat"
+ $rawData = Read-BCCoverageCsvFile -Path $csvFile
+ $stats = Get-CoverageStatistics -CoverageEntries $rawData
+
+ # Line rate should be between 0 and 1
+ $stats.LineRate | Should -BeGreaterThan 0
+ $stats.LineRate | Should -BeLessOrEqual 1
+ }
+ }
+}
diff --git a/Tests/CodeCoverage/BuildCodeCoverageSummary.Test.ps1 b/Tests/CodeCoverage/BuildCodeCoverageSummary.Test.ps1
new file mode 100644
index 0000000000..fe3602d956
--- /dev/null
+++ b/Tests/CodeCoverage/BuildCodeCoverageSummary.Test.ps1
@@ -0,0 +1,141 @@
+Get-Module TestActionsHelper | Remove-Module -Force
+Import-Module (Join-Path $PSScriptRoot '../TestActionsHelper.psm1')
+
+# BuildCodeCoverageSummary depends on AL-Go-Helper.ps1 and CoverageReportGenerator.ps1.
+# We dot-source only CoverageReportGenerator (the functions we actually test)
+# and mock the GitHub environment variables.
+
+BeforeAll {
+ . (Join-Path $PSScriptRoot "../../Actions/BuildCodeCoverageSummary/CoverageReportGenerator.ps1")
+
+ $testDataPath = Join-Path $PSScriptRoot "TestData/CoberturaFiles"
+}
+
+Describe "BuildCodeCoverageSummary - GetStringByteSize" {
+
+ BeforeAll {
+ # Replicate the helper function defined inside the action script
+ function GetStringByteSize($string) {
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($string)
+ return $bytes.Length
+ }
+ }
+
+ It "Should return correct byte size for ASCII strings" {
+ $size = GetStringByteSize("Hello")
+ $size | Should -Be 5
+ }
+
+ It "Should return correct byte size for empty string" {
+ $size = GetStringByteSize("")
+ $size | Should -Be 0
+ }
+
+ It "Should handle multi-byte characters" {
+ $size = GetStringByteSize("ä")
+ $size | Should -BeGreaterThan 1
+ }
+}
+
+Describe "BuildCodeCoverageSummary - Coverage file discovery" {
+
+ It "Should detect when coverage file exists" {
+ $workspace = $TestDrive
+ $project = "TestProject"
+
+ # Create expected directory structure
+ $coverageDir = Join-Path $workspace "$project/.buildartifacts/CodeCoverage"
+ New-Item -ItemType Directory -Path $coverageDir -Force | Out-Null
+ Copy-Item (Join-Path $testDataPath "cobertura1.xml") (Join-Path $coverageDir "cobertura.xml")
+
+ $coverageFile = Join-Path $workspace "$project/.buildartifacts/CodeCoverage/cobertura.xml"
+ Test-Path -Path $coverageFile -PathType Leaf | Should -BeTrue
+ }
+
+ It "Should detect when coverage file is missing" {
+ $workspace = $TestDrive
+ $coverageFile = Join-Path $workspace "missing-project/.buildartifacts/CodeCoverage/cobertura.xml"
+ Test-Path -Path $coverageFile -PathType Leaf | Should -BeFalse
+ }
+}
+
+Describe "BuildCodeCoverageSummary - Summary generation" {
+
+ It "Should generate coverage summary from Cobertura file" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.SummaryMD | Should -Not -BeNullOrEmpty
+ $result.SummaryMD | Should -Match "Coverage"
+ }
+
+ It "Should return empty summary for missing file" {
+ $result = Get-CoverageSummaryMD -CoverageFile (Join-Path $TestDrive "nonexistent.xml")
+
+ $result.SummaryMD | Should -BeNullOrEmpty
+ }
+
+ It "Should handle empty coverage data" {
+ $coverageFile = Join-Path $testDataPath "cobertura-empty.xml"
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $result | Should -Not -BeNullOrEmpty
+ }
+}
+
+Describe "BuildCodeCoverageSummary - Size limit handling" {
+
+ BeforeAll {
+ function GetStringByteSize($string) {
+ return [System.Text.Encoding]::UTF8.GetBytes($string).Length
+ }
+ }
+
+ It "Should calculate combined size correctly" {
+ $titleSize = GetStringByteSize("## Code Coverage`n`n")
+ $summarySize = GetStringByteSize("Some summary content")
+ $detailsSize = GetStringByteSize("Some details content")
+
+ $totalSize = $titleSize + $summarySize + $detailsSize
+ $totalSize | Should -BeLessThan (1MB)
+ }
+
+ It "Should detect when summary exceeds 1MB limit" {
+ $largeContent = "x" * (1MB + 1)
+ $size = GetStringByteSize($largeContent)
+
+ $size | Should -BeGreaterThan (1MB - 4)
+ }
+
+ It "Should keep small summaries under limit" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $titleSize = GetStringByteSize("## Code Coverage`n`n")
+ $summarySize = GetStringByteSize($result.SummaryMD)
+
+ ($titleSize + $summarySize) | Should -BeLessThan (1MB - 4)
+ }
+}
+
+Describe "BuildCodeCoverageSummary - Step summary output" {
+
+ It "Should write to GITHUB_STEP_SUMMARY file" {
+ $stepSummaryFile = Join-Path $TestDrive "step-summary.md"
+ Set-Content -Path $stepSummaryFile -Value "" -Encoding UTF8
+
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ if ($result.SummaryMD) {
+ Add-Content -Encoding UTF8 -Path $stepSummaryFile -Value "## Code Coverage`n`n"
+ Add-Content -Encoding UTF8 -Path $stepSummaryFile -Value "$($result.SummaryMD)`n`n"
+ }
+
+ $content = Get-Content $stepSummaryFile -Raw
+ $content | Should -Match "Code Coverage"
+ }
+}
diff --git a/Tests/CodeCoverage/CoberturaFormatter.Test.ps1 b/Tests/CodeCoverage/CoberturaFormatter.Test.ps1
new file mode 100644
index 0000000000..961bf247a4
--- /dev/null
+++ b/Tests/CodeCoverage/CoberturaFormatter.Test.ps1
@@ -0,0 +1,351 @@
+Get-Module TestActionsHelper | Remove-Module -Force
+Import-Module (Join-Path $PSScriptRoot '../TestActionsHelper.psm1')
+
+BeforeAll {
+ $scriptPath = Join-Path $PSScriptRoot "../../Actions/.Modules/TestRunner/CoverageProcessor"
+ Import-Module (Join-Path $scriptPath "CoberturaFormatter.psm1") -Force
+
+ $testDataPath = Join-Path $PSScriptRoot "TestData"
+}
+
+Describe "CoberturaFormatter - New-CoberturaDocument" {
+
+ Context "XML structure generation" {
+ It "Should create valid Cobertura XML structure" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectTypeId = 5
+ ObjectId = 50100
+ Lines = @(
+ [PSCustomObject]@{ LineNo = 10; Hits = 5; IsCovered = $true; CoverageStatus = 0 }
+ [PSCustomObject]@{ LineNo = 15; Hits = 0; IsCovered = $false; CoverageStatus = 1 }
+ )
+ SourceInfo = @{
+ FilePath = "TestCodeunit.al"
+ RelativePath = "src/TestCodeunit.al"
+ ExecutableLines = 10
+ TotalLines = 20
+ }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $xml | Should -Not -BeNullOrEmpty
+ $xml.coverage | Should -Not -BeNullOrEmpty
+ $xml.coverage.packages | Should -Not -BeNullOrEmpty
+ $xml.coverage.sources | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should include timestamp in coverage element" {
+ $coverageData = @{}
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $xml.coverage.timestamp | Should -Not -BeNullOrEmpty
+ [long]$xml.coverage.timestamp | Should -BeGreaterThan 0
+ }
+
+ It "Should calculate overall line-rate correctly" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectTypeId = 5
+ ObjectId = 50100
+ Lines = @(
+ [PSCustomObject]@{ LineNo = 10; Hits = 5; IsCovered = $true }
+ )
+ SourceInfo = @{ ExecutableLines = 4; FilePath = "test.al"; RelativePath = "test.al" }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $lineRateStr = $xml.coverage.'line-rate'
+ $lineRateStr | Should -Not -BeNullOrEmpty
+ $lineRate = [double]::Parse($lineRateStr, [System.Globalization.CultureInfo]::InvariantCulture)
+ $lineRate | Should -BeGreaterOrEqual 0
+ $lineRate | Should -BeLessOrEqual 1
+ }
+
+ It "Should handle empty coverage data" {
+ $coverageData = @{}
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $xml.coverage | Should -Not -BeNullOrEmpty
+ $xml.coverage.'lines-valid' | Should -Be "0"
+ $xml.coverage.'lines-covered' | Should -Be "0"
+ }
+
+ It "Should include app metadata when provided" {
+ $coverageData = @{}
+ $appInfo = @{
+ Name = "Test App"
+ Version = "1.0.0.0"
+ Publisher = "Test Publisher"
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData -AppInfo $appInfo
+
+ $xml | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context "Package organization" {
+ It "Should group objects by module/folder" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectId = 50100
+ Lines = @()
+ SourceInfo = @{
+ FilePath = "C:\src\Module1\Test.al"
+ RelativePath = "Module1/Test.al"
+ ExecutableLines = 5
+ }
+ }
+ "Codeunit.50200" = @{
+ ObjectType = "Codeunit"
+ ObjectId = 50200
+ Lines = @()
+ SourceInfo = @{
+ FilePath = "C:\src\Module2\Other.al"
+ RelativePath = "Module2/Other.al"
+ ExecutableLines = 10
+ }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $packages = $xml.coverage.packages.package
+ $packages.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should create classes for each AL object" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectId = 50100
+ Lines = @([PSCustomObject]@{ LineNo = 10; Hits = 1; IsCovered = $true })
+ SourceInfo = @{
+ FilePath = "Test.al"
+ RelativePath = "Test.al"
+ ExecutableLines = 5
+ }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $classes = $xml.coverage.packages.package.classes.class
+ $classes | Should -Not -BeNullOrEmpty
+ $classes.name | Should -Match "Codeunit"
+ }
+ }
+
+ Context "Line coverage" {
+ It "Should include line elements with hit counts" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectId = 50100
+ Lines = @(
+ [PSCustomObject]@{ LineNo = 10; Hits = 5; IsCovered = $true }
+ [PSCustomObject]@{ LineNo = 15; Hits = 3; IsCovered = $true }
+ )
+ SourceInfo = @{
+ FilePath = "Test.al"
+ RelativePath = "Test.al"
+ ExecutableLines = 10
+ }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $lines = $xml.coverage.packages.package.classes.class.lines.line
+ $lines | Should -Not -BeNullOrEmpty
+ $lines[0].number | Should -Be "10"
+ $lines[0].hits | Should -Be "5"
+ }
+
+ It "Should include lines with zero hits when ExecutableLineNumbers provided" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectId = 50100
+ Lines = @(
+ [PSCustomObject]@{ LineNo = 10; Hits = 1; IsCovered = $true }
+ )
+ SourceInfo = @{
+ FilePath = "Test.al"
+ RelativePath = "Test.al"
+ ExecutableLines = 5
+ ExecutableLineNumbers = @(10, 15, 20)
+ }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $lines = $xml.coverage.packages.package.classes.class.lines.line
+ $lines.Count | Should -BeGreaterThan 1
+ $zeroHitLine = $lines | Where-Object { $_.number -eq "15" }
+ $zeroHitLine.hits | Should -Be "0"
+ }
+ }
+
+ Context "Method coverage" {
+ It "Should include method elements when procedures available" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectId = 50100
+ Lines = @(
+ [PSCustomObject]@{ LineNo = 10; Hits = 5; IsCovered = $true }
+ )
+ SourceInfo = @{
+ FilePath = "Test.al"
+ RelativePath = "Test.al"
+ ExecutableLines = 10
+ Procedures = @(
+ @{ Name = "TestProcedure"; StartLine = 8; EndLine = 12 }
+ )
+ }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+
+ $methods = $xml.coverage.packages.package.classes.class.methods.method
+ if ($methods) {
+ $methods.name | Should -Contain "TestProcedure"
+ }
+ }
+ }
+}
+
+Describe "CoberturaFormatter - Save-CoberturaFile" {
+
+ Context "File output" {
+ It "Should save XML to specified path" {
+ $coverageData = @{
+ "Codeunit.50100" = @{
+ ObjectType = "Codeunit"
+ ObjectId = 50100
+ Lines = @()
+ SourceInfo = @{
+ FilePath = "Test.al"
+ RelativePath = "Test.al"
+ ExecutableLines = 5
+ }
+ }
+ }
+
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+ $outputPath = Join-Path $TestDrive "output.cobertura.xml"
+
+ Save-CoberturaFile -XmlDocument $xml -OutputPath $outputPath
+
+ $outputPath | Should -Exist
+ }
+
+ It "Should create valid XML file" {
+ $coverageData = @{}
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+ $outputPath = Join-Path $TestDrive "valid.cobertura.xml"
+
+ Save-CoberturaFile -XmlDocument $xml -OutputPath $outputPath
+
+ { [xml](Get-Content $outputPath -Raw) } | Should -Not -Throw
+ }
+
+ It "Should use UTF-8 encoding" {
+ $coverageData = @{}
+ $xml = New-CoberturaDocument -CoverageData $coverageData
+ $outputPath = Join-Path $TestDrive "utf8.cobertura.xml"
+
+ Save-CoberturaFile -XmlDocument $xml -OutputPath $outputPath
+
+ $content = Get-Content $outputPath -Raw
+ $content | Should -Match '= 80%" {
+ $icon = Get-CoverageStatusIcon -Coverage 80
+ $icon | Should -Match "green"
+ }
+
+ It "Should return green circle for 100%" {
+ $icon = Get-CoverageStatusIcon -Coverage 100
+ $icon | Should -Match "green"
+ }
+
+ It "Should return yellow circle for 50-79%" {
+ $icon = Get-CoverageStatusIcon -Coverage 50
+ $icon | Should -Match "yellow"
+
+ $icon = Get-CoverageStatusIcon -Coverage 79
+ $icon | Should -Match "yellow"
+ }
+
+ It "Should return red circle for < 50%" {
+ $icon = Get-CoverageStatusIcon -Coverage 0
+ $icon | Should -Match "red"
+
+ $icon = Get-CoverageStatusIcon -Coverage 49
+ $icon | Should -Match "red"
+ }
+ }
+}
+
+Describe "CoverageReportGenerator - Format-CoveragePercent" {
+
+ Context "Percentage formatting" {
+ It "Should format line rate as percentage with icon" {
+ $result = Format-CoveragePercent -LineRate 0.85
+
+ $result | Should -Match "85"
+ $result | Should -Match "%"
+ }
+
+ It "Should include appropriate icon" {
+ $result = Format-CoveragePercent -LineRate 0.85
+ $result | Should -Match "circle"
+ }
+
+ It "Should handle 0% coverage" {
+ $result = Format-CoveragePercent -LineRate 0
+ $result | Should -Match "0"
+ $result | Should -Match "%"
+ }
+
+ It "Should handle 100% coverage" {
+ $result = Format-CoveragePercent -LineRate 1.0
+ $result | Should -Match "100"
+ $result | Should -Match "%"
+ }
+ }
+}
+
+Describe "CoverageReportGenerator - Read-CoberturaFile" {
+
+ Context "File parsing" {
+ It "Should parse valid Cobertura XML" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+
+ $data = Read-CoberturaFile -CoverageFile $coverageFile
+
+ $data | Should -Not -BeNullOrEmpty
+ $data.LineRate | Should -BeGreaterOrEqual 0
+ $data.LineRate | Should -BeLessOrEqual 1
+ }
+
+ It "Should extract coverage statistics" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+
+ $data = Read-CoberturaFile -CoverageFile $coverageFile
+
+ $data.LinesCovered | Should -BeGreaterOrEqual 0
+ $data.LinesValid | Should -BeGreaterOrEqual 0
+ }
+
+ It "Should group classes by package" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+
+ $data = Read-CoberturaFile -CoverageFile $coverageFile
+
+ $data.Packages | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should handle empty coverage file" {
+ $coverageFile = Join-Path $testDataPath "cobertura-empty.xml"
+
+ $data = Read-CoberturaFile -CoverageFile $coverageFile
+
+ $data | Should -Not -BeNullOrEmpty
+ }
+ }
+}
+
+Describe "CoverageReportGenerator - Get-CoverageSummaryMD" {
+
+ Context "Markdown generation" {
+ It "Should generate markdown summary" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.SummaryMD | Should -Not -BeNullOrEmpty
+ $result.SummaryMD | Should -Match "Coverage"
+ }
+
+ It "Should include overall coverage percentage" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $result.SummaryMD | Should -Match "%"
+ }
+
+ It "Should include module/package breakdown in details" {
+ $coverageFile = Join-Path $testDataPath "cobertura1.xml"
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ # Should have table with coverage data in details or summary
+ ($result.SummaryMD + $result.DetailsMD) | Should -Match "\|"
+ }
+
+ It "Should handle empty coverage" {
+ $coverageFile = Join-Path $testDataPath "cobertura-empty.xml"
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $result | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should load stats from matching .stats.json file" {
+ $coverageFile = Join-Path $TestDrive "test.cobertura.xml"
+ $statsFile = Join-Path $TestDrive "test.cobertura.stats.json"
+
+ # Copy test coverage file
+ Copy-Item (Join-Path $testDataPath "cobertura1.xml") $coverageFile
+
+ # Create matching stats file
+ @{
+ ExcludedObjectCount = 5
+ ExcludedLinesExecuted = 100
+ } | ConvertTo-Json | Set-Content $statsFile -Encoding UTF8
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $result | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should handle missing coverage file gracefully" {
+ $coverageFile = Join-Path $TestDrive "nonexistent.xml"
+
+ $result = Get-CoverageSummaryMD -CoverageFile $coverageFile
+
+ $result.SummaryMD | Should -BeNullOrEmpty
+ }
+ }
+}
diff --git a/Tests/CodeCoverage/MergeCoverageSummaries.Test.ps1 b/Tests/CodeCoverage/MergeCoverageSummaries.Test.ps1
new file mode 100644
index 0000000000..2cf8bbd894
--- /dev/null
+++ b/Tests/CodeCoverage/MergeCoverageSummaries.Test.ps1
@@ -0,0 +1,213 @@
+Get-Module TestActionsHelper | Remove-Module -Force
+Import-Module (Join-Path $PSScriptRoot '../TestActionsHelper.psm1')
+
+BeforeAll {
+ . (Join-Path $PSScriptRoot "../../Actions/BuildCodeCoverageSummary/CoverageReportGenerator.ps1")
+ Import-Module (Join-Path $PSScriptRoot "../../Actions/MergeCoverageSummaries/CoberturaMerger.psm1") -Force -DisableNameChecking
+
+ $testDataPath = Join-Path $PSScriptRoot "TestData/CoberturaFiles"
+}
+
+Describe "MergeCoverageSummaries - Artifact discovery" {
+
+ Context "Finding coverage files" {
+ It "Should find cobertura.xml files in subdirectories" {
+ $artifactDir = Join-Path $TestDrive "artifacts"
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job1-CodeCoverage") -Force | Out-Null
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job2-CodeCoverage") -Force | Out-Null
+
+ Copy-Item (Join-Path $testDataPath "cobertura1.xml") (Join-Path $artifactDir "job1-CodeCoverage/cobertura.xml")
+ Copy-Item (Join-Path $testDataPath "cobertura2.xml") (Join-Path $artifactDir "job2-CodeCoverage/cobertura.xml")
+
+ $files = @(Get-ChildItem -Path $artifactDir -Filter "cobertura.xml" -Recurse -File)
+
+ $files.Count | Should -Be 2
+ }
+
+ It "Should return empty when no coverage files exist" {
+ $emptyDir = Join-Path $TestDrive "empty-artifacts"
+ New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
+
+ $files = @(Get-ChildItem -Path $emptyDir -Filter "cobertura.xml" -Recurse -File)
+
+ $files.Count | Should -Be 0
+ }
+
+ It "Should handle missing artifact directory" {
+ $missingDir = Join-Path $TestDrive "nonexistent"
+
+ Test-Path $missingDir | Should -BeFalse
+ }
+ }
+}
+
+Describe "MergeCoverageSummaries - Single file bypass" {
+
+ It "Should use single file directly without merging" {
+ $artifactDir = Join-Path $TestDrive "single-artifact"
+ $jobDir = Join-Path $artifactDir "job1-CodeCoverage"
+ New-Item -ItemType Directory -Path $jobDir -Force | Out-Null
+
+ $sourceFile = Join-Path $testDataPath "cobertura1.xml"
+ Copy-Item $sourceFile (Join-Path $jobDir "cobertura.xml")
+
+ $coberturaFiles = @(Get-ChildItem -Path $artifactDir -Filter "cobertura.xml" -Recurse -File)
+
+ $coberturaFiles.Count | Should -Be 1
+ # When count is 1, the action uses the file directly
+ $mergedFile = $coberturaFiles[0].FullName
+ Test-Path $mergedFile | Should -BeTrue
+ }
+}
+
+Describe "MergeCoverageSummaries - Multi-file merge" {
+
+ Context "Merging multiple coverage files" {
+ It "Should merge multiple cobertura files" {
+ $artifactDir = Join-Path $TestDrive "merge-artifacts"
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job1") -Force | Out-Null
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job2") -Force | Out-Null
+
+ Copy-Item (Join-Path $testDataPath "cobertura1.xml") (Join-Path $artifactDir "job1/cobertura.xml")
+ Copy-Item (Join-Path $testDataPath "cobertura2.xml") (Join-Path $artifactDir "job2/cobertura.xml")
+
+ $coberturaFiles = @(Get-ChildItem -Path $artifactDir -Filter "cobertura.xml" -Recurse -File)
+ $mergedOutputDir = Join-Path $artifactDir "_merged"
+ $mergedFile = Join-Path $mergedOutputDir "cobertura.xml"
+
+ $stats = Merge-CoberturaFiles `
+ -CoberturaFiles ($coberturaFiles.FullName) `
+ -OutputPath $mergedFile
+
+ $mergedFile | Should -Exist
+ $stats | Should -Not -BeNullOrEmpty
+ $stats.InputFileCount | Should -Be 2
+ }
+
+ It "Should produce valid merged XML" {
+ $artifactDir = Join-Path $TestDrive "valid-merge"
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job1") -Force | Out-Null
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job2") -Force | Out-Null
+
+ Copy-Item (Join-Path $testDataPath "cobertura1.xml") (Join-Path $artifactDir "job1/cobertura.xml")
+ Copy-Item (Join-Path $testDataPath "cobertura2.xml") (Join-Path $artifactDir "job2/cobertura.xml")
+
+ $coberturaFiles = @(Get-ChildItem -Path $artifactDir -Filter "cobertura.xml" -Recurse -File)
+ $mergedFile = Join-Path $artifactDir "_merged/cobertura.xml"
+
+ Merge-CoberturaFiles -CoberturaFiles ($coberturaFiles.FullName) -OutputPath $mergedFile
+
+ { [xml](Get-Content $mergedFile -Raw) } | Should -Not -Throw
+ }
+ }
+}
+
+Describe "MergeCoverageSummaries - Stats file merging" {
+
+ Context "Merging stats metadata" {
+ It "Should find and merge stats.json files" {
+ $artifactDir = Join-Path $TestDrive "stats-merge"
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job1") -Force | Out-Null
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job2") -Force | Out-Null
+
+ @{ AppSourcePaths = @("src/App1"); ExcludedObjectCount = 3 } |
+ ConvertTo-Json | Set-Content (Join-Path $artifactDir "job1/cobertura.stats.json") -Encoding UTF8
+ @{ AppSourcePaths = @("src/App2"); ExcludedObjectCount = 5 } |
+ ConvertTo-Json | Set-Content (Join-Path $artifactDir "job2/cobertura.stats.json") -Encoding UTF8
+
+ $statsFiles = @(Get-ChildItem -Path $artifactDir -Filter "cobertura.stats.json" -Recurse -File)
+ $merged = Merge-CoverageStats -StatsFiles ($statsFiles.FullName)
+
+ $merged | Should -Not -BeNullOrEmpty
+ $merged.ExcludedObjectCount | Should -Be 8
+ $merged.AppSourcePaths.Count | Should -Be 2
+ }
+
+ It "Should handle missing stats files gracefully" {
+ $artifactDir = Join-Path $TestDrive "no-stats"
+ New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null
+
+ $statsFiles = @(Get-ChildItem -Path $artifactDir -Filter "cobertura.stats.json" -Recurse -File)
+
+ $statsFiles.Count | Should -Be 0
+ }
+ }
+}
+
+Describe "MergeCoverageSummaries - Summary generation" {
+
+ Context "Consolidated coverage report" {
+ It "Should generate markdown from merged file" {
+ $artifactDir = Join-Path $TestDrive "summary-gen"
+ New-Item -ItemType Directory -Path (Join-Path $artifactDir "job1") -Force | Out-Null
+ Copy-Item (Join-Path $testDataPath "cobertura1.xml") (Join-Path $artifactDir "job1/cobertura.xml")
+
+ $coberturaFiles = @(Get-ChildItem -Path $artifactDir -Filter "cobertura.xml" -Recurse -File)
+ $mergedFile = $coberturaFiles[0].FullName
+
+ $result = Get-CoverageSummaryMD -CoverageFile $mergedFile
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.SummaryMD | Should -Match "Coverage"
+ }
+ }
+}
+
+Describe "MergeCoverageSummaries - Size limit handling" {
+
+ BeforeAll {
+ function GetStringByteSize($string) {
+ return [System.Text.Encoding]::UTF8.GetBytes($string).Length
+ }
+ }
+
+ It "Should calculate header and info sizes" {
+ $header = "## :bar_chart: Code Coverage - Consolidated`n`n"
+ $inputInfo = ":information_source: Merged from **2** build job(s)`n`n"
+
+ $headerSize = GetStringByteSize($header)
+ $inputInfoSize = GetStringByteSize($inputInfo)
+
+ $headerSize | Should -BeGreaterThan 0
+ $inputInfoSize | Should -BeGreaterThan 0
+ ($headerSize + $inputInfoSize) | Should -BeLessThan (1MB)
+ }
+}
+
+Describe "MergeCoverageSummaries - Incomplete build warning" {
+
+ It "Should detect incomplete build from BUILD_RESULT env var" {
+ # Simulate the logic from the action script
+ $buildResult = 'failure'
+ $incompleteWarning = ""
+
+ if ($buildResult -eq 'failure') {
+ $incompleteWarning = "> :warning: **Incomplete coverage data** - some build jobs failed"
+ }
+
+ $incompleteWarning | Should -Not -BeNullOrEmpty
+ $incompleteWarning | Should -Match "Incomplete"
+ }
+
+ It "Should not warn for successful builds" {
+ $buildResult = 'success'
+ $incompleteWarning = ""
+
+ if ($buildResult -eq 'failure') {
+ $incompleteWarning = "> :warning: **Incomplete coverage data**"
+ }
+
+ $incompleteWarning | Should -BeNullOrEmpty
+ }
+}
+
+Describe "MergeCoverageSummaries - Output variables" {
+
+ It "Should format output variable correctly" {
+ $mergedFile = "C:\workspace\.coverage-inputs\_merged\cobertura.xml"
+ $outputLine = "mergedCoverageFile=$mergedFile"
+
+ $outputLine | Should -Match "mergedCoverageFile="
+ $outputLine | Should -Match "cobertura.xml"
+ }
+}
diff --git a/Tests/CodeCoverage/TestData/ALFiles/complex-codeunit.al b/Tests/CodeCoverage/TestData/ALFiles/complex-codeunit.al
new file mode 100644
index 0000000000..7cca3565f9
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/ALFiles/complex-codeunit.al
@@ -0,0 +1,44 @@
+codeunit 50102 "Complex Codeunit"
+{
+ // Header comments
+ // More comments
+
+ var
+ GlobalVar: Integer;
+
+ procedure MainProcedure(): Boolean
+ var
+ localVar: Text;
+ counter: Integer;
+ begin
+ // Initialize
+ localVar := 'Test';
+ counter := 0;
+
+ // Process
+ repeat
+ counter += 1;
+ if counter mod 2 = 0 then
+ DoEvenProcessing()
+ else
+ DoOddProcessing();
+ until counter >= 10;
+
+ exit(true);
+ end;
+
+ local procedure DoEvenProcessing()
+ begin
+ GlobalVar += 2;
+ end;
+
+ local procedure DoOddProcessing()
+ begin
+ GlobalVar += 1;
+ end;
+
+ procedure GetGlobalVar(): Integer
+ begin
+ exit(GlobalVar);
+ end;
+}
diff --git a/Tests/CodeCoverage/TestData/ALFiles/sample-codeunit.al b/Tests/CodeCoverage/TestData/ALFiles/sample-codeunit.al
new file mode 100644
index 0000000000..1ac38c2c32
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/ALFiles/sample-codeunit.al
@@ -0,0 +1,25 @@
+codeunit 50100 "Test Codeunit 1"
+{
+ procedure TestProcedure1()
+ var
+ myVar: Integer;
+ begin
+ // This is a comment
+ myVar := 10;
+ if myVar > 5 then
+ myVar := 20;
+
+ DoSomething(myVar);
+ end;
+
+ procedure TestProcedure2()
+ begin
+ Message('Hello World');
+ end;
+
+ local procedure DoSomething(value: Integer)
+ begin
+ // Another comment
+ Message('Value: %1', value);
+ end;
+}
diff --git a/Tests/CodeCoverage/TestData/ALFiles/sample-page.al b/Tests/CodeCoverage/TestData/ALFiles/sample-page.al
new file mode 100644
index 0000000000..693a24c887
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/ALFiles/sample-page.al
@@ -0,0 +1,31 @@
+page 50001 "Test Page"
+{
+ PageType = Card;
+ SourceTable = "Test Table";
+
+ layout
+ {
+ area(Content)
+ {
+ field(MyField; Rec.MyField)
+ {
+ ApplicationArea = All;
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(MyAction)
+ {
+ ApplicationArea = All;
+ trigger OnAction()
+ begin
+ Message('Action executed');
+ end;
+ }
+ }
+ }
+}
diff --git a/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura-empty.xml b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura-empty.xml
new file mode 100644
index 0000000000..67cfdf7961
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura-empty.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura-malformed.xml b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura-malformed.xml
new file mode 100644
index 0000000000..137539c390
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura-malformed.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura1.xml b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura1.xml
new file mode 100644
index 0000000000..2450162f26
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura1.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura2.xml b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura2.xml
new file mode 100644
index 0000000000..4596497c06
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/CoberturaFiles/cobertura2.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/CodeCoverage/TestData/CoverageFiles/empty-coverage.dat b/Tests/CodeCoverage/TestData/CoverageFiles/empty-coverage.dat
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/Tests/CodeCoverage/TestData/CoverageFiles/malformed-coverage.dat b/Tests/CodeCoverage/TestData/CoverageFiles/malformed-coverage.dat
new file mode 100644
index 0000000000..ac0594bba2
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/CoverageFiles/malformed-coverage.dat
@@ -0,0 +1,3 @@
+This is not valid CSV
+Random data here
+123,456,abc
diff --git a/Tests/CodeCoverage/TestData/CoverageFiles/sample-coverage.dat b/Tests/CodeCoverage/TestData/CoverageFiles/sample-coverage.dat
new file mode 100644
index 0000000000..943811d0a9
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/CoverageFiles/sample-coverage.dat
@@ -0,0 +1,11 @@
+5,50100,10,0,5
+5,50100,11,0,5
+5,50100,12,0,3
+5,50100,15,1,0
+5,50100,16,0,2
+5,50101,20,0,10
+5,50101,21,0,10
+5,50101,22,1,0
+3,50000,5,0,100
+3,50000,6,0,100
+8,50001,30,0,1
diff --git a/Tests/CodeCoverage/TestData/CoverageFiles/sample-coverage.xml b/Tests/CodeCoverage/TestData/CoverageFiles/sample-coverage.xml
new file mode 100644
index 0000000000..0e6b222629
--- /dev/null
+++ b/Tests/CodeCoverage/TestData/CoverageFiles/sample-coverage.xml
@@ -0,0 +1,59 @@
+
+
+
+ 5
+ 50100
+ 10
+ 0
+ 5
+
+
+ 5
+ 50100
+ 11
+ 0
+ 5
+
+
+ 5
+ 50100
+ 12
+ 0
+ 3
+
+
+ 5
+ 50100
+ 15
+ 1
+ 0
+
+
+ 5
+ 50100
+ 16
+ 0
+ 2
+
+
+ 5
+ 50101
+ 20
+ 0
+ 10
+
+
+ 5
+ 50101
+ 21
+ 0
+ 10
+
+
+ 5
+ 50101
+ 22
+ 1
+ 0
+
+