Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Actions/.Modules/ReadSettings.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ function GetDefaultSettings
"doNotRunTests" = $false
"doNotRunBcptTests" = $false
"doNotRunPageScriptingTests" = $false
"testIsolation" = [ordered]@{
"enabled" = $false
"defaultRunnerCodeunitId" = 0
"partitions" = @()
}
"doNotPublishApps" = $false
"doNotSignApps" = $false
"configPackages" = @()
Expand Down
238 changes: 238 additions & 0 deletions Actions/.Modules/TestIsolation.psm1
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<#
.SYNOPSIS
Test Isolation module for AL-Go for GitHub.

.DESCRIPTION
Builds a scriptblock compatible with Run-AlPipeline's -RunTestsInBcContainer
override. The scriptblock runs the tests once per declared partition (each with
its own test runner and codeunit-range filter), plus one trailing call under
the default runner whose filter is the complement of every explicit partition's
filter.
#>

Import-Module (Join-Path -Path $PSScriptRoot "DebugLogHelper.psm1")

# Codeunit IDs are positive 32-bit integers
$script:maxCodeunitId = [long] 2147483647

function ConvertTo-CodeunitIntervals {
<#
.SYNOPSIS
Parse a partition 'codeunits' filter into a list of [lo, hi] integer intervals.
.DESCRIPTION
Only single IDs and closed ranges joined by '|' are supported
(e.g. '60100|60200..60299'). This is deliberately a subset of the BC
filter grammar: the complement AL-Go computes for the trailing
default-runner call is only well-defined over unions of closed
intervals. The settings schema enforces the same subset; this
function is the defense for settings that bypassed schema validation.
#>
Param(
[Parameter(Mandatory = $true)]
[string] $Filter
)

$intervals = @()
foreach ($piece in $Filter.Split('|')) {
$trimmed = $piece.Trim()
if (-not $trimmed) { continue }
if ($trimmed -match '^(\d+)$') {
$lo = [long] $Matches[1]
$hi = $lo
}
elseif ($trimmed -match '^(\d+)\s*\.\.\s*(\d+)$') {
$lo = [long] $Matches[1]
$hi = [long] $Matches[2]
}
else {
throw "Unsupported piece '$trimmed' in testIsolation codeunits filter '$Filter'. Supported syntax is single codeunit IDs and closed ranges joined by '|' (e.g. '60100|60200..60299')."
}
if ($lo -lt 1 -or $hi -gt $script:maxCodeunitId) {
throw "Codeunit IDs in testIsolation codeunits filter '$Filter' must be between 1 and $script:maxCodeunitId."
}
if ($lo -gt $hi) {
throw "Invalid range '$trimmed' in testIsolation codeunits filter '$Filter': lower bound is greater than upper bound."
}
$intervals += , @($lo, $hi)
}
if ($intervals.Count -eq 0) {
throw "testIsolation codeunits filter '$Filter' does not contain any codeunit IDs."
}
return , $intervals
}

function Get-MergedIntervals {
Param(
[Parameter(Mandatory = $true)]
$Intervals
)

$sorted = @($Intervals | Sort-Object -Property @{ Expression = { $_[0] } }, @{ Expression = { $_[1] } })
$merged = @()
foreach ($interval in $sorted) {
# Also merge adjacent intervals (lo = previous hi + 1) so the complement never contains empty gaps
if ($merged.Count -gt 0 -and $interval[0] -le ($merged[-1][1] + 1)) {
if ($interval[1] -gt $merged[-1][1]) { $merged[-1][1] = $interval[1] }
}
else {
$merged += , @($interval[0], $interval[1])
}
}
return , $merged
}

function Get-ComplementFilter {
<#
.SYNOPSIS
Build a BC filter expression matching every codeunit ID NOT covered by the given merged intervals.
.DESCRIPTION
The BC filter grammar has no negation over ranges ('<>' only applies
to single values), so the exclusion filter is expressed as the
complement: a '|'-joined union of the gaps, e.g. excluding 60100 and
60200..60299 yields '..60099|60101..60199|60300..'. Returns an empty
string when the intervals cover the entire codeunit ID space.
#>
Param(
[Parameter(Mandatory = $true)]
$MergedIntervals
)

$pieces = @()
$next = [long] 1
foreach ($interval in $MergedIntervals) {
if ($interval[0] -gt $next) {
$gapHi = $interval[0] - 1
if ($next -eq 1) { $pieces += "..$gapHi" }
elseif ($next -eq $gapHi) { $pieces += "$next" }
else { $pieces += "$next..$gapHi" }
}
if (($interval[1] + 1) -gt $next) { $next = $interval[1] + 1 }
}
if ($next -le $script:maxCodeunitId) {
$pieces += "$next.."
}
return ($pieces -join '|')
}

function New-PartitionedTestRunnerScriptBlock {
<#
.SYNOPSIS
Build a scriptblock for Run-AlPipeline's -RunTestsInBcContainer hook.
.DESCRIPTION
Run-AlPipeline invokes the override once per test app with a hashtable
of parameters (extensionId, containerName, disabledTests, JUnit/XUnit
file, AppendTo*ResultFile, auth context, etc.). The returned
scriptblock loops over the configured partitions and, for each one,
runs the tests with the partition's runner id and codeunit-range
filter. After all partitions, it issues one trailing call under
defaultRunnerCodeunitId whose -testCodeunitRange is the complement of
the union of every explicit partition's filter - so every test
codeunit not matched by an explicit partition runs under the default
runner exactly once. If the partitions cover the entire codeunit ID
space, the trailing call is skipped.

Result-file appending is preserved because we forward the JUnit/XUnit
file params Run-AlPipeline already configured (AppendTo*ResultFile = $true).
.PARAMETER Settings
The testIsolation settings object with `defaultRunnerCodeunitId` and
`partitions` (array of @{ runnerCodeunitId; codeunits }). Closed over
by the returned scriptblock.
.PARAMETER InnerScriptBlock
The scriptblock each partitioned call is routed through, with the
same contract as Run-AlPipeline's -RunTestsInBcContainer override
(receives a parameter hashtable, returns $true if all tests passed).
Pass a project's existing RunTestsInBcContainer override here so
partitioning wraps it instead of replacing it. Defaults to calling
Run-TestsInBcContainer directly. The scriptblock must splat the
hashtable on to Run-TestsInBcContainer for the partition-specific
testCodeunitRange/testRunnerCodeunitId entries to take effect.
.OUTPUTS
[scriptblock] returning $true if every invocation reported success.
#>
Param(
[Parameter(Mandatory = $true)]
$Settings,
[scriptblock] $InnerScriptBlock
)

$capturedPartitions = @($Settings.partitions)
$capturedDefaultRunner = [int] $Settings.defaultRunnerCodeunitId
$capturedInner = $InnerScriptBlock
if (-not $capturedInner) {
$capturedInner = { Param([Hashtable] $parameters) Run-TestsInBcContainer @parameters }
}

$partitionIntervalSets = @()
foreach ($p in $capturedPartitions) {
$partitionIntervalSets += , (ConvertTo-CodeunitIntervals -Filter ([string] $p.codeunits))
}

# Overlapping partitions run the shared codeunits once per matching partition,
# duplicating them in the test results - warn, but leave the config decision to the user
for ($i = 0; $i -lt $partitionIntervalSets.Count; $i++) {
for ($j = $i + 1; $j -lt $partitionIntervalSets.Count; $j++) {
$overlaps = $false
foreach ($a in $partitionIntervalSets[$i]) {
foreach ($b in $partitionIntervalSets[$j]) {
if (($a[0] -le $b[1]) -and ($b[0] -le $a[1])) { $overlaps = $true; break }
}
if ($overlaps) { break }
}
if ($overlaps) {
OutputWarning -message "testIsolation partitions overlap: '$($capturedPartitions[$i].codeunits)' (runner $($capturedPartitions[$i].runnerCodeunitId)) and '$($capturedPartitions[$j].codeunits)' (runner $($capturedPartitions[$j].runnerCodeunitId)). Overlapping codeunits run once per matching partition and appear multiple times in the test results."
}
}
}

$defaultRangeFilter = ''
$skipDefaultCall = $false
if ($capturedPartitions.Count -gt 0) {
$allIntervals = @()
foreach ($set in $partitionIntervalSets) { $allIntervals += $set }
$defaultRangeFilter = Get-ComplementFilter -MergedIntervals (Get-MergedIntervals -Intervals $allIntervals)
$skipDefaultCall = (-not $defaultRangeFilter)
}

return {
Param([Hashtable] $parameters)

$appId = "$($parameters.extensionId)"
$allPassed = $true
$invocations = 0

foreach ($p in $capturedPartitions) {
$call = @{}
foreach ($k in $parameters.Keys) { $call[$k] = $parameters[$k] }
$call['testCodeunitRange'] = "$($p.codeunits)"
$call['testRunnerCodeunitId'] = "$([int] $p.runnerCodeunitId)"

Write-Host "Running partition runner=$($p.runnerCodeunitId) range='$($p.codeunits)' app=$appId"
$invocations++

$passed = & $capturedInner $call
if (-not $passed) { $allPassed = $false }
}

if ($skipDefaultCall) {
Write-Host "Partitions cover the entire codeunit ID space - skipping the default-runner call for app $appId"
}
else {
$defaultCall = @{}
foreach ($k in $parameters.Keys) { $defaultCall[$k] = $parameters[$k] }
if ($defaultRangeFilter) { $defaultCall['testCodeunitRange'] = $defaultRangeFilter }
if ($capturedDefaultRunner -gt 0) { $defaultCall['testRunnerCodeunitId'] = "$capturedDefaultRunner" }
$defaultRunnerDisplay = if ($capturedDefaultRunner -gt 0) { "$capturedDefaultRunner" } else { "BC default" }

Write-Host "Running default partition runner=$defaultRunnerDisplay range='$defaultRangeFilter' app=$appId"
$invocations++

$passed = & $capturedInner $defaultCall
if (-not $passed) { $allPassed = $false }
}

Write-Host "Partitioned test run for app $appId complete. Invocations: $invocations. All passed: $allPassed"
return $allPassed
}.GetNewClosure()
}

Export-ModuleMember -Function New-PartitionedTestRunnerScriptBlock
39 changes: 39 additions & 0 deletions Actions/.Modules/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,45 @@
"treatTestFailuresAsWarnings": {
"type": "boolean"
},
"testIsolation": {
"type": "object",
"additionalProperties": false,
"required": [ "enabled", "defaultRunnerCodeunitId", "partitions" ],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable partitioned test runs. When true, AL-Go invokes Run-TestsInBcContainer once per entry in 'partitions' (each with its own test runner) plus one fallback call covering everything else under 'defaultRunnerCodeunitId'."
},
"defaultRunnerCodeunitId": {
"type": "integer",
"minimum": 0,
"description": "Test runner codeunit ID used for codeunits not matched by any 'partitions' entry. 0 = the BcContainerHelper default runner."
},
"partitions": {
"type": "array",
"description": "Each entry runs the codeunits matching its 'codeunits' BC filter under the configured 'runnerCodeunitId'. Codeunits not matched by any entry fall through to 'defaultRunnerCodeunitId'.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [ "runnerCodeunitId", "codeunits" ],
"properties": {
"runnerCodeunitId": {
"type": "integer",
"minimum": 1,
"description": "ID of a Subtype = TestRunner codeunit whose TestIsolation property satisfies the codeunits matched below."
},
"codeunits": {
"type": "string",
"minLength": 1,
"pattern": "^\\s*\\d+(\\.\\.\\d+)?(\\s*\\|\\s*\\d+(\\.\\.\\d+)?)*\\s*$",
"description": "Codeunit IDs to run under 'runnerCodeunitId': single IDs and closed ranges joined by '|' (e.g. '60100|60200..60299'). Only this subset of the BC filter syntax is supported, because AL-Go derives the complement of all partitions for the default-runner call."
}
}
}
}
},
"description": "See https://aka.ms/ALGoSettings#testIsolation"
},
"rulesetFile": {
"type": "string"
},
Expand Down
19 changes: 19 additions & 0 deletions Actions/RunPipeline/RunPipeline.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,25 @@ try {
$runAlPipelineParams["preprocessorsymbols"] = $settings.preprocessorSymbols
$runAlPipelineParams["features"] = $settings.features

if ($settings.testIsolation.enabled -and -not $settings.doNotRunTests) {
Import-Module (Join-Path $PSScriptRoot '../.Modules/TestIsolation.psm1' -Resolve)
Write-Host "Test isolation enabled - $($settings.testIsolation.partitions.Count) explicit partition(s) + default runner ($($settings.testIsolation.defaultRunnerCodeunitId))"

$testIsolationParams = @{ Settings = $settings.testIsolation }
if ($runAlPipelineParams.Keys -contains 'RunTestsInBcContainer') {
Write-Host "Existing RunTestsInBcContainer override detected - test isolation partitioning will wrap it"
$testIsolationParams.InnerScriptBlock = $runAlPipelineParams.RunTestsInBcContainer
}

$testIsolationTelemetry = [System.Collections.Generic.Dictionary[[System.String], [System.String]]]::new()
Add-TelemetryProperty -Hashtable $testIsolationTelemetry -Key 'PartitionCount' -Value "$($settings.testIsolation.partitions.Count)"
Add-TelemetryProperty -Hashtable $testIsolationTelemetry -Key 'DefaultRunnerCodeunitId' -Value "$($settings.testIsolation.defaultRunnerCodeunitId)"
Add-TelemetryProperty -Hashtable $testIsolationTelemetry -Key 'WrapsCustomOverride' -Value "$($testIsolationParams.ContainsKey('InnerScriptBlock'))"
Trace-Information -Message "Test Isolation enabled" -AdditionalData $testIsolationTelemetry

$runAlPipelineParams["RunTestsInBcContainer"] = New-PartitionedTestRunnerScriptBlock @testIsolationParams
}

Write-Host "Invoke Run-AlPipeline with buildmode $buildMode"
Run-AlPipeline @runAlPipelineParams `
-accept_insiderEula `
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Try out the [AL-Go workshop](https://aka.ms/algoworkshop) for an in-depth worksh
1. [DeliveryTargets and NuGet/GitHub Packages](Scenarios/DeliveryTargets.md)
1. [Enabling Telemetry for AL-Go workflows and actions](Scenarios/EnablingTelemetry.md)
1. [Add a performance test app to an existing project](Scenarios/AddAPerformanceTestApp.md)
1. [Partition test runs by required isolation](Scenarios/TestIsolation.md)
1. [Publish your app to AppSource](Scenarios/PublishToAppSource.md)
1. [Connect your GitHub repository to Power Platform](Scenarios/SetupPowerPlatform.md)
1. [How to set up Service Principal for Power Platform](Scenarios/SetupServicePrincipalForPowerPlatform.md)
Expand Down
23 changes: 23 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
### Test Isolation - run selected test codeunits under a custom test runner
Comment thread
Drakonian marked this conversation as resolved.
Comment thread
Drakonian marked this conversation as resolved.

Business Central test codeunits can declare a `RequiredTestIsolation` value (BC runtime 16+) that tells the runtime which transactional isolation they need. The standard test runner shipped by BC has a single fixed `TestIsolation` value and cannot satisfy multiple requirements at once. AL-Go for GitHub now supports running tests under multiple runners in a single pipeline pass.

When `testIsolation.enabled` is set in your settings, AL-Go partitions the test stage: each entry in `partitions` runs the matched codeunits under the configured `runnerCodeunitId`, and everything else runs under `defaultRunnerCodeunitId`. Results are merged back into the same JUnit file downstream reporting already consumes; container lifecycle and `disabledTests.json` handling are unchanged, and an existing `RunTestsInBcContainer.ps1` override is wrapped (each partitioned call is routed through it) rather than replaced.

Enable it by adding:

```json
{
"testIsolation": {
"enabled": true,
"defaultRunnerCodeunitId": 0,
"partitions": [
{ "runnerCodeunitId": 130451, "codeunits": "60200..60299" },
{ "runnerCodeunitId": 130452, "codeunits": "60300|60301" }
]
}
}
```

Requires BC 15+ (the test page must expose the `TestCodeunitRangeFilter` control used by BcContainerHelper for codeunit-range filtering). See the full settings reference and compatibility notes in [Test Isolation](Scenarios/TestIsolation.md). The feature is opt-in - existing projects are unaffected unless they set `testIsolation.enabled = true`.

## v9.1

### Resilient Pull Request Status Check for large builds
Expand Down
Loading