diff --git a/.github/AL-Go-Settings.json b/.github/AL-Go-Settings.json index cc66e900911..af49d63a0de 100644 --- a/.github/AL-Go-Settings.json +++ b/.github/AL-Go-Settings.json @@ -37,6 +37,14 @@ ] } }, + { + "buildModes": [ + "UncategorizedTests" + ], + "settings": { + "enableTaskScheduler": true + } + }, { "buildModes": [ "LegacyTestsBucket1" diff --git a/build/projects/Test Apps AT/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps AT/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps AT/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps AU/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps AU/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps AU/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps BE/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps BE/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps BE/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps CA/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps CA/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps CA/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps CH/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps CH/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps CH/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps CZ/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps CZ/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps CZ/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps DE/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps DE/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps DE/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps DK/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps DK/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps DK/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps ES/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps ES/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps ES/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps FI/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps FI/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps FI/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps FR/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps FR/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps FR/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps GB/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps GB/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps GB/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps IN/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps IN/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps IN/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps IS/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps IS/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps IS/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps IT/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps IT/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps IT/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps MX/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps MX/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps MX/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps NL/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps NL/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps NL/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps NO/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps NO/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps NO/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps NZ/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps NZ/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps NZ/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps RU/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps RU/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps RU/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps SE/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps SE/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps SE/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps US/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps US/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps US/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps W1/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps W1/.AL-Go/PipelineFinalize.ps1 new file mode 100644 index 00000000000..a726947778c --- /dev/null +++ b/build/projects/Test Apps W1/.AL-Go/PipelineFinalize.ps1 @@ -0,0 +1 @@ +. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/scripts/ApiTestCredential.psm1 b/build/scripts/ApiTestCredential.psm1 new file mode 100644 index 00000000000..5983d23d783 --- /dev/null +++ b/build/scripts/ApiTestCredential.psm1 @@ -0,0 +1,117 @@ +function New-ApiTestPasswordFileStream { + param([Parameter(Mandatory = $true)][string]$FilePath) + + $ErrorActionPreference = 'Stop' + $security = [System.Security.AccessControl.FileSecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $writerSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $entries = @( + @{ Sid = 'S-1-5-18'; Rights = [System.Security.AccessControl.FileSystemRights]::FullControl } + @{ Sid = 'S-1-5-20'; Rights = [System.Security.AccessControl.FileSystemRights]::Read } + @{ Sid = 'S-1-5-32-544'; Rights = [System.Security.AccessControl.FileSystemRights]::FullControl } + ) + if ($writerSid -notin @('S-1-5-18', 'S-1-5-32-544')) { + # The host identity writes the credential and deletes it during cleanup; it needs + # neither ReadData nor FullControl. Container service identities retain their access. + $entries += @{ + Sid = $writerSid + Rights = [System.Security.AccessControl.FileSystemRights]'Write, Delete' + } + } + foreach ($entry in $entries) { + $security.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new($entry.Sid), + $entry.Rights, + [System.Security.AccessControl.AccessControlType]::Allow)) + } + + # Supply the protected DACL to CreateFile itself, not Set-Acl after creation. + # CreateNew also refuses to follow or overwrite a pre-existing credential file. + if ($PSVersionTable.PSEdition -eq 'Core') { + return [System.IO.FileSystemAclExtensions]::Create( + [System.IO.FileInfo]::new($FilePath), [System.IO.FileMode]::CreateNew, + [System.Security.AccessControl.FileSystemRights]::Write, [System.IO.FileShare]::None, + 4096, [System.IO.FileOptions]::None, $security) + } + return [System.IO.FileStream]::new( + $FilePath, [System.IO.FileMode]::CreateNew, + [System.Security.AccessControl.FileSystemRights]::Write, [System.IO.FileShare]::None, + 4096, [System.IO.FileOptions]::None, $security) +} + +<# +.SYNOPSIS + Creates the API test credential directly in the container's shared my mount. +.DESCRIPTION + Records cleanup identity before atomically creating a new file with a protected + DACL. No plaintext staging copies are made. SYSTEM and Administrators retain full + access, NetworkService can read, and the host writer can write and delete. + An existing file or unresolved mount is an error, including outside CI. Successful + pipelines finalize cleanup; failed/cancelled pipelines rely on container teardown. +#> +function Write-ApiTestPassword { + param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9_.-]*$')] + [string]$ContainerName, + [Parameter(Mandatory = $true)] + [PSCredential]$Credential + ) + + $ErrorActionPreference = 'Stop' + $myFolders = @((Get-BcContainerSharedFolders -containerName $ContainerName).GetEnumerator() | + Where-Object { $_.Value.TrimEnd('\') -eq 'C:\Run\my' }) + if ($myFolders.Count -ne 1) { + throw "Cannot resolve the API test credential's container mount." + } + $filePath = Join-Path $myFolders[0].Key 'ApiTestPassword' + # PipelineFinalize runs in this process; GITHUB_ENV only affects later steps. + $env:BCAppsApiTestPasswordPath = $filePath + $env:BCAppsApiTestPasswordContainer = $ContainerName + if ($env:GITHUB_ENV) { + Add-Content -LiteralPath $env:GITHUB_ENV -Encoding UTF8 -Value @( + "BCAppsApiTestPasswordPath=$filePath" + "BCAppsApiTestPasswordContainer=$ContainerName" + ) -ErrorAction Stop + } + + $created = $false + $bytes = $null + $characters = $null + $passwordBuffer = [IntPtr]::Zero + try { + # Write directly to the shared mount. Copy-FileToBcContainer would introduce + # another host staging copy with inherited permissions. + $stream = New-ApiTestPasswordFileStream -FilePath $filePath + $created = $true + try { + $passwordBuffer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Credential.Password) + $characters = [char[]]::new($Credential.Password.Length) + [System.Runtime.InteropServices.Marshal]::Copy($passwordBuffer, $characters, 0, $characters.Length) + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($characters) + $stream.Write($bytes, 0, $bytes.Length) + } + finally { + if ($null -ne $bytes) { [Array]::Clear($bytes, 0, $bytes.Length) } + if ($null -ne $characters) { [Array]::Clear($characters, 0, $characters.Length) } + if ($passwordBuffer -ne [IntPtr]::Zero) { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordBuffer) + } + $stream.Dispose() + } + } + catch { + $originalError = $_ + if ($created) { + try { + & (Join-Path $PSScriptRoot 'Remove-ApiTestPassword.ps1') -ContainerName $ContainerName -FilePath $filePath + } + catch { + Write-Warning 'Could not clean up the API test credential after setup failed; container teardown is still required.' + } + } + throw $originalError + } +} + +Export-ModuleMember -Function Write-ApiTestPassword diff --git a/build/scripts/NewBcContainer.ps1 b/build/scripts/NewBcContainer.ps1 index d6e98d84794..78871ab947b 100644 --- a/build/scripts/NewBcContainer.ps1 +++ b/build/scripts/NewBcContainer.ps1 @@ -24,6 +24,15 @@ if ($platformVersion) { New-BcContainer @parameters +if ($parameters.auth -in @('UserPassword', 'NavUserPassword')) { + if (-not $parameters.credential) { + throw "The BCApps UserPassword test container requires a credential." + } + + Import-Module (Join-Path $PSScriptRoot 'ApiTestCredential.psm1') -Force + Write-ApiTestPassword -ContainerName $parameters.ContainerName -Credential $parameters.credential +} + Set-BcContainerServerConfiguration -containerName $parameters.ContainerName -keyName "EnforceUserPathForAlFileOperations" -keyValue "false" Set-BcContainerServerConfiguration -containerName $parameters.ContainerName -keyName "UsePermissionSetsFromExtensions" -keyValue "true" Restart-BcContainer -containerName $parameters.ContainerName diff --git a/build/scripts/ParallelTestExecution.psm1 b/build/scripts/ParallelTestExecution.psm1 index 2d6e2b669e7..f8ed562ccee 100644 --- a/build/scripts/ParallelTestExecution.psm1 +++ b/build/scripts/ParallelTestExecution.psm1 @@ -14,6 +14,133 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { } Import-Module (Join-Path $PSScriptRoot "ALAppBuild.psm1" -Resolve) +<# +.SYNOPSIS + Gets disabled test entries for an app. +.PARAMETER AppName + Application name used to locate its DisabledTests folder. +#> +function Get-DisabledTestsForApp { + param( + [Parameter(Mandatory=$true)] + [string]$AppName + ) + + $appFolderName = $AppName -replace ' ', '_' + $disabledTests = @() + + $disabledTestsFolders = Get-ChildItem -Path (Get-BaseFolder) -Filter "DisabledTests" -Recurse -Directory + foreach ($disabledTestsFolder in $disabledTestsFolders) { + $appFolder = Join-Path $disabledTestsFolder.FullName $appFolderName + if (-not (Test-Path $appFolder)) { + continue + } + + foreach ($jsonFile in (Get-ChildItem -Path $appFolder -Filter "*.json")) { + $disabledTests += @( + Get-Content -Raw -Path $jsonFile.FullName | + ConvertFrom-Json + ) + } + } + + return @($disabledTests) +} + +function Get-ParametersForCommand { + param( + [Parameter(Mandatory=$true)] + [Hashtable]$Parameters, + [Parameter(Mandatory=$true)] + [string]$CommandName + ) + + $command = Get-Command $CommandName -ErrorAction Stop + $filtered = @{} + foreach ($key in $Parameters.Keys) { + if ($command.Parameters.ContainsKey($key)) { + $filtered[$key] = $Parameters[$key] + } + } + return $filtered +} + +function ConvertTo-RequiredDisabledWorkItems { + param( + [array]$DiscoveredTests, + [string]$AppName, + [string]$AppId + ) + + $codeunits = @() + foreach ($entry in @($DiscoveredTests)) { + if ($entry.PSObject.Properties.Name -contains "Codeunits") { + $codeunits += @($entry.Codeunits) + } else { + $codeunits += $entry + } + } + + return @( + $codeunits | + Where-Object { $_ -and $_.Id -and @($_.Tests).Count -gt 0 } | + ForEach-Object { + [PSCustomObject]@{ + Key = "${AppName}::$($_.Id)" + AppName = $AppName + AppId = $AppId + CodeunitId = [string]$_.Id + CodeunitName = [string]$_.Name + TestCount = @($_.Tests).Count + } + } + ) +} + +function Get-RequiredDisabledWorkItems { + param( + [Parameter(Mandatory=$true)] + [Hashtable]$Parameters, + [Parameter(Mandatory=$true)] + [string]$TestType, + [string[]]$AppNamesToTest, + [Parameter(Mandatory=$true)] + [Hashtable]$AppIdByName + ) + + # Without a test type, the test-tool extension selector ignores required isolation. + # Keep Legacy buckets on their existing execution path rather than treating every + # codeunit as requiring a clean tenant. + if ($TestType -eq 'Legacy') { + Write-Host 'Legacy buckets use ordinary execution; clean isolation discovery requires a typed lane.' + return @() + } + + $workItems = @() + foreach ($appName in $AppNamesToTest) { + $appId = $AppIdByName[$appName] + if (-not $appId) { + continue + } + + $discoveryParameters = Get-ParametersForCommand -Parameters $Parameters -CommandName "Get-TestsFromBcContainer" + $discoveryParameters["extensionId"] = $appId + $discoveryParameters["requiredTestIsolation"] = "Disabled" + $discoveryParameters["disabledTests"] = @(Get-DisabledTestsForApp -AppName $appName) + $discoveryParameters["testType"] = $TestType + + Write-Host "Discovering RequiredTestIsolation=Disabled codeunits in '$appName'..." + $discoveredTests = @(Get-TestsFromBcContainer @discoveryParameters) + $appWorkItems = @(ConvertTo-RequiredDisabledWorkItems -DiscoveredTests $discoveredTests -AppName $appName -AppId $appId) + if ($appWorkItems.Count -gt 0) { + Write-Host " Found $($appWorkItems.Count) codeunit(s), $((($appWorkItems | Measure-Object TestCount -Sum).Sum)) test method(s)." + $workItems += $appWorkItems + } + } + + return @($workItems) +} + <# .SYNOPSIS Returns the rerun budget for the current build. @@ -72,7 +199,7 @@ function Get-CachedTestRunResult { [string]$ContainerName ) - $tempDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } + $tempDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } elseif ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() } $stateFile = Join-Path $tempDir "parallelTests_$ContainerName.json" if (-not (Test-Path $stateFile)) { return $null } @@ -198,24 +325,173 @@ function Get-AppNamesForBucket { <# .SYNOPSIS - Gets the list of operational tenants in a BC container. + Gets IDs and database names for operational tenants in a BC container. .PARAMETER containerName Name of the BC container to query. .OUTPUTS - [string[]] Array of tenant IDs that are in Operational state. + [PSCustomObject[]] Operational tenant records with Id and DatabaseName properties. #> -function Get-AvailableBcTenants { +function Get-AvailableBcTenantInfo { param( [Parameter(Mandatory=$true)] [string]$containerName ) $tenants = Invoke-ScriptInBcContainer -containerName $containerName -scriptblock { - Get-NavTenant $ServerInstance | Where-Object { $_.State -eq "Operational" } | ForEach-Object { $_.Id } + Get-NavTenant $ServerInstance | + Where-Object { $_.State -eq "Operational" } | + ForEach-Object { + [PSCustomObject]@{ + Id = $_.Id + DatabaseName = $_.DatabaseName + } + } } return @($tenants) } +<# +.SYNOPSIS + Gets the IDs of operational tenants in a BC container. +.PARAMETER containerName + Name of the BC container. +#> +function Get-AvailableBcTenants { + param( + [Parameter(Mandatory=$true)] + [string]$containerName + ) + + return @( + Get-AvailableBcTenantInfo -containerName $containerName | + ForEach-Object { $_.Id } + ) +} + +function New-BcTestTenantTemplate { + param( + [Parameter(Mandatory=$true)] + [string]$ContainerName, + [Parameter(Mandatory=$true)] + [string]$SourceDatabaseName + ) + + $result = @(Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { Param($sourceDatabaseName) + $templateDatabaseName = "$sourceDatabaseName-test-template" + $maxAttempts = 3 + $retryDelaySeconds = 5 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + if (Test-NAVDatabase -DatabaseName $templateDatabaseName) { + Remove-NAVDatabase -DatabaseName $templateDatabaseName | Out-Null + } + + Write-Host "Creating immutable test tenant template '$templateDatabaseName' from '$sourceDatabaseName' (attempt $attempt/$maxAttempts)..." + Copy-NAVDatabase -SourceDatabaseName $sourceDatabaseName -DestinationDatabaseName $templateDatabaseName -DatabaseServer "." | Out-Null + break + } catch { + Write-Host "WARNING: Template database copy failed on attempt $attempt/${maxAttempts}: $($_.Exception.Message)" + if ($attempt -eq $maxAttempts) { + throw "Failed to create a test tenant template from '$sourceDatabaseName' after $maxAttempts attempts. Last error: $($_.Exception.Message)" + } + Start-Sleep -Seconds $retryDelaySeconds + } + } + $templateDatabaseName + } -argumentList $SourceDatabaseName) + + if ($result.Count -eq 0) { + throw "Creating the clean test tenant template returned no database name." + } + return [string]$result[-1] +} + +<# +.SYNOPSIS + Replaces a tenant database with a copy of an idle source database. +.PARAMETER ContainerName + Name of the BC container. +.PARAMETER Tenant + Tenant ID to refresh. +.PARAMETER TenantDatabaseName + Database currently mounted for the tenant. +.PARAMETER TemplateDatabaseName + Immutable test template, or the untouched primary database used to restore discovery. +#> +function Reset-BcTestTenant { + param( + [Parameter(Mandatory=$true)] + [string]$ContainerName, + [Parameter(Mandatory=$true)] + [string]$Tenant, + [Parameter(Mandatory=$true)] + [string]$TenantDatabaseName, + [Parameter(Mandatory=$true)] + [string]$TemplateDatabaseName + ) + + Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { + Param($tenant, $tenantDatabaseName, $templateDatabaseName) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $mountedTenant = Get-NAVTenant -ServerInstance $ServerInstance -Tenant $tenant -ErrorAction SilentlyContinue + if ($mountedTenant) { + Dismount-NAVTenant -ServerInstance $ServerInstance -Tenant $tenant -Force | Out-Null + } + + $maxAttempts = 3 + $retryDelaySeconds = 5 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + if (Test-NAVDatabase -DatabaseName $tenantDatabaseName) { + Remove-NAVDatabase -DatabaseName $tenantDatabaseName | Out-Null + } + + Copy-NAVDatabase -SourceDatabaseName $templateDatabaseName -DestinationDatabaseName $tenantDatabaseName -DatabaseServer "." | Out-Null + break + } catch { + Write-Host "WARNING: Tenant database refresh failed on attempt $attempt/${maxAttempts}: $($_.Exception.Message)" + if ($attempt -eq $maxAttempts) { + throw "Failed to refresh tenant database '$tenantDatabaseName' after $maxAttempts attempts. Last error: $($_.Exception.Message)" + } + Start-Sleep -Seconds $retryDelaySeconds + } + } + Mount-NAVTenant -ServerInstance $ServerInstance -Id $tenant -DatabaseServer "." -DatabaseName $tenantDatabaseName -OverwriteTenantIdInDatabase -Force | Out-Null + + $maxWaitSeconds = 300 + while ((Get-NAVTenant -ServerInstance $ServerInstance -Tenant $tenant).State -eq "Mounting") { + if ($stopwatch.Elapsed.TotalSeconds -ge $maxWaitSeconds) { + throw "Tenant '$tenant' did not finish mounting within $maxWaitSeconds seconds." + } + Start-Sleep -Milliseconds 250 + } + + $state = (Get-NAVTenant -ServerInstance $ServerInstance -Tenant $tenant).State + if ($state -notin @("Operational", "OperationalWithWarnings")) { + throw "Tenant '$tenant' is '$state' after refresh; expected an operational state." + } + + $stopwatch.Stop() + Write-Host "Refreshed tenant '$tenant' from '$templateDatabaseName' in $([math]::Round($stopwatch.Elapsed.TotalSeconds, 2)) seconds." + } -argumentList $Tenant, $TenantDatabaseName, $TemplateDatabaseName +} + +function Remove-BcTestTenantTemplate { + param( + [Parameter(Mandatory=$true)] + [string]$ContainerName, + [Parameter(Mandatory=$true)] + [string]$TemplateDatabaseName + ) + + Invoke-ScriptInBcContainer -containerName $ContainerName -scriptblock { Param($templateDatabaseName) + if (Test-NAVDatabase -DatabaseName $templateDatabaseName) { + Remove-NAVDatabase -DatabaseName $templateDatabaseName | Out-Null + } + } -argumentList $TemplateDatabaseName +} + <# .SYNOPSIS Merges multiple test result XML files into a single file. @@ -297,7 +573,10 @@ function Merge-TestResultFiles { Any of these fingerprints is sufficient evidence: "Cannot open page 130455", "InvokeInteractions failed with status code 500", or a stack frame referencing "InteractionManager.cs:line N" (line number not pinned, so platform refactors do not - silently invalidate the match). + silently invalidate the match). The platform can also fail while page 130455 resolves an + extension's codeunit metadata; the known variants surface from ExtensionId_a45_OnValidate + as an invalid metadata BLOB range or a missing nullable metadata value. A generic + ClientSession InError state alone is not evidence of this race. .PARAMETER Output The combined output (stdout + stderr + verbose) captured from a finished background job. Null or empty returns $false. @@ -308,7 +587,11 @@ function Test-TransientTestFailure { ) if ([string]::IsNullOrEmpty($Output)) { return $false } - return [bool]($Output -match 'Cannot open page 130455|InvokeInteractions failed with status code 500|InteractionManager\.cs:line \d+') + return [bool]( + ($Output -match 'TRANSIENT TEST PLATFORM RACE') -or + ($Output -match 'Cannot open page 130455|InvokeInteractions failed with status code 500|InteractionManager\.cs:line \d+') -or + ($Output -match '(?s)ObjName:Command Line Test Tool.*MethodName:ExtensionId_a45_OnValidate.*(?:Offset and length were out of bounds|Nullable object must have a value)') + ) } <# @@ -403,6 +686,7 @@ function Start-TestAppDispatch { [string]$ScriptPath, [string]$TestType, $State, + [switch]$SkipAutomaticDisabledPass, [string]$Verb = 'Dispatching', [string]$FileSuffix ) @@ -414,12 +698,149 @@ function Start-TestAppDispatch { $appParams['extensionId'] = $AppId $appParams.Remove('ReRun') | Out-Null - $job = Start-TestJob -parameters $appParams -tenant $Tenant -scriptPath $ScriptPath -testType $TestType -fileSuffix $FileSuffix + $job = Start-TestJob -parameters $appParams -tenant $Tenant -scriptPath $ScriptPath -testType $TestType ` + -skipAutomaticDisabledPass:$SkipAutomaticDisabledPass -fileSuffix $FileSuffix $State.jobs = @($State.jobs) + @([PSCustomObject]@{ jobId = $job.Id; tenant = $Tenant; appName = $AppName }) Start-Sleep -Seconds 5 } +function Start-RequiredDisabledDispatch { + param( + [Hashtable]$Parameters, + $WorkItem, + $TenantInfo, + [string]$ScriptPath, + [string]$TestType, + $State, + [string]$Verb = "Dispatching" + ) + + Write-Host "$Verb RequiredTestIsolation=Disabled codeunit $($WorkItem.CodeunitId) '$($WorkItem.CodeunitName)' from '$($WorkItem.AppName)' on tenant '$($TenantInfo.Id)'" + + $codeunitParameters = $Parameters.Clone() + $codeunitParameters["appName"] = $WorkItem.AppName + $codeunitParameters["extensionId"] = $WorkItem.AppId + $codeunitParameters["testCodeunit"] = $WorkItem.CodeunitId + $codeunitParameters["requiredTestIsolation"] = "Disabled" + $codeunitParameters["testRunnerCodeunitId"] = "130451" + $codeunitParameters["disabledTests"] = @(Get-DisabledTestsForApp -AppName $WorkItem.AppName) + $codeunitParameters.Remove("ReRun") | Out-Null + if ($Verb -eq "Re-dispatching") { + $codeunitParameters["ReRun"] = $true + } + + $appendKeys = @{ + XUnitResultFileName = "AppendToXUnitResultFile" + JUnitResultFileName = "AppendToJUnitResultFile" + } + foreach ($resultKey in $appendKeys.Keys) { + if ($codeunitParameters.ContainsKey($resultKey) -and $codeunitParameters[$resultKey]) { + $codeunitParameters[$appendKeys[$resultKey]] = $true + } + } + + $job = Start-TestJob -parameters $codeunitParameters -tenant $TenantInfo.Id -scriptPath $ScriptPath ` + -testType $TestType -skipAutomaticDisabledPass + $State.jobs = @($State.jobs) + @( + [PSCustomObject]@{ + jobId = $job.Id + tenant = $TenantInfo.Id + appName = $WorkItem.Key + } + ) + Start-Sleep -Seconds 1 +} + +function Invoke-RequiredDisabledTestExecution { + param( + [Parameter(Mandatory=$true)] + [Hashtable]$Parameters, + [Parameter(Mandatory=$true)] + [array]$WorkItems, + [Parameter(Mandatory=$true)] + [array]$TenantInfo, + [Parameter(Mandatory=$true)] + [string]$TemplateDatabaseName, + [Parameter(Mandatory=$true)] + [string]$ScriptPath, + [Parameter(Mandatory=$true)] + [string]$TestType + ) + + if ($WorkItems.Count -eq 0) { + return $true + } + + $workItemByKey = @{} + foreach ($workItem in $WorkItems) { + $workItemByKey[$workItem.Key] = $workItem + } + + $state = [PSCustomObject]@{ + jobs = @() + hasFailures = $false + transient = @() + retried = @{} + retryTenant = @{} + } + $pending = @($WorkItems) + + while ($pending.Count -gt 0 -or $state.transient.Count -gt 0) { + if ($state.transient.Count -gt 0) { + $retryItems = @() + foreach ($transient in @($state.transient)) { + $key = $transient.Key + $state.retried[$key] = $true + $state.retryTenant[$key] = $transient.Tenant + $retryItems += $workItemByKey[$key] + } + $state.transient = @() + $pending = @($retryItems) + @($pending) + } + + $availableTenantInfo = @($TenantInfo) + $batch = @() + while ($pending.Count -gt 0 -and $availableTenantInfo.Count -gt 0) { + $workItem = $pending[0] + $pending = @($pending | Select-Object -Skip 1) + $selectedTenantInfo = if ($state.retryTenant.ContainsKey($workItem.Key)) { + $availableTenantInfo | + Where-Object { $_.Id -eq $state.retryTenant[$workItem.Key] } | + Select-Object -First 1 + } else { + $availableTenantInfo | Select-Object -First 1 + } + if (-not $selectedTenantInfo) { + throw "Could not reserve tenant for clean codeunit '$($workItem.Key)'." + } + $availableTenantInfo = @($availableTenantInfo | Where-Object { $_.Id -ne $selectedTenantInfo.Id }) + $verb = if ($state.retried.ContainsKey($workItem.Key)) { "Re-dispatching" } else { "Dispatching" } + + $batch += [PSCustomObject]@{ + WorkItem = $workItem + TenantInfo = $selectedTenantInfo + Verb = $verb + } + } + + # Finish every restore in the batch before any test starts. This keeps SQL backup/restore + # activity from overlapping page 130455 metadata enumeration and API cold starts. + foreach ($dispatch in $batch) { + Reset-BcTestTenant -ContainerName $Parameters.containerName -Tenant $dispatch.TenantInfo.Id ` + -TenantDatabaseName $dispatch.TenantInfo.DatabaseName -TemplateDatabaseName $TemplateDatabaseName + } + foreach ($dispatch in $batch) { + Start-RequiredDisabledDispatch -Parameters $Parameters -WorkItem $dispatch.WorkItem ` + -TenantInfo $dispatch.TenantInfo ` + -ScriptPath $ScriptPath -TestType $TestType -State $state -Verb $dispatch.Verb + } + $null = Wait-ForAllTestJobs -state $state + } + + return (-not $state.hasFailures) +} + <# .SYNOPSIS Records a finished job's outcome on the state object, queueing a rerun when one is warranted. @@ -442,10 +863,17 @@ function Register-TestJobOutcome { switch ($Result.Outcome) { 'Transient' { Write-Host "Transient platform race for '$($Result.AppName)' on '$($Result.Tenant)'. Queued for one retry." - $State.transient = @($State.transient) + @($Result.AppName) + $State.transient = @($State.transient) + @( + [PSCustomObject]@{ + Key = $Result.AppName + Tenant = $Result.Tenant + } + ) } 'Failed' { - $canRerun = ($State.rerunBudget -gt 0) -and + $supportsAppReruns = $null -ne $State.PSObject.Properties['rerunBudget'] + $canRerun = $supportsAppReruns -and + ($State.rerunBudget -gt 0) -and ($State.tenantCount -gt 1) -and (-not $State.rerunDone.ContainsKey($Result.AppName)) @@ -542,6 +970,28 @@ function Wait-ForFreeTenant { throw "Wait-ForFreeTenant: timed out after $timeoutSeconds seconds waiting for a free tenant. Running jobs: $($state.jobs | ForEach-Object { "$($_.appName) on $($_.tenant)" } | Out-String)" } +function Wait-ForSpecificTenant { + param( + $state, + $tenants, + [string]$tenant, + [int]$timeoutSeconds = 7200, + [int]$pollIntervalSeconds = 10 + ) + + $waited = 0 + while ($waited -lt $timeoutSeconds) { + $available = @(Get-FreeTenants -state $state -tenants $tenants) + if ($tenant -in $available) { + return $tenant + } + Start-Sleep -Seconds $pollIntervalSeconds + $waited += $pollIntervalSeconds + } + + throw "Wait-ForSpecificTenant: timed out after $timeoutSeconds seconds waiting for tenant '$tenant'." +} + <# .SYNOPSIS Starts a background job to run tests for a single app on a specific tenant. @@ -564,6 +1014,7 @@ function Start-TestJob { [string]$tenant, [string]$scriptPath, [string]$testType, + [switch]$skipAutomaticDisabledPass, [string]$fileSuffix ) @@ -587,15 +1038,19 @@ function Start-TestJob { $bchModule = Get-Module BcContainerHelper | Select-Object -First 1 $bchModulePath = if ($bchModule) { $bchModule.Path } else { "BcContainerHelper" } - return Start-Job -ScriptBlock { - param($params, $scriptPath, $testType, $bchPath) + $jobScript = { + param($params, $scriptPath, $testType, $bchPath, $skipDisabledPass) Import-Module $bchPath # Background jobs run a single app sequentially. Pass an empty $AppNamesToTest so the # shared script skips the parallel dispatch branch and falls through to running this # one app's tests directly. - $passed = . $scriptPath -parameters $params -TestType $testType -AppNamesToTest @() + $passed = . $scriptPath -parameters $params -TestType $testType -AppNamesToTest @() ` + -SkipAutomaticDisabledPass:$skipDisabledPass if (-not $passed) { throw "Test execution failed" } - } -ArgumentList $jobParams, $scriptPath, $testType, $bchModulePath + } + + return Start-Job -ScriptBlock $jobScript -ArgumentList $jobParams, $scriptPath, $testType, $bchModulePath, ` + $skipAutomaticDisabledPass.IsPresent } <# @@ -627,6 +1082,59 @@ function Wait-ForAllTestJobs { $state.jobs = @() } +function Add-MissingJUnitTestProperties { + param( + [Parameter(Mandatory=$true)] + [string]$ResultFile, + [array]$WorkItems = @() + ) + + if (-not (Test-Path $ResultFile) -or $WorkItems.Count -eq 0) { + return + } + + $workItemByCodeunitId = @{} + foreach ($workItem in $WorkItems) { + $workItemByCodeunitId[[string]$workItem.CodeunitId] = $workItem + } + + $xml = [xml](Get-Content $ResultFile -Raw) + $changed = $false + foreach ($suite in @($xml.testsuites.testsuite)) { + if ($suite.properties) { + continue + } + + $codeunitId = ([string]$suite.name -split ' ', 2)[0] + $workItem = $workItemByCodeunitId[$codeunitId] + if (-not $workItem) { + continue + } + + $properties = $xml.CreateElement("properties") + foreach ($propertyValue in @{ + extensionid = $workItem.AppId + appName = $workItem.AppName + }.GetEnumerator()) { + $property = $xml.CreateElement("property") + $property.SetAttribute("name", $propertyValue.Key) + $property.SetAttribute("value", [string]$propertyValue.Value) + $properties.AppendChild($property) | Out-Null + } + + if ($suite.FirstChild) { + $suite.InsertBefore($properties, $suite.FirstChild) | Out-Null + } else { + $suite.AppendChild($properties) | Out-Null + } + $changed = $true + } + + if ($changed) { + $xml.Save($ResultFile) + } +} + <# .SYNOPSIS Merges per-job test result files into the single file expected by Run-AlPipeline. @@ -642,6 +1150,7 @@ function Merge-TenantTestResults { param( [Hashtable]$parameters, [string[]]$tenants, + [array]$workItems = @(), [string[]]$rerunSuffixes = @() ) @@ -655,6 +1164,11 @@ function Merge-TenantTestResults { $ext = [System.IO.Path]::GetExtension($origFile) $tenantFiles = @($suffixes | ForEach-Object { Join-Path $dir "$name-$_$ext" }) + if ($resultKey -eq "JUnitResultFileName") { + foreach ($tenantFile in $tenantFiles) { + Add-MissingJUnitTestProperties -ResultFile $tenantFile -WorkItems $workItems + } + } Merge-TestResultFiles -targetFile $origFile -sourceFiles $tenantFiles # Clean up per-job files @@ -680,6 +1194,8 @@ function Merge-TenantTestResults { All available tenant ids. Warmup dispatches onto the first one. .PARAMETER State The parallel execution state object; mutated (jobs/hasFailures/transient) as the warmup runs. +.PARAMETER CleanTenantAppNames + Apps whose automatic Disabled-isolation pass must be deferred to clean-codeunit execution. .OUTPUTS [string[]] The remaining app names to dispatch (first app removed if it was warmed up). #> @@ -691,7 +1207,8 @@ function Invoke-WarmupDispatch { [Parameter(Mandatory=$true)][AllowEmptyCollection()][string[]]$Tenants, [Parameter(Mandatory=$true)][string]$ScriptPath, [string]$TestType, - [Parameter(Mandatory=$true)]$State + [Parameter(Mandatory=$true)]$State, + [string[]]$CleanTenantAppNames = @() ) # Only serialize when there is a fan-out to protect: >1 app AND >1 tenant. @@ -708,7 +1225,8 @@ function Invoke-WarmupDispatch { Write-Host "Warming up: dispatching first app '$warmupApp' on '$($Tenants[0])' alone and awaiting completion before parallel fan-out." Start-TestAppDispatch -Parameters $Parameters -AppName $warmupApp -AppId $warmupAppId -Tenant $Tenants[0] ` - -ScriptPath $ScriptPath -TestType $TestType -State $State -Verb 'Dispatching' + -ScriptPath $ScriptPath -TestType $TestType -State $State -Verb 'Dispatching' ` + -SkipAutomaticDisabledPass:($warmupApp -in $CleanTenantAppNames) # Await the single job so the process is warm before anything runs in parallel. A transient # failure here lands in $State.transient and the caller's loop re-queues it; a hard failure is @@ -790,8 +1308,8 @@ function Invoke-ParallelTestExecution { # GitHub Actions provides a per-job temp directory ($RUNNER_TEMP) that is cleaned up between # jobs, so a stale state file from a previous run cannot corrupt the current run. Fall back - # to $env:TEMP for local execution outside of CI. - $tempDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } + # to the local temp directory outside of CI. + $tempDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } elseif ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() } $stateFile = Join-Path $tempDir "parallelTests_$($parameters.containerName).json" # Short-circuit ONLY when a previous call ran to completion (wait+merge done). The @@ -808,7 +1326,8 @@ function Invoke-ParallelTestExecution { } } - $tenants = @(Get-AvailableBcTenants -containerName $parameters.containerName) + $tenantInfo = @(Get-AvailableBcTenantInfo -containerName $parameters.containerName) + $tenants = @($tenantInfo | ForEach-Object { $_.Id }) Write-Host "Available tenants: $($tenants -join ', ')" # Build a name -> appId map so we can set extensionId per dispatch. Run-TestsInBcContainer @@ -820,15 +1339,91 @@ function Invoke-ParallelTestExecution { Where-Object { $_.IsInstalled } | ForEach-Object { $appIdByName[$_.Name] = $_.AppId } + $cleanTenantInfo = @( + $tenantInfo | + Where-Object { $_.Id -ne $parameters.tenant } + ) + # Enable only with the AL authentication uptake; infrastructure alone must not add test lanes. + $cleanCodeunitExecution = (Get-ALGoSetting -Key 'enableCleanTestCodeunitExecution') -eq $true + $cleanTenantAppNames = @() + $requiredDisabledWorkItems = @() + if ($cleanCodeunitExecution) { + $cleanTenantAppNames = $appNamesToTest + } + if ($cleanCodeunitExecution -and $testType -ne 'Legacy' -and $tenantInfo.Count -gt 1) { + $sourceTenantInfo = @($tenantInfo | Where-Object { $_.Id -eq $parameters.tenant }) + if ($sourceTenantInfo.Count -ne 1 -or [string]::IsNullOrWhiteSpace($sourceTenantInfo[0].DatabaseName)) { + throw "Could not determine the database name for source tenant '$($parameters.tenant)'." + } + if (@($tenantInfo | Where-Object { + [string]::IsNullOrWhiteSpace($_.Id) -or [string]::IsNullOrWhiteSpace($_.DatabaseName) + }).Count -gt 0 -or + @($tenantInfo.Id | Sort-Object -Unique).Count -ne $tenantInfo.Count -or + @($tenantInfo.DatabaseName | Sort-Object -Unique).Count -ne $tenantInfo.Count) { + throw "Discovery requires non-empty, unique tenant IDs and database names." + } + + # Discovery executes OnRun triggers. Keep the primary fixture untouched and restore + # the discovery worker before any template copy or test dispatch can observe its writes. + $discoveryTenant = $cleanTenantInfo[0] + $discoveryParameters = $parameters.Clone() + $discoveryParameters["tenant"] = $discoveryTenant.Id + try { + $requiredDisabledWorkItems = @( + Get-RequiredDisabledWorkItems -Parameters $discoveryParameters -TestType $testType ` + -AppNamesToTest $appNamesToTest -AppIdByName $appIdByName + ) + } + finally { + Reset-BcTestTenant -ContainerName $parameters.containerName -Tenant $discoveryTenant.Id ` + -TenantDatabaseName $discoveryTenant.DatabaseName -TemplateDatabaseName $sourceTenantInfo[0].DatabaseName + } + } elseif ($cleanCodeunitExecution) { + $requiredDisabledWorkItems = @( + Get-RequiredDisabledWorkItems -Parameters $parameters -TestType $testType ` + -AppNamesToTest $appNamesToTest -AppIdByName $appIdByName + ) + } + $templateDatabaseName = "" + try { + if ($requiredDisabledWorkItems.Count -gt 0) { + if ($cleanTenantInfo.Count -eq 0) { + throw "Clean RequiredTestIsolation=Disabled execution requires at least one secondary tenant." + } + Write-Host "Preparing clean-tenant execution for $($requiredDisabledWorkItems.Count) RequiredTestIsolation=Disabled codeunit(s)." + $sourceTenantInfo = @($tenantInfo | Where-Object { $_.Id -eq $parameters.tenant }) | Select-Object -First 1 + if (-not $sourceTenantInfo -or [string]::IsNullOrEmpty($sourceTenantInfo.DatabaseName)) { + throw "Could not determine the database name for source tenant '$($parameters.tenant)'." + } + $templateDatabaseName = New-BcTestTenantTemplate -ContainerName $parameters.containerName -SourceDatabaseName $sourceTenantInfo.DatabaseName + } + # dispatched=true marks "we started the foreach" - lets concurrent reads notice an in-flight # run. completed=false stays false until wait+merge finish; only then is finalResult valid. $state = [PSCustomObject]@{ jobs = @(); dispatched = $true; completed = $false; finalResult = $false; hasFailures = $false - transient = @(); retried = @{} + transient = @(); retried = @{}; retryTenant = @{} rerun = @(); rerunDone = @{}; rerunBudget = (Get-AppRerunBudget); tenantCount = $tenants.Count } $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile -Force + if ($requiredDisabledWorkItems.Count -gt 0) { + try { + $requiredDisabledPassed = Invoke-RequiredDisabledTestExecution -Parameters $parameters ` + -WorkItems $requiredDisabledWorkItems -TenantInfo $cleanTenantInfo ` + -TemplateDatabaseName $templateDatabaseName -ScriptPath $scriptPath -TestType $testType + if (-not $requiredDisabledPassed) { + $state.hasFailures = $true + } + } + finally { + foreach ($cleanTenant in $cleanTenantInfo) { + Reset-BcTestTenant -ContainerName $parameters.containerName -Tenant $cleanTenant.Id ` + -TenantDatabaseName $cleanTenant.DatabaseName -TemplateDatabaseName $templateDatabaseName + } + } + } + # Single dispatch loop, FIFO. TestConfiguration.json lists the smallest app first (a cheap # serial warmup) and the rest longest-first (LPT, keeps the tail short). The retry cap lives in # Receive-TestJobResult: an app already in $state.retried is classified as Failed on a re-fail. @@ -837,7 +1432,8 @@ function Invoke-ParallelTestExecution { # Run the first app alone and await it to warm the container before parallelizing the rest. # No-op for single-app/single-tenant. $pending = @(Invoke-WarmupDispatch -Parameters $parameters -Pending $pending -AppIdByName $appIdByName ` - -Tenants $tenants -ScriptPath $scriptPath -TestType $testType -State $state) + -Tenants $tenants -ScriptPath $scriptPath -TestType $testType -State $state ` + -CleanTenantAppNames $cleanTenantAppNames) $rerunSuffixes = @() @@ -850,9 +1446,12 @@ function Invoke-ParallelTestExecution { if ($state.transient.Count -gt 0) { $toRetry = @($state.transient) $state.transient = @() - Write-Host "Re-queueing $($toRetry.Count) app(s) after transient platform race: $($toRetry -join ', ')" - foreach ($appName in $toRetry) { + $retryAppNames = @($toRetry | ForEach-Object { $_.Key }) + Write-Host "Re-queueing $($toRetry.Count) app(s) after transient platform race: $($retryAppNames -join ', ')" + foreach ($transient in $toRetry) { + $appName = $transient.Key $state.retried[$appName] = $true + $state.retryTenant[$appName] = $transient.Tenant # A transient retry goes out through the normal queue and so writes to a TENANT # result file. Tenant files are merged before rerun files, so any rerun file this # app already produced would overwrite the newer result - drop it. @@ -860,7 +1459,7 @@ function Invoke-ParallelTestExecution { Remove-RerunResultFile -parameters $parameters -suffix $state.rerunDone[$appName] } } - $pending = @($toRetry) + @($pending) + $pending = @($retryAppNames) + @($pending) } # Reruns take priority over the normal queue, for the same tail-latency reason as above: @@ -875,7 +1474,8 @@ function Invoke-ParallelTestExecution { $tenant = Wait-ForFreeTenant -state $state -tenants $tenants -excludeTenant $rerunItem.excludeTenant Start-TestAppDispatch -Parameters $parameters -AppName $rerunItem.appName -AppId $appIdByName[$rerunItem.appName] ` -Tenant $tenant -ScriptPath $scriptPath -TestType $testType -State $state ` - -Verb 'Re-running' -FileSuffix $rerunItem.suffix + -Verb 'Re-running' -FileSuffix $rerunItem.suffix ` + -SkipAutomaticDisabledPass:$cleanCodeunitExecution continue } @@ -890,9 +1490,14 @@ function Invoke-ParallelTestExecution { } $verb = if ($state.retried.ContainsKey($appName)) { 'Re-dispatching' } else { 'Dispatching' } - $tenant = Wait-ForFreeTenant -state $state -tenants $tenants + $tenant = if ($state.retryTenant.ContainsKey($appName)) { + Wait-ForSpecificTenant -state $state -tenants $tenants -tenant $state.retryTenant[$appName] + } else { + Wait-ForFreeTenant -state $state -tenants $tenants + } Start-TestAppDispatch -Parameters $parameters -AppName $appName -AppId $appId -Tenant $tenant ` - -ScriptPath $scriptPath -TestType $testType -State $state -Verb $verb + -ScriptPath $scriptPath -TestType $testType -State $state ` + -SkipAutomaticDisabledPass:$cleanCodeunitExecution -Verb $verb } else { # Nothing left to dispatch; drain any still-running jobs. New transient failures and # reruns discovered here are picked up at the top of the next loop iteration. @@ -902,17 +1507,25 @@ function Invoke-ParallelTestExecution { } } - $allPassed = -not $state.hasFailures + $allPassed = -not $state.hasFailures - Merge-TenantTestResults -parameters $parameters -tenants $tenants -rerunSuffixes $rerunSuffixes + Merge-TenantTestResults -parameters $parameters -tenants $tenants ` + -workItems $requiredDisabledWorkItems -rerunSuffixes $rerunSuffixes - # Persist final result and mark complete so subsequent override invocations short-circuit - # to this value (and not the placeholder we wrote before dispatch). - $state.finalResult = $allPassed - $state.completed = $true - $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile -Force + # Persist final result and mark complete so subsequent override invocations short-circuit + # to this value (and not the placeholder we wrote before dispatch). + $state.finalResult = $allPassed + $state.completed = $true + $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile -Force - return $allPassed + return $allPassed + } + finally { + if ($templateDatabaseName) { + Remove-BcTestTenantTemplate -ContainerName $parameters.containerName ` + -TemplateDatabaseName $templateDatabaseName + } + } } <# @@ -957,4 +1570,4 @@ function Invoke-PerProjectTestRun { return (. $script -parameters $parameters -TestType $testType -AppNamesToTest $appNamesToTest) } -Export-ModuleMember -Function Invoke-ParallelTestExecution, Get-AvailableBcTenants, Get-CachedTestRunResult, Get-InstalledTestAppNames, Get-AppNamesForBucket, Invoke-PerProjectTestRun, Get-AppNameFromMetadata, Invoke-WarmupDispatch, Merge-TestResultFiles, Get-AppRerunBudget +Export-ModuleMember -Function Invoke-ParallelTestExecution, Get-AvailableBcTenants, Get-CachedTestRunResult, Get-InstalledTestAppNames, Get-AppNamesForBucket, Invoke-PerProjectTestRun, Get-AppNameFromMetadata, Get-DisabledTestsForApp, Reset-BcTestTenant, Invoke-WarmupDispatch, Merge-TestResultFiles, Get-AppRerunBudget, Test-TransientTestFailure diff --git a/build/scripts/PipelineFinalize.ps1 b/build/scripts/PipelineFinalize.ps1 new file mode 100644 index 00000000000..006bdc54b80 --- /dev/null +++ b/build/scripts/PipelineFinalize.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' + +# This hook is success-only. Failures/cancellation rely on normal container teardown, +# not guaranteed after runner loss. CI credentials are per-run; other callers may differ. +if (-not $env:BCAppsApiTestPasswordPath) { + return +} + +Write-Host 'API test credential cleanup (PipelineFinalize).' +& (Join-Path $PSScriptRoot 'Remove-ApiTestPassword.ps1') ` + -ContainerName $env:BCAppsApiTestPasswordContainer -FilePath $env:BCAppsApiTestPasswordPath +$env:BCAppsApiTestPasswordPath = $null +$env:BCAppsApiTestPasswordContainer = $null diff --git a/build/scripts/Remove-ApiTestPassword.ps1 b/build/scripts/Remove-ApiTestPassword.ps1 new file mode 100644 index 00000000000..4c49720fe99 --- /dev/null +++ b/build/scripts/Remove-ApiTestPassword.ps1 @@ -0,0 +1,34 @@ +Param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9_.-]*$')] + [string]$ContainerName, + [Parameter(Mandatory = $true)] + [string]$FilePath +) + +$ErrorActionPreference = 'Stop' + +if ((Split-Path -Path $FilePath -Leaf) -ne 'ApiTestPassword') { + throw 'API test credential cleanup requires the exact ApiTestPassword file.' +} +if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { + return +} + +# End any remaining test workers before deleting their shared credential. +$containerIds = @(docker container ls --all --filter "name=^/$([regex]::Escape($ContainerName))$" --format '{{.ID}}') +if ($LASTEXITCODE -ne 0) { + throw 'Could not determine whether the API test container still exists.' +} +if ($containerIds.Count -gt 1) { + throw 'API test credential cleanup matched more than one container.' +} +if ($containerIds.Count -eq 1) { + docker stop --time 30 $containerIds[0] | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'Could not stop API test consumers; leaving credential removal to container teardown.' + } +} + +# The recorded mount path remains usable for stopped or already-removed containers. +Remove-Item -LiteralPath $FilePath -Force -ErrorAction Stop diff --git a/build/scripts/RunTestsInBcContainer.ps1 b/build/scripts/RunTestsInBcContainer.ps1 index 5ffd8c957bc..51067229314 100644 --- a/build/scripts/RunTestsInBcContainer.ps1 +++ b/build/scripts/RunTestsInBcContainer.ps1 @@ -5,40 +5,14 @@ Param( # Names of test apps to dispatch in parallel across tenants. Only set by the per-project # override on the parent invocation. Empty when called from inside a background job, which # forces the sequential single-app path further down. - [string[]] $AppNamesToTest = @() + [string[]] $AppNamesToTest = @(), + [switch] $SkipAutomaticDisabledPass ) Import-Module $PSScriptRoot\EnlistmentHelperFunctions.psm1 +Import-Module $PSScriptRoot\ParallelTestExecution.psm1 Import-Module $PSScriptRoot\TestTolerance\TestTolerance.psm1 -Force -function Get-DisabledTests -{ - param( - [string] $AppName - ) - - $baseFolder = Get-BaseFolder - - # Convert app name to folder name format (replace spaces with underscores) - $appFolderName = $AppName -replace ' ', '_' - - $disabledTests = @() - - # Look for DisabledTests folders and find the app-specific subfolder - $disabledTestsFolders = Get-ChildItem -Path $baseFolder -Filter "DisabledTests" -Recurse -Directory - foreach ($disabledTestsFolder in $disabledTestsFolders) { - $appFolder = Join-Path $disabledTestsFolder.FullName $appFolderName - if (Test-Path $appFolder) { - $jsonFiles = Get-ChildItem -Path $appFolder -Filter "*.json" - foreach ($jsonFile in $jsonFiles) { - $disabledTests += (Get-Content -Raw -Path $jsonFile.FullName | ConvertFrom-Json) - } - } - } - - return @($disabledTests) -} - <# .SYNOPSIS Runs Run-TestsInBcContainer and detects silent test truncation caused by BCH ERROR DIALOG events. @@ -84,15 +58,21 @@ function Invoke-RunTestsWithCancellationDetection { } $bchCancelled = $false + $transientPlatformRace = $false if ($transcriptStarted -and (Test-Path $transcriptFile)) { - if (Select-String -Path $transcriptFile -Pattern 'database command was cancelled|ERROR DIALOG' -Quiet) { + $transcriptContent = Get-Content -Path $transcriptFile -Raw + if ($transcriptContent -match 'database command was cancelled|ERROR DIALOG') { Write-Host "::warning::BCH client cancellation detected for app '$($parameters['appName'])' on tenant '$($parameters['tenant'])'. Tests were silently truncated - subsequent codeunits did not run." $bchCancelled = $true } + if (Test-TransientTestFailure -Output $transcriptContent) { + Write-Host "::warning::TRANSIENT TEST PLATFORM RACE detected for app '$($parameters['appName'])' on tenant '$($parameters['tenant'])'." + $transientPlatformRace = $true + } } Remove-Item $transcriptFile -Force -ErrorAction SilentlyContinue - return ($bchPassed -and -not $bchCancelled) + return ($bchPassed -and -not $bchCancelled -and -not $transientPlatformRace) } function Invoke-TestsWithReruns { @@ -126,7 +106,7 @@ if (($null -ne $TestType) -and ($TestType -ne "Legacy")) { $parameters["testType"] = $TestType } -$parameters["disabledTests"] = @(Get-DisabledTests -AppName $parameters["appName"]) # Add disabled tests to parameters +$parameters["disabledTests"] = @(Get-DisabledTestsForApp -AppName $parameters["appName"]) $parameters["renewClientContextBetweenTests"] = $true # When invoked from the per-project override on the parent process, $AppNamesToTest contains @@ -135,17 +115,18 @@ $parameters["renewClientContextBetweenTests"] = $true # circuit. When invoked from inside a background job (Start-TestJob), $AppNamesToTest is empty # and we fall through to the sequential single-app path below. if ($AppNamesToTest.Count -gt 0) { - Import-Module $PSScriptRoot\ParallelTestExecution.psm1 return Invoke-ParallelTestExecution -parameters $parameters -scriptPath $PSCommandPath -testType $TestType -appNamesToTest $AppNamesToTest } +$isRequiredDisabledRun = $parameters.ContainsKey("requiredTestIsolation") -and $parameters["requiredTestIsolation"] -eq "Disabled" # A failing app is retried once by the parallel dispatcher, on a different tenant (see # ParallelTestExecution.psm1). Retrying in place here would reuse the tenant the app just dirtied, # so the same residue could re-trigger the failure - hence a single attempt per dispatch. $result = Invoke-TestsWithReruns -parameters $parameters -maxAttempts 1 -# For UnitTests, also run with DisableTestIsolation on the same tenant -if ($TestType -eq "UnitTest") { +# Preserve the old sequential fallback. Parallel project execution defers this pass to the +# clean-tenant codeunit scheduler in ParallelTestExecution.psm1. +if ($TestType -eq "UnitTest" -and -not $SkipAutomaticDisabledPass -and -not $isRequiredDisabledRun) { Write-Host "Running DisableTestIsolation pass for UnitTest" $parameters["requiredTestIsolation"] = "Disabled" $parameters["testRunnerCodeunitId"] = "130451" diff --git a/build/scripts/tests/ApiTestCredential.Test.ps1 b/build/scripts/tests/ApiTestCredential.Test.ps1 new file mode 100644 index 00000000000..f47051ac4e3 --- /dev/null +++ b/build/scripts/tests/ApiTestCredential.Test.ps1 @@ -0,0 +1,249 @@ +Describe 'API test credential materialization' { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ApiTestCredential.psm1') -Force + $script:previousGitHubEnv = $env:GITHUB_ENV + $script:previousPasswordPath = $env:BCAppsApiTestPasswordPath + $script:previousPasswordContainer = $env:BCAppsApiTestPasswordContainer + $script:previousExitCode = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction SilentlyContinue + $script:createdMountStub = -not (Get-Command Get-BcContainerSharedFolders -ListImported -ErrorAction SilentlyContinue) + if ($script:createdMountStub) { + function global:Get-BcContainerSharedFolders { + param([string]$containerName) + $null = $containerName + throw 'Container mount lookup must be mocked.' + } + } + $script:createdDockerStub = -not (Get-Command docker -ErrorAction SilentlyContinue) + if ($script:createdDockerStub) { + function global:docker { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) + $null = $Arguments + throw 'Docker must be mocked.' + } + } + $script:fixturePassword = [System.Security.SecureString]::new() + foreach ($character in 'synthetic-fixture-only'.ToCharArray()) { + $script:fixturePassword.AppendChar($character) + } + $script:fixturePassword.MakeReadOnly() + $script:credential = [PSCredential]::new('unit-test', $script:fixturePassword) + $script:mount = Join-Path $PSScriptRoot 'unused-mount' + } + + AfterAll { + $script:fixturePassword.Dispose() + $env:GITHUB_ENV = $script:previousGitHubEnv + $env:BCAppsApiTestPasswordPath = $script:previousPasswordPath + $env:BCAppsApiTestPasswordContainer = $script:previousPasswordContainer + $global:LASTEXITCODE = $script:previousExitCode + if ($script:createdMountStub) { Remove-Item function:global:Get-BcContainerSharedFolders } + if ($script:createdDockerStub) { Remove-Item function:global:docker } + } + + BeforeEach { + $env:GITHUB_ENV = Join-Path $PSScriptRoot 'unused-github-env' + $env:BCAppsApiTestPasswordPath = $null + $env:BCAppsApiTestPasswordContainer = $null + $script:events = [System.Collections.Generic.List[string]]::new() + $script:stream = [PSCustomObject]@{ + Events = $script:events + Buffer = $null + Written = $null + FailWrite = $false + FailDispose = $false + } + $script:stream | Add-Member ScriptMethod Write { + param($buffer, $offset, $count) + $this.Events.Add('write') + $this.Buffer = $buffer + $this.Written = [System.Text.Encoding]::UTF8.GetString($buffer, $offset, $count) + if ($this.FailWrite) { throw 'Synthetic write failure' } + } + $script:stream | Add-Member ScriptMethod Dispose { + $this.Events.Add('dispose') + if ($this.FailDispose) { throw 'Synthetic flush failure' } + } + Mock -ModuleName ApiTestCredential Get-BcContainerSharedFolders { @{ $script:mount = 'c:\run\my\' } } + Mock -ModuleName ApiTestCredential Add-Content { $script:events.Add('register') } + Mock -ModuleName ApiTestCredential New-ApiTestPasswordFileStream { + $env:BCAppsApiTestPasswordPath | Should -Be (Join-Path $script:mount 'ApiTestPassword') + $env:BCAppsApiTestPasswordContainer | Should -Be 'unit-test' + $script:events.Add('create') + $script:stream + } + Mock -ModuleName ApiTestCredential Test-Path { $true } + Mock -ModuleName ApiTestCredential docker { + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'container') { 'synthetic-container-id' } + else { $script:events.Add('stop') } + } + Mock -ModuleName ApiTestCredential Remove-Item { $script:events.Add('delete') } + } + + It 'registers cleanup and creates the secured backing file before the first secret write' { + Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential + + ($script:events -join ',') | Should -Be 'register,create,write,dispose' + $script:stream.Written | Should -Be 'synthetic-fixture-only' + @($script:stream.Buffer | Where-Object { $_ -ne 0 }).Count | Should -Be 0 + Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 1 -Exactly -ParameterFilter { + $FilePath -eq (Join-Path $script:mount 'ApiTestPassword') + } + Should -Invoke -ModuleName ApiTestCredential Add-Content -Times 1 -Exactly -ParameterFilter { + $LiteralPath -eq $env:GITHUB_ENV -and $ErrorAction -eq 'Stop' -and + $Value.Count -eq 2 -and + $Value -contains "BCAppsApiTestPasswordPath=$(Join-Path $script:mount 'ApiTestPassword')" -and + $Value -contains 'BCAppsApiTestPasswordContainer=unit-test' + } + Should -Invoke -ModuleName ApiTestCredential docker -Times 0 + } + + It 'uses the same secured mount locally without workflow registration' { + $env:GITHUB_ENV = '' + Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential + + ($script:events -join ',') | Should -Be 'create,write,dispose' + Should -Invoke -ModuleName ApiTestCredential Get-BcContainerSharedFolders -Times 1 -Exactly + Should -Invoke -ModuleName ApiTestCredential Add-Content -Times 0 + } + + It 'preserves Unicode passwords while clearing the written byte buffer' { + $characters = [char[]]@(0x0061, 0x00E9, 0xD83D, 0xDD10) + $password = [System.Security.SecureString]::new() + try { + foreach ($character in $characters) { $password.AppendChar($character) } + $password.MakeReadOnly() + $credential = [PSCredential]::new('unit-test', $password) + + Write-ApiTestPassword -ContainerName 'unit-test' -Credential $credential + + $script:stream.Written | Should -Be (-join $characters) + @($script:stream.Buffer | Where-Object { $_ -ne 0 }).Count | Should -Be 0 + } + finally { + $password.Dispose() + } + } + + It 'does not materialize an immutable plaintext password string in the writer' { + $source = Get-Content (Join-Path $PSScriptRoot '..\ApiTestCredential.psm1') -Raw + $source | Should -Not -Match 'GetNetworkCredential|PtrToString' + $source | Should -Match 'SecureStringToBSTR' + $source | Should -Match 'ZeroFreeBSTR' + } + + It 'fails closed on an unresolved mount in CI and locally' { + Mock -ModuleName ApiTestCredential Get-BcContainerSharedFolders { @{} } + foreach ($environmentPath in @('unused-github-env', '')) { + $env:GITHUB_ENV = $environmentPath + { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Cannot resolve*' + } + Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 0 + Should -Invoke -ModuleName ApiTestCredential Add-Content -Times 0 + } + + It 'fails closed on ambiguous mounts' { + Mock -ModuleName ApiTestCredential Get-BcContainerSharedFolders { @{ 'C:\first' = 'C:\Run\my'; 'C:\second' = 'c:\run\my' } } + { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Cannot resolve*' + Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 0 + } + + It 'does not materialize credentials if workflow cleanup registration fails' { + Mock -ModuleName ApiTestCredential Add-Content { throw 'Synthetic registration failure' } + { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic registration failure*' + Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 0 + } + + It 'does not write or delete an existing file when secured creation fails' { + Mock -ModuleName ApiTestCredential New-ApiTestPasswordFileStream { throw 'Synthetic ACL or create failure' } + { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic ACL or create failure*' + ($script:events -join ',') | Should -Be 'register' + Should -Invoke -ModuleName ApiTestCredential Remove-Item -Times 0 + } + + It 'closes the failed writer and stops consumers before deleting a partial file' { + $script:stream.FailWrite = $true + { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic write failure*' + + ($script:events -join ',') | Should -Be 'register,create,write,dispose,stop,delete' + @($script:stream.Buffer | Where-Object { $_ -ne 0 }).Count | Should -Be 0 + } + + It 'also cleans up after a flush failure without reporting success' { + $script:stream.FailDispose = $true + { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic flush failure*' + ($script:events -join ',') | Should -Be 'register,create,write,dispose,stop,delete' + } + + It 'preserves the setup error and credential when consumers cannot be stopped' { + $script:stream.FailWrite = $true + Mock -ModuleName ApiTestCredential docker { $global:LASTEXITCODE = 1 } -ParameterFilter { $args[0] -eq 'stop' } + Mock -ModuleName ApiTestCredential Write-Warning {} + + { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic write failure*' + Should -Invoke -ModuleName ApiTestCredential Remove-Item -Times 0 + Should -Invoke -ModuleName ApiTestCredential Write-Warning -Times 1 -Exactly + } +} + +Describe 'API test credential atomic Windows ACL' { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ApiTestCredential.psm1') -Force + $script:aclRoot = Join-Path $PSScriptRoot ("acl-fixture-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:aclRoot | Out-Null + } + + AfterAll { + Remove-Item -LiteralPath $script:aclRoot -Recurse -Force + } + + It 'has only required explicit SIDs on an empty file before writing synthetic bytes' { + $path = Join-Path $script:aclRoot 'ApiTestPassword' + $stream = $null + try { + $stream = & (Get-Module ApiTestCredential) { param($path) New-ApiTestPasswordFileStream -FilePath $path } $path + $stream.Length | Should -Be 0 + $acl = Get-Acl -LiteralPath $path + $acl.AreAccessRulesProtected | Should -BeTrue + $rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) + @($rules | Where-Object IsInherited).Count | Should -Be 0 + $writerSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $expectedSids = @('S-1-5-18', 'S-1-5-20', 'S-1-5-32-544', $writerSid) | Select-Object -Unique + ($rules.IdentityReference.Value | Sort-Object) | Should -Be ($expectedSids | Sort-Object) + foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) { + ($rules | Where-Object { $_.IdentityReference.Value -eq $sid }).FileSystemRights | + Should -Be ([System.Security.AccessControl.FileSystemRights]::FullControl) + } + $networkServiceRights = [System.Security.AccessControl.FileSystemRights]'Read, Synchronize' + if ($writerSid -eq 'S-1-5-20') { + $networkServiceRights = $networkServiceRights -bor [System.Security.AccessControl.FileSystemRights]'Write, Delete' + } + ($rules | Where-Object { $_.IdentityReference.Value -eq 'S-1-5-20' }).FileSystemRights | + Should -Be $networkServiceRights + if ($writerSid -notin @('S-1-5-18', 'S-1-5-20', 'S-1-5-32-544')) { + ($rules | Where-Object { $_.IdentityReference.Value -eq $writerSid }).FileSystemRights | + Should -Be ([System.Security.AccessControl.FileSystemRights]'Write, Delete, Synchronize') + } + $stream.WriteByte(65) + $stream.Length | Should -Be 1 + } + finally { + if ($stream) { $stream.Dispose() } + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } + + It 'refuses to overwrite an existing file or change its ACL' { + $path = Join-Path $script:aclRoot 'ApiTestPassword' + [System.IO.File]::WriteAllText($path, 'synthetic-existing-fixture') + $before = (Get-Acl -LiteralPath $path).Sddl + try { + { & (Get-Module ApiTestCredential) { param($path) New-ApiTestPasswordFileStream -FilePath $path } $path } | Should -Throw + [System.IO.File]::ReadAllText($path) | Should -Be 'synthetic-existing-fixture' + (Get-Acl -LiteralPath $path).Sddl | Should -Be $before + } + finally { + Remove-Item -LiteralPath $path -Force + } + } +} diff --git a/build/scripts/tests/ParallelTestExecution.Test.ps1 b/build/scripts/tests/ParallelTestExecution.Test.ps1 index 84c7d0e83ba..53fbe2acad9 100644 --- a/build/scripts/tests/ParallelTestExecution.Test.ps1 +++ b/build/scripts/tests/ParallelTestExecution.Test.ps1 @@ -1,8 +1,41 @@ $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 +if (-not (Get-Command Get-TestsFromBcContainer -ErrorAction SilentlyContinue)) { + function global:Get-TestsFromBcContainer { + param( + [string]$containerName, + [string]$tenant, + [string]$extensionId, + [string]$requiredTestIsolation, + [string]$testType, + [array]$disabledTests + ) + $null = $containerName, $tenant, $extensionId, $requiredTestIsolation, $testType, $disabledTests + throw "Get-TestsFromBcContainer stub should never be called; a Pester mock must intercept it." + } +} + +if (-not (Get-Command Invoke-ScriptInBcContainer -ErrorAction SilentlyContinue)) { + function global:Invoke-ScriptInBcContainer { + param( + [string]$containerName, + [scriptblock]$scriptblock, + [object[]]$argumentList, + [bool]$useSession + ) + $null = $containerName, $scriptblock, $argumentList, $useSession + throw "Invoke-ScriptInBcContainer stub should never be called; a Pester mock must intercept it." + } +} + Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force Describe "ParallelTestExecution app-name resolution" { + BeforeEach { + Mock -ModuleName ParallelTestExecution Get-ALGoSetting { $true } -ParameterFilter { + $Key -eq 'enableCleanTestCodeunitExecution' + } + } BeforeAll { # Get-BcContainerAppInfo comes from BcContainerHelper, which is present when the module # runs inside a BC container but is NOT loaded in the "Run PS Tests" runner. Pester cannot @@ -97,6 +130,122 @@ Describe "ParallelTestExecution app-name resolution" { $result | Should -Not -Contain 'Projects-Json-Key' } } + + It "uses docker exec for the long-running template database copy" { + InModuleScope ParallelTestExecution { + Mock Invoke-ScriptInBcContainer { 'default-test-template' } + + New-BcTestTenantTemplate -ContainerName 'bc' -SourceDatabaseName 'default' | Should -Be 'default-test-template' + + Should -Invoke Invoke-ScriptInBcContainer -Times 1 -ParameterFilter { + $containerName -eq 'bc' -and $useSession -eq $false + } + } + } + + It "uses docker exec for long-running tenant database refreshes" { + InModuleScope ParallelTestExecution { + Mock Invoke-ScriptInBcContainer { } + + Reset-BcTestTenant -ContainerName 'bc' -Tenant 'tenant2' ` + -TenantDatabaseName 'tenant2' -TemplateDatabaseName 'default-test-template' + + Should -Invoke Invoke-ScriptInBcContainer -Times 1 -ParameterFilter { + $containerName -eq 'bc' -and $useSession -eq $false + } + } + } + + It "removes the clean template when normal app dispatch aborts" { + InModuleScope ParallelTestExecution { + Mock Get-AvailableBcTenantInfo { + @( + [PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' } + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + ) + } + Mock Get-BcContainerAppInfo { + @([PSCustomObject]@{ IsInstalled = $true; Name = 'Tests'; AppId = 'tests-id' }) + } + Mock Get-RequiredDisabledWorkItems { + @([PSCustomObject]@{ + Key = 'Tests::500' + AppName = 'Tests' + AppId = 'tests-id' + CodeunitId = '500' + CodeunitName = 'API E2E' + TestCount = 2 + }) + } + Mock New-BcTestTenantTemplate { 'default-test-template' } + Mock Invoke-RequiredDisabledTestExecution { $true } + Mock Reset-BcTestTenant { } + Mock Invoke-WarmupDispatch { @($Pending) } + Mock Wait-ForFreeTenant { 'default' } + Mock Start-TestAppDispatch { throw 'dispatch failed' } + Mock Remove-BcTestTenantTemplate { } + + { + Invoke-ParallelTestExecution -parameters @{ + containerName = "ut-$([guid]::NewGuid().ToString('N'))" + tenant = 'default' + } -scriptPath 'unused.ps1' -testType 'IntegrationTest' -appNamesToTest @('Tests') + } | Should -Throw '*dispatch failed*' + + Should -Invoke Remove-BcTestTenantTemplate -Times 1 -ParameterFilter { + $TemplateDatabaseName -eq 'default-test-template' + } + } + } +} + +Describe "ParallelTestExecution background-task profile" { + BeforeAll { + $script:profileRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path + $script:repoSettings = Get-Content (Join-Path $script:profileRepoRoot '.github\AL-Go-Settings.json') -Raw | + ConvertFrom-Json + } + + It "opts only UncategorizedTests into background task execution" { + $script:repoSettings.PSObject.Properties.Name | Should -Not -Contain 'enableTaskScheduler' + $rules = @($script:repoSettings.conditionalSettings | Where-Object { + $_.settings.PSObject.Properties.Name -contains 'enableTaskScheduler' + }) + $rules.Count | Should -Be 1 + @($rules[0].buildModes).Count | Should -Be 1 + $rules[0].buildModes[0] | Should -Be 'UncategorizedTests' + $rules[0].settings.enableTaskScheduler | Should -BeTrue + $rules[0].PSObject.Properties.Name | Should -Not -Contain 'projects' + } + + It "retains the profile for every country test project without scheduler overrides" { + $projects = @(Get-ChildItem (Join-Path $script:profileRepoRoot 'build\projects') -Directory -Filter 'Test Apps *') + $projects.Count | Should -BeGreaterThan 0 + foreach ($project in $projects) { + $settings = Get-Content (Join-Path $project.FullName '.AL-Go\settings.json') -Raw | ConvertFrom-Json + $settings.PSObject.Properties.Name | Should -Not -Contain 'enableTaskScheduler' + $profiles = @($settings.ConditionalSettings | Where-Object { $_.buildModes -contains 'UncategorizedTests' }) + $profiles.Count | Should -Be @($settings.buildModes | Where-Object { $_ -eq 'UncategorizedTests' }).Count + foreach ($testProfile in $profiles) { + $testProfile.settings.testType | Should -Be 'Uncategorized' + } + foreach ($rule in $settings.ConditionalSettings) { + $rule.settings.PSObject.Properties.Name | Should -Not -Contain 'enableTaskScheduler' + } + } + } + + It "runs APIV RapidStart polling tests in the background-enabled profile" -ForEach @( + @{ Version = '1' } + @{ Version = '2' } + ) { + $path = Join-Path $script:profileRepoRoot "src\Apps\W1\APIV$Version\test\src\APIV${Version}AutomationRSPackage.Codeunit.al" + $source = Get-Content $path -Raw + $source | Should -Match 'TestType\s*=\s*Uncategorized\s*;' + $source | Should -Match 'procedure TestImportRSPackage\(' + $source | Should -Match 'procedure TestImportWrongRSPackage\(' + $source | Should -Match 'procedure TestApplyRSPackage\(' + } } Describe "ParallelTestExecution transient retry scheduling" { @@ -104,6 +253,63 @@ Describe "ParallelTestExecution transient retry scheduling" { Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force } + It "classifies page 130455 metadata-resolution runtime failures as transient" { + InModuleScope ParallelTestExecution { + @( + "ObjName:Command Line Test Tool, ObjID:130455, Type:Form, MethodName:ExtensionId_a45_OnValidate`nOffset and length were out of bounds for the array" + "ObjName:Command Line Test Tool, ObjID:130455, Type:Form, MethodName:ExtensionId_a45_OnValidate`nNullable object must have a value." + "TRANSIENT TEST PLATFORM RACE detected for app 'Tests' on tenant 'tenant2'." + "Cannot open page 130455" + "InvokeInteractions failed with status code 500" + "at InteractionManager.InvokeInteractions in InteractionManager.cs:line 203" + "Cannot open page 130455`nClientSession State is InError (Wait time 25 seconds)" + ) | ForEach-Object { + Test-TransientTestFailure -Output $_ | Should -BeTrue + } + } + } + + It "does not classify generic session errors or unrelated failures as transient" { + InModuleScope ParallelTestExecution { + @( + $null + '' + 'ClientSession State is InError (Wait time 25 seconds)' + "Assertion failed: expected 2 but was 1`nClientSession State is InError" + "Permission denied opening page 42`nClientSession State is InError" + 'Cannot open page 130456' + 'InvokeInteractions failed with status code 403' + 'Nullable object must have a value.' + 'Object reference not set to an instance of an object.' + 'Assertion failed: expected 2 but was 1' + 'GET request failed. Response code is 500 (InternalServerError), expected code is 200. Error message: Object reference not set to an instance of an object.' + "Opened page 130455 successfully.`nGET request failed. Response code is 500 (InternalServerError), expected code is 200. Error message: Object reference not set to an instance of an object." + ) | ForEach-Object { + Test-TransientTestFailure -Output $_ | Should -BeFalse + } + } + } + + It "retries known races only once and never retries ordinary InError failures" { + InModuleScope ParallelTestExecution { + Mock Receive-Job { 'Cannot open page 130455' } -RemoveParameterType Job + Mock Remove-Job {} -RemoveParameterType Job + $entry = @{ appName = 'Tests'; tenant = 'default' } + $job = @{ State = 'Failed' } + + (Receive-TestJobResult -Entry $entry -Job $job -Retried @{}).Outcome | Should -Be 'Transient' + (Receive-TestJobResult -Entry $entry -Job $job -Retried @{ Tests = $true }).Outcome | Should -Be 'Failed' + + Mock Receive-Job { "Assertion failed`nClientSession State is InError" } -RemoveParameterType Job + (Receive-TestJobResult -Entry $entry -Job $job -Retried @{}).Outcome | Should -Be 'Failed' + Mock Receive-Job { 'GET request failed. Response code is 500 (InternalServerError), expected code is 200. Error message: Object reference not set to an instance of an object.' } -RemoveParameterType Job + (Receive-TestJobResult -Entry $entry -Job $job -Retried @{}).Outcome | Should -Be 'Failed' + $job.State = 'Completed' + (Receive-TestJobResult -Entry $entry -Job $job -Retried @{}).Outcome | Should -Be 'Passed' + Should -Invoke Remove-Job -Times 5 -Exactly + } + } + It "re-dispatches a transient platform-race victim ahead of the apps still queued" { # The dispatch order is what decides the critical path: a platform race normally kills a # job within a minute of dispatch, so the victim is one of the first (longest) apps. If the @@ -113,13 +319,20 @@ Describe "ParallelTestExecution transient retry scheduling" { $script:dispatched = [System.Collections.Generic.List[string]]::new() $script:raced = $false - Mock Get-AvailableBcTenants { @('default') } + Mock Get-AvailableBcTenantInfo { + @( + [PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' } + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + ) + } Mock Get-BcContainerAppInfo { @('Big', 'Medium', 'Small') | ForEach-Object { [PSCustomObject]@{ IsInstalled = $true; Name = $_; AppId = "id-$_" } } } + Mock Get-RequiredDisabledWorkItems { @() } Mock Wait-ForFreeTenant { 'default' } + Mock Wait-ForSpecificTenant { 'default' } Mock Wait-ForAllTestJobs { $true } Mock Merge-TenantTestResults { } Mock Start-TestAppDispatch { @@ -127,7 +340,9 @@ Describe "ParallelTestExecution transient retry scheduling" { # 'Big' loses the platform race on its very first dispatch, exactly once. if ($AppName -eq 'Big' -and -not $script:raced) { $script:raced = $true - $State.transient = @($State.transient) + @($AppName) + $State.transient = @($State.transient) + @( + [PSCustomObject]@{ Key = $AppName; Tenant = $Tenant } + ) } } @@ -140,24 +355,910 @@ Describe "ParallelTestExecution transient retry scheduling" { } } +Describe "ParallelTestExecution RequiredTestIsolation discovery" { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force + } + + It "flattens discovered codeunits and excludes codeunits without enabled methods" { + InModuleScope ParallelTestExecution { + $discovered = @( + [PSCustomObject]@{ Id = '100'; Name = 'Direct'; Tests = @('A', 'B') } + [PSCustomObject]@{ + Group = 'G' + Codeunits = @( + [PSCustomObject]@{ Id = '200'; Name = 'Grouped'; Tests = @('C') } + [PSCustomObject]@{ Id = '300'; Name = 'Disabled'; Tests = @() } + ) + } + ) + + $result = @(ConvertTo-RequiredDisabledWorkItems -DiscoveredTests $discovered -AppName 'API Tests' -AppId 'app-id') + + $result.Count | Should -Be 2 + $result[0].Key | Should -Be 'API Tests::100' + $result[0].TestCount | Should -Be 2 + $result[1].CodeunitId | Should -Be '200' + } + } + + It "discovers selected apps once each while preserving isolation, lane and exclusion filters" { + InModuleScope ParallelTestExecution { + Mock Get-ParametersForCommand { @{ containerName = 'c'; tenant = 'default' } } + Mock Get-DisabledTestsForApp { + @([PSCustomObject]@{ codeunitId = 999; method = 'DisabledMethod' }) + } + Mock Get-TestsFromBcContainer { + @([PSCustomObject]@{ Id = '500'; Name = 'API E2E'; Tests = @('Create', 'Modify') }) + } + + $result = @( + Get-RequiredDisabledWorkItems -Parameters @{ containerName = 'c'; tenant = 'default' } ` + -TestType 'IntegrationTest' -AppNamesToTest @('API Tests', 'New API Tests') ` + -AppIdByName @{ 'API Tests' = 'app-id'; 'New API Tests' = 'new-app-id'; 'Unselected Tests' = 'other-id' } + ) + + $result.Count | Should -Be 2 + $result[0].CodeunitId | Should -Be '500' + $result[1].AppName | Should -Be 'New API Tests' + Should -Invoke Get-TestsFromBcContainer -Times 1 -ParameterFilter { + $extensionId -eq 'app-id' -and + $requiredTestIsolation -eq 'Disabled' -and + $testType -eq 'IntegrationTest' -and + $disabledTests.Count -eq 1 + } + Should -Invoke Get-TestsFromBcContainer -Times 1 -Exactly -ParameterFilter { + $extensionId -eq 'new-app-id' -and + $requiredTestIsolation -eq 'Disabled' -and + $testType -eq 'IntegrationTest' -and + $disabledTests.Count -eq 1 + } + Should -Invoke Get-TestsFromBcContainer -Times 0 -ParameterFilter { + $extensionId -eq 'other-id' + } + } + } + + It "does not use unfiltered discovery for Legacy buckets" { + InModuleScope ParallelTestExecution { + Mock Get-ParametersForCommand { @{ containerName = 'c'; tenant = 'default'; testType = 'stale' } } + Mock Get-DisabledTestsForApp { @() } + Mock Get-TestsFromBcContainer { @() } + + $result = @(Get-RequiredDisabledWorkItems -Parameters @{ containerName = 'c' } ` + -TestType 'Legacy' -AppNamesToTest @('Legacy Tests') ` + -AppIdByName @{ 'Legacy Tests' = 'legacy-id' }) + + $result.Count | Should -Be 0 + Should -Invoke Get-TestsFromBcContainer -Times 0 + } + } +} + +Describe "ParallelTestExecution clean tenant scheduling" { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force + } + + BeforeEach { + Mock -ModuleName ParallelTestExecution Get-ALGoSetting { $true } -ParameterFilter { + $Key -eq 'enableCleanTestCodeunitExecution' + } + Mock -ModuleName ParallelTestExecution Invoke-ScriptInBcContainer { + throw 'Unexpected unmocked container access.' + } + Mock -ModuleName ParallelTestExecution Reset-BcTestTenant { + throw 'Tenant resets require an explicit test mock.' + } + } + + It "writes and reads cached state without temp environment variables" { + InModuleScope ParallelTestExecution { + $previousRunnerTemp = $env:RUNNER_TEMP + $previousTemp = $env:TEMP + $containerName = "ut-$([guid]::NewGuid().ToString('N'))" + $stateFile = Join-Path ([System.IO.Path]::GetTempPath()) "parallelTests_$containerName.json" + Mock Get-AvailableBcTenantInfo { + @([PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' }) + } + Mock Get-BcContainerAppInfo { + @([PSCustomObject]@{ IsInstalled = $true; Name = 'Tests'; AppId = 'tests-id' }) + } + Mock Get-RequiredDisabledWorkItems { @() } + Mock Wait-ForFreeTenant { 'default' } + Mock Start-TestAppDispatch { } + Mock Wait-ForAllTestJobs { $true } + Mock Merge-TenantTestResults { } + try { + $env:RUNNER_TEMP = $null + $env:TEMP = $null + $stateFile = Join-Path ([System.IO.Path]::GetTempPath()) "parallelTests_$containerName.json" + + Invoke-ParallelTestExecution -parameters @{ + containerName = $containerName + tenant = 'default' + } -scriptPath 'unused.ps1' -testType 'IntegrationTest' -appNamesToTest @('Tests') | + Should -BeTrue + + Test-Path $stateFile | Should -BeTrue + Get-CachedTestRunResult -ContainerName $containerName | Should -BeTrue + } finally { + $env:RUNNER_TEMP = $previousRunnerTemp + $env:TEMP = $previousTemp + if (Test-Path $stateFile) { + Remove-Item $stateFile -Force + } + } + } + } + + It "does not create a database template when no Disabled-isolation codeunits are enabled" { + InModuleScope ParallelTestExecution { + Mock Get-AvailableBcTenantInfo { + @([PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' }) + } + Mock Get-BcContainerAppInfo { + @([PSCustomObject]@{ IsInstalled = $true; Name = 'Tests'; AppId = 'tests-id' }) + } + Mock Get-RequiredDisabledWorkItems { @() } + Mock New-BcTestTenantTemplate { throw 'Template must not be created' } + Mock Wait-ForFreeTenant { 'default' } + Mock Start-TestAppDispatch { } + Mock Wait-ForAllTestJobs { $true } + Mock Merge-TenantTestResults { } + + $result = Invoke-ParallelTestExecution -parameters @{ + containerName = "ut-$([guid]::NewGuid().ToString('N'))" + tenant = 'default' + } -scriptPath 'unused.ps1' -testType 'IntegrationTest' -appNamesToTest @('Tests') + + $result | Should -BeTrue + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Get-RequiredDisabledWorkItems -Times 1 -Exactly -ParameterFilter { + $AppNamesToTest.Count -eq 1 -and $AppNamesToTest[0] -eq 'Tests' + } + Should -Invoke Start-TestAppDispatch -Times 1 -Exactly -ParameterFilter { + $SkipAutomaticDisabledPass + } + } + } + + Describe "ParallelTestExecution result metadata" { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force + } + + It "adds app properties to clean-codeunit JUnit suites" { + InModuleScope ParallelTestExecution { + $resultFile = Join-Path ([System.IO.Path]::GetTempPath()) "junit-$([guid]::NewGuid().ToString('N')).xml" + try { + @( + '' + '' + ' ' + ' ' + ' ' + '' + ) | Set-Content -Path $resultFile -Encoding utf8 + + Add-MissingJUnitTestProperties -ResultFile $resultFile -WorkItems @( + [PSCustomObject]@{ + CodeunitId = '139800' + AppId = 'app-id' + AppName = '_Exclude_APIV2_ Tests' + } + ) + + [xml]$xml = Get-Content $resultFile -Raw + $properties = @($xml.testsuites.testsuite.properties.property) + ($properties | Where-Object name -eq 'extensionid').value | Should -Be 'app-id' + ($properties | Where-Object name -eq 'appName').value | Should -Be '_Exclude_APIV2_ Tests' + } finally { + Remove-Item $resultFile -Force -ErrorAction SilentlyContinue + } + } + } + } + + It "defers the automatic Unit disabled pass only for clean-tenant apps" { + InModuleScope ParallelTestExecution { + $script:skipValues = [System.Collections.Generic.List[bool]]::new() + Mock Start-Sleep { } + Mock Start-TestJob { + $script:skipValues.Add($skipAutomaticDisabledPass.IsPresent) + [PSCustomObject]@{ Id = $script:skipValues.Count } + } + + $state = [PSCustomObject]@{ jobs = @() } + Start-TestAppDispatch -Parameters @{} -AppName 'Normal Tests' -AppId 'normal-id' ` + -Tenant 'default' -ScriptPath 'runner.ps1' -TestType 'UnitTest' -State $state + Start-TestAppDispatch -Parameters @{} -AppName 'API Tests' -AppId 'api-id' ` + -Tenant 'tenant2' -ScriptPath 'runner.ps1' -TestType 'UnitTest' -State $state ` + -SkipAutomaticDisabledPass + + $script:skipValues | Should -Be @($false, $true) + } + } + + It "loads all app exclusions without consulting country settings" { + InModuleScope ParallelTestExecution { + $script:disabledTestRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N')) + Mock Get-BaseFolder { $script:disabledTestRoot } + Mock Get-ALGoSetting { throw 'Exclusions must not depend on country settings' } + try { + $appFolder = Join-Path $script:disabledTestRoot 'DisabledTests\Example_Tests' + $null = New-Item -Path $appFolder -ItemType Directory -Force + '[{"codeunitId":500,"method":"First"}]' | + Set-Content (Join-Path $appFolder 'first.json') + '[{"codeunitId":501,"method":"*"}]' | + Set-Content (Join-Path $appFolder 'second.json') + + $result = @(Get-DisabledTestsForApp -AppName 'Example Tests') + + $result.Count | Should -Be 2 + $result.codeunitId | Should -Contain 500 + $result.codeunitId | Should -Contain 501 + @(Get-DisabledTestsForApp -AppName 'Other Tests').Count | Should -Be 0 + Should -Invoke Get-ALGoSetting -Times 0 + } finally { + Remove-Item $script:disabledTestRoot -Recurse -Force + } + } + } + + It "dispatches one codeunit with Disabled isolation" { + InModuleScope ParallelTestExecution { + $script:capturedParameters = $null + + Mock Get-DisabledTestsForApp { @() } + Mock Start-Sleep { } + Mock Start-TestJob { + $script:capturedParameters = $parameters + [PSCustomObject]@{ Id = 42 } + } + + $state = [PSCustomObject]@{ jobs = @() } + $workItem = [PSCustomObject]@{ + Key = 'Tests::500' + AppName = 'Tests' + AppId = 'tests-id' + CodeunitId = '500' + CodeunitName = 'API E2E' + } + + Start-RequiredDisabledDispatch -Parameters @{ + containerName = 'c' + JUnitResultFileName = 'results.xml' + } -WorkItem $workItem -TenantInfo ([PSCustomObject]@{ + Id = 'tenant2' + DatabaseName = 'tenant2' + }) -ScriptPath 'runner.ps1' ` + -TestType 'IntegrationTest' -State $state + + $script:capturedParameters.testCodeunit | Should -Be '500' + $script:capturedParameters.requiredTestIsolation | Should -Be 'Disabled' + $script:capturedParameters.testRunnerCodeunitId | Should -Be '130451' + $script:capturedParameters.AppendToJUnitResultFile | Should -BeTrue + $state.jobs.Count | Should -Be 1 + } + } + + It "marks scheduler retries as reruns so existing XML entries are replaced" { + InModuleScope ParallelTestExecution { + $script:capturedParameters = $null + + Mock Get-DisabledTestsForApp { @() } + Mock Reset-BcTestTenant { } + Mock Start-Sleep { } + Mock Start-TestJob { + $script:capturedParameters = $parameters + [PSCustomObject]@{ Id = 42 } + } + + Start-RequiredDisabledDispatch -Parameters @{ containerName = 'c' } -WorkItem ([PSCustomObject]@{ + Key = 'Tests::500' + AppName = 'Tests' + AppId = 'tests-id' + CodeunitId = '500' + CodeunitName = 'API E2E' + }) -TenantInfo ([PSCustomObject]@{ + Id = 'tenant2' + DatabaseName = 'tenant2' + }) -ScriptPath 'runner.ps1' ` + -TestType 'IntegrationTest' -State ([PSCustomObject]@{ jobs = @() }) ` + -Verb 'Re-dispatching' + + $script:capturedParameters.ReRun | Should -BeTrue + } + } + + It "retries a clean codeunit on its original tenant" { + InModuleScope ParallelTestExecution { + $script:dispatchTenants = [System.Collections.Generic.List[string]]::new() + $script:firstDispatch = $true + + Mock Reset-BcTestTenant { } + Mock Wait-ForAllTestJobs { $true } + Mock Start-RequiredDisabledDispatch { + $script:dispatchTenants.Add($TenantInfo.Id) + if ($script:firstDispatch) { + $script:firstDispatch = $false + $State.transient = @( + [PSCustomObject]@{ Key = $WorkItem.Key; Tenant = $TenantInfo.Id } + ) + } + } + + $workItem = [PSCustomObject]@{ + Key = 'Tests::500' + AppName = 'Tests' + AppId = 'tests-id' + CodeunitId = '500' + CodeunitName = 'API E2E' + } + $tenantInfo = @( + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + [PSCustomObject]@{ Id = 'tenant3'; DatabaseName = 'tenant3' } + ) + + $result = Invoke-RequiredDisabledTestExecution -Parameters @{ containerName = 'c' } ` + -WorkItems @($workItem) -TenantInfo $tenantInfo -TemplateDatabaseName 'template' ` + -ScriptPath 'runner.ps1' -TestType 'UnitTest' + + $result | Should -BeTrue + $script:dispatchTenants | Should -Be @('tenant2', 'tenant2') + } + } + + It "finishes all tenant resets before dispatching a clean-codeunit batch" { + InModuleScope ParallelTestExecution { + $script:events = [System.Collections.Generic.List[string]]::new() + Mock Reset-BcTestTenant { + $script:events.Add("reset:$Tenant") + } + Mock Start-RequiredDisabledDispatch { + $script:events.Add("dispatch:$($TenantInfo.Id)") + } + Mock Wait-ForAllTestJobs { } + + $workItems = @( + [PSCustomObject]@{ Key = 'Tests::500'; AppName = 'Tests'; AppId = 'id'; CodeunitId = '500'; CodeunitName = 'A' } + [PSCustomObject]@{ Key = 'Tests::501'; AppName = 'Tests'; AppId = 'id'; CodeunitId = '501'; CodeunitName = 'B' } + ) + $tenantInfo = @( + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + [PSCustomObject]@{ Id = 'tenant3'; DatabaseName = 'tenant3' } + ) + + Invoke-RequiredDisabledTestExecution -Parameters @{ containerName = 'c' } ` + -WorkItems $workItems -TenantInfo $tenantInfo -TemplateDatabaseName 'template' ` + -ScriptPath 'runner.ps1' -TestType 'UnitTest' | Should -BeTrue + + $script:events | Should -Be @('reset:tenant2', 'reset:tenant3', 'dispatch:tenant2', 'dispatch:tenant3') + } + } + + It "preserves configured Task Scheduler state during clean and ordinary execution" -ForEach @( + @{ Enabled = $false } + @{ Enabled = $true } + ) { + InModuleScope ParallelTestExecution -Parameters @{ Enabled = $Enabled } { + param($Enabled) + + Mock Get-AvailableBcTenantInfo { + @( + [PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' } + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + ) + } + Mock Get-BcContainerAppInfo { + @([PSCustomObject]@{ IsInstalled = $true; Name = 'Tests'; AppId = 'tests-id' }) + } + Mock Get-RequiredDisabledWorkItems { + @([PSCustomObject]@{ + Key = 'Tests::500' + AppName = 'Tests' + AppId = 'tests-id' + CodeunitId = '500' + CodeunitName = 'API E2E' + TestCount = 2 + }) + } + $script:taskSchedulerEnabled = $Enabled + $script:expectedTaskSchedulerEnabled = $Enabled + Mock New-BcTestTenantTemplate { 'default-test-template' } + Mock Wait-ForFreeTenant { 'default' } + Mock Start-TestAppDispatch { + $script:taskSchedulerEnabled | Should -Be $script:expectedTaskSchedulerEnabled + } + Mock Wait-ForAllTestJobs { $true } + Mock Invoke-RequiredDisabledTestExecution { + $script:taskSchedulerEnabled | Should -Be $script:expectedTaskSchedulerEnabled + $true + } + Mock Reset-BcTestTenant { + $script:taskSchedulerEnabled | Should -Be $script:expectedTaskSchedulerEnabled + } + Mock Remove-BcTestTenantTemplate { } + Mock Merge-TenantTestResults { } + + $result = Invoke-ParallelTestExecution -parameters @{ + containerName = "ut-$([guid]::NewGuid().ToString('N'))" + tenant = 'default' + } -scriptPath 'unused.ps1' -testType 'IntegrationTest' -appNamesToTest @('Tests') + + $result | Should -BeTrue + Should -Invoke Get-RequiredDisabledWorkItems -Times 1 -Exactly -ParameterFilter { + $AppNamesToTest.Count -eq 1 -and $AppNamesToTest[0] -eq 'Tests' + } + Should -Invoke New-BcTestTenantTemplate -Times 1 -ParameterFilter { + $SourceDatabaseName -eq 'default' + } + Should -Invoke Invoke-ScriptInBcContainer -Times 0 + Should -Invoke Reset-BcTestTenant -Times 1 -Exactly -ParameterFilter { + $Tenant -eq 'tenant2' -and + $TenantDatabaseName -eq 'tenant2' -and + $TemplateDatabaseName -eq 'default' + } + Should -Invoke Reset-BcTestTenant -Times 1 -ParameterFilter { + $Tenant -eq 'tenant2' -and + $TenantDatabaseName -eq 'tenant2' -and + $TemplateDatabaseName -eq 'default-test-template' + } + Should -Invoke Invoke-RequiredDisabledTestExecution -Times 1 -ParameterFilter { + $TemplateDatabaseName -eq 'default-test-template' -and + $WorkItems.Count -eq 1 -and + $TenantInfo.Count -eq 1 -and + $TenantInfo[0].Id -eq 'tenant2' + } + Should -Invoke Remove-BcTestTenantTemplate -Times 1 -ParameterFilter { + $TemplateDatabaseName -eq 'default-test-template' + } + } + } + + Context "discovery fixture preservation" { + BeforeEach { + InModuleScope ParallelTestExecution { + $script:fixtureEvents = [System.Collections.Generic.List[string]]::new() + $script:fixtureTenants = @( + [PSCustomObject]@{ Id = 'worker-a'; DatabaseName = 'worker-a-db' } + [PSCustomObject]@{ Id = 'primary'; DatabaseName = 'primary-db' } + [PSCustomObject]@{ Id = 'worker-b'; DatabaseName = 'worker-b-db' } + ) + $script:fixtures = @{ + 'primary-db' = 'pristine' + 'worker-a-db' = 'pristine' + 'worker-b-db' = 'pristine' + } + $script:fixtureParameters = @{ + containerName = "ut-$([guid]::NewGuid().ToString('N'))" + tenant = 'primary' + extensionId = 'caller-app-id' + testSuite = 'CALLER' + disabledTests = @([PSCustomObject]@{ codeunitId = 999; method = 'Excluded' }) + } + $script:originalFixtureParameters = $script:fixtureParameters | ConvertTo-Json -Depth 5 + $script:fixtureWorkItems = @([PSCustomObject]@{ + Key = 'Tests::500'; AppName = 'Tests'; AppId = 'tests-id' + CodeunitId = '500'; CodeunitName = 'API E2E'; TestCount = 2 + }) + $script:discoveryThrows = $false + $script:cleanThrows = $false + $script:cleanPassed = $true + Mock Get-AvailableBcTenantInfo { $script:fixtureTenants } + Mock Get-BcContainerAppInfo { + @([PSCustomObject]@{ IsInstalled = $true; Name = 'Tests'; AppId = 'tests-id' }) + } + Mock Get-RequiredDisabledWorkItems { + $script:fixtureEvents.Add("discover:$($Parameters.tenant)") + [object]::ReferenceEquals($Parameters, $script:fixtureParameters) | Should -BeFalse + $expected = $script:fixtureParameters.Clone() + $expected.tenant = 'worker-a' + ($Parameters | ConvertTo-Json -Depth 5) | Should -Be ($expected | ConvertTo-Json -Depth 5) + $discoveryDatabase = ($script:fixtureTenants | + Where-Object Id -eq $Parameters.tenant).DatabaseName + $script:fixtures[$discoveryDatabase] = 'discovery-dirty' + if ($script:discoveryThrows) { + throw 'discovery failed' + } + $script:fixtureWorkItems + } + Mock Reset-BcTestTenant { + $Tenant | Should -Not -Be 'primary' + $TenantDatabaseName | Should -Not -Be 'primary-db' + $script:fixtureEvents.Add("restore:${Tenant}:$TemplateDatabaseName") + $script:fixtures[$TenantDatabaseName] = $script:fixtures[$TemplateDatabaseName] + } + Mock New-BcTestTenantTemplate { + $SourceDatabaseName | Should -Be 'primary-db' + $script:fixtures['primary-db'] | Should -Be 'pristine' + $script:fixtures['worker-a-db'] | Should -Be 'pristine' + $script:fixtureEvents.Add("template:$SourceDatabaseName") + $script:fixtures['template-db'] = $script:fixtures[$SourceDatabaseName] + 'template-db' + } + Mock Invoke-RequiredDisabledTestExecution { + $script:fixtureEvents.Add('clean') + $script:fixtures['primary-db'] | Should -Be 'pristine' + foreach ($worker in $TenantInfo) { + $script:fixtures[$worker.DatabaseName] = 'clean-dirty' + } + if ($script:cleanThrows) { + throw 'clean execution failed' + } + $script:cleanPassed + } + Mock Invoke-WarmupDispatch { + $script:fixtureEvents.Add('warmup') + @($Pending) + } + Mock Get-AppRerunBudget { 0 } + Mock Wait-ForFreeTenant { 'primary' } + Mock Start-TestAppDispatch { + $script:fixtureEvents.Add("ordinary:$Tenant") + foreach ($info in $script:fixtureTenants) { + $script:fixtures[$info.DatabaseName] | Should -Be 'pristine' + } + } + Mock Wait-ForAllTestJobs { $true } + Mock Merge-TenantTestResults { } + Mock Remove-BcTestTenantTemplate { + $script:fixtureEvents.Add('remove-template') + } + } + } + + It "preserves ordinary execution when clean-codeunit activation is " -ForEach @( + @{ Lane = 'IntegrationTest'; Activation = 'absent'; Enabled = $null } + @{ Lane = 'IntegrationTest'; Activation = 'false'; Enabled = $false } + @{ Lane = 'UnitTest'; Activation = 'absent'; Enabled = $null } + @{ Lane = 'UnitTest'; Activation = 'false'; Enabled = $false } + @{ Lane = 'Legacy'; Activation = 'absent'; Enabled = $null } + @{ Lane = 'Legacy'; Activation = 'false'; Enabled = $false } + ) { + InModuleScope ParallelTestExecution -Parameters @{ Lane = $Lane; Enabled = $Enabled } { + $script:activationUnderTest = $Enabled + Mock Get-ALGoSetting { $script:activationUnderTest } -ParameterFilter { + $Key -eq 'enableCleanTestCodeunitExecution' + } + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType $Lane -appNamesToTest @('Tests') | Should -BeTrue + + $script:fixtureEvents | Should -Be @('warmup', 'ordinary:primary') + Should -Invoke Get-RequiredDisabledWorkItems -Times 0 + Should -Invoke Reset-BcTestTenant -Times 0 + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Invoke-RequiredDisabledTestExecution -Times 0 + Should -Invoke Invoke-WarmupDispatch -Times 1 -Exactly -ParameterFilter { + $CleanTenantAppNames.Count -eq 0 + } + Should -Invoke Start-TestAppDispatch -Times 1 -Exactly -ParameterFilter { + -not $SkipAutomaticDisabledPass + } + ($script:fixtureParameters | ConvertTo-Json -Depth 5) | Should -Be $script:originalFixtureParameters + } + } + + It "restores secondary discovery before copying the pristine primary and dispatching tests" { + InModuleScope ParallelTestExecution { + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'UnitTest' -appNamesToTest @('Tests') | Should -BeTrue + + $script:fixtureEvents | Should -Be @( + 'discover:worker-a', 'restore:worker-a:primary-db', 'template:primary-db', 'clean', + 'restore:worker-a:template-db', 'restore:worker-b:template-db', + 'warmup', 'ordinary:primary', 'remove-template' + ) + $script:fixtures['template-db'] | Should -Be 'pristine' + ($script:fixtureParameters | ConvertTo-Json -Depth 5) | Should -Be $script:originalFixtureParameters + Should -Invoke Get-RequiredDisabledWorkItems -Times 1 -Exactly -ParameterFilter { + $Parameters.tenant -eq 'worker-a' -and $TestType -eq 'UnitTest' -and + $AppNamesToTest.Count -eq 1 -and $AppIdByName.Tests -eq 'tests-id' + } + Should -Invoke Invoke-WarmupDispatch -Times 1 -Exactly -ParameterFilter { + $CleanTenantAppNames.Count -eq 1 -and $CleanTenantAppNames[0] -eq 'Tests' + } + Should -Invoke Start-TestAppDispatch -Times 1 -Exactly -ParameterFilter { + $SkipAutomaticDisabledPass + } + Should -Invoke Invoke-ScriptInBcContainer -Times 0 + } + } + + It "restores discovery even when there are no clean work items without creating a template" { + InModuleScope ParallelTestExecution { + $script:fixtureWorkItems = @() + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') | Should -BeTrue + + $script:fixtureEvents | Should -Be @( + 'discover:worker-a', 'restore:worker-a:primary-db', 'warmup', 'ordinary:primary' + ) + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Invoke-RequiredDisabledTestExecution -Times 0 + ($script:fixtureParameters | ConvertTo-Json -Depth 5) | Should -Be $script:originalFixtureParameters + } + } + + It "restores discovery after an exception without copying or dispatching tainted fixtures" { + InModuleScope ParallelTestExecution { + $script:discoveryThrows = $true + { + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') + } | Should -Throw '*discovery failed*' + + $script:fixtureEvents | Should -Be @('discover:worker-a', 'restore:worker-a:primary-db') + $script:fixtures['primary-db'] | Should -Be 'pristine' + $script:fixtures['worker-a-db'] | Should -Be 'pristine' + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Invoke-RequiredDisabledTestExecution -Times 0 + Should -Invoke Invoke-WarmupDispatch -Times 0 + ($script:fixtureParameters | ConvertTo-Json -Depth 5) | Should -Be $script:originalFixtureParameters + } + } + + It "stops before template creation or dispatch when restoring discovery fails" { + InModuleScope ParallelTestExecution { + Mock Reset-BcTestTenant { throw 'discovery restore failed' } + { + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') + } | Should -Throw '*discovery restore failed*' + + Should -Invoke Reset-BcTestTenant -Times 1 -Exactly + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Invoke-RequiredDisabledTestExecution -Times 0 + Should -Invoke Invoke-WarmupDispatch -Times 0 + ($script:fixtureParameters | ConvertTo-Json -Depth 5) | Should -Be $script:originalFixtureParameters + } + } + + It "restores every clean worker and removes the template when clean execution throws" { + InModuleScope ParallelTestExecution { + $script:cleanThrows = $true + { + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') + } | Should -Throw '*clean execution failed*' + + $script:fixtureEvents | Should -Be @( + 'discover:worker-a', 'restore:worker-a:primary-db', 'template:primary-db', 'clean', + 'restore:worker-a:template-db', 'restore:worker-b:template-db', 'remove-template' + ) + $script:fixtures['worker-a-db'] | Should -Be 'pristine' + $script:fixtures['worker-b-db'] | Should -Be 'pristine' + Should -Invoke Invoke-WarmupDispatch -Times 0 + Should -Invoke Invoke-ScriptInBcContainer -Times 0 + } + } + + It "restores every clean worker while retaining a failed test result" { + InModuleScope ParallelTestExecution { + $script:cleanPassed = $false + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') | Should -BeFalse + + $script:fixtures['worker-a-db'] | Should -Be 'pristine' + $script:fixtures['worker-b-db'] | Should -Be 'pristine' + $script:fixtureEvents.IndexOf('ordinary:primary') | + Should -BeGreaterThan $script:fixtureEvents.IndexOf('restore:worker-b:template-db') + Should -Invoke Remove-BcTestTenantTemplate -Times 1 -Exactly + } + } + + It "keeps Legacy discovery on the original parameters without copying or resetting tenants" { + InModuleScope ParallelTestExecution { + Mock Get-RequiredDisabledWorkItems { + [object]::ReferenceEquals($Parameters, $script:fixtureParameters) | Should -BeTrue + $TestType | Should -Be 'Legacy' + @() + } + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'Legacy' -appNamesToTest @('Tests') | Should -BeTrue + + Should -Invoke Get-RequiredDisabledWorkItems -Times 1 -Exactly + Should -Invoke Reset-BcTestTenant -Times 0 + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Invoke-RequiredDisabledTestExecution -Times 0 + ($script:fixtureParameters | ConvertTo-Json -Depth 5) | Should -Be $script:originalFixtureParameters + } + } + + It "preserves single-tenant discovery and ordinary execution without clean work items" { + InModuleScope ParallelTestExecution { + $script:fixtureTenants = @($script:fixtureTenants | Where-Object Id -eq 'primary') + Mock Get-RequiredDisabledWorkItems { + [object]::ReferenceEquals($Parameters, $script:fixtureParameters) | Should -BeTrue + @() + } + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') | Should -BeTrue + + Should -Invoke Get-RequiredDisabledWorkItems -Times 1 -Exactly + Should -Invoke Reset-BcTestTenant -Times 0 + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Start-TestAppDispatch -Times 1 -Exactly + } + } + + It "retains the single-tenant error when clean work items are discovered" { + InModuleScope ParallelTestExecution { + $script:fixtureTenants = @($script:fixtureTenants | Where-Object Id -eq 'primary') + Mock Get-RequiredDisabledWorkItems { $script:fixtureWorkItems } + { + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') + } | Should -Throw '*at least one secondary tenant*' + + Should -Invoke Reset-BcTestTenant -Times 0 + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Invoke-WarmupDispatch -Times 0 + } + } + + It "rejects unsafe tenant metadata before discovery: " -ForEach @( + @{ Fault = 'missing primary'; MessagePattern = '*source tenant*' } + @{ Fault = 'blank primary database'; MessagePattern = '*source tenant*' } + @{ Fault = 'blank worker ID'; MessagePattern = '*unique tenant IDs and database names*' } + @{ Fault = 'blank worker database'; MessagePattern = '*unique tenant IDs and database names*' } + @{ Fault = 'duplicate worker ID'; MessagePattern = '*unique tenant IDs and database names*' } + @{ Fault = 'duplicate worker database'; MessagePattern = '*unique tenant IDs and database names*' } + @{ Fault = 'worker aliases primary database'; MessagePattern = '*unique tenant IDs and database names*' } + @{ Fault = 'duplicate primary ID'; MessagePattern = '*source tenant*' } + ) { + InModuleScope ParallelTestExecution -Parameters @{ Fault = $Fault; ExpectedError = $MessagePattern } { + param($Fault, $ExpectedError) + + switch ($Fault) { + 'missing primary' { $script:fixtureParameters.tenant = 'missing' } + 'blank primary database' { $script:fixtureTenants[1].DatabaseName = ' ' } + 'blank worker ID' { $script:fixtureTenants[0].Id = ' ' } + 'blank worker database' { $script:fixtureTenants[0].DatabaseName = ' ' } + 'duplicate worker ID' { $script:fixtureTenants[2].Id = 'WORKER-A' } + 'duplicate worker database' { $script:fixtureTenants[2].DatabaseName = 'WORKER-A-DB' } + 'worker aliases primary database' { $script:fixtureTenants[0].DatabaseName = 'PRIMARY-DB' } + 'duplicate primary ID' { $script:fixtureTenants[2].Id = 'PRIMARY' } + } + { + Invoke-ParallelTestExecution -parameters $script:fixtureParameters -scriptPath 'unused.ps1' ` + -testType 'IntegrationTest' -appNamesToTest @('Tests') + } | Should -Throw $ExpectedError + + Should -Invoke Get-RequiredDisabledWorkItems -Times 0 + Should -Invoke Reset-BcTestTenant -Times 0 + Should -Invoke New-BcTestTenantTemplate -Times 0 + Should -Invoke Invoke-WarmupDispatch -Times 0 + Should -Invoke Invoke-ScriptInBcContainer -Times 0 + } + } + } + + It "contains no Task Scheduler override or server restart commands" { + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Get-Module ParallelTestExecution).Path, [ref]$tokens, [ref]$parseErrors) + $parseErrors.Count | Should -Be 0 + $overrides = @($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -in @( + 'Set-NAVServerConfiguration', 'Set-NAVServerInstance', + 'Set-BcTestTaskScheduler', 'Enable-BcTestTaskScheduler', 'Disable-BcTestTaskScheduler' + ) + }, $true)) + $overrides.Count | Should -Be 0 + } +} + +Describe "RunTestsInBcContainer platform-race transcript classification" { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force + function Run-TestsInBcContainer { + param([string]$appName, [string]$tenant) + $null = $appName, $tenant + throw 'Container test execution must be mocked.' + } + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PSScriptRoot '../RunTestsInBcContainer.ps1'), [ref]$tokens, [ref]$parseErrors) + $parseErrors.Count | Should -Be 0 + $function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Invoke-RunTestsWithCancellationDetection' + }, $true) + . ([scriptblock]::Create($function.Extent.Text)) + } + + BeforeEach { + $script:transcriptPath = $null + Mock Start-Transcript { + $script:transcriptPath = $Path + Set-Content -LiteralPath $Path -Value $script:transcript + } + Mock Stop-Transcript {} + Mock Run-TestsInBcContainer { $false } + Mock Write-Host {} + } + + AfterEach { + $script:transcriptPath | Should -Not -BeNullOrEmpty + Test-Path -LiteralPath $script:transcriptPath | Should -BeFalse + } + + It "does not emit a platform marker for an ordinary API failure or unrelated page mention" -ForEach @( + @{ Transcript = 'GET request failed. Response code is 500 (InternalServerError), expected code is 200. Error message: Object reference not set to an instance of an object.' } + @{ Transcript = "Opened page 130455 successfully.`nGET request failed. Response code is 500 (InternalServerError), expected code is 200. Error message: Object reference not set to an instance of an object." } + @{ Transcript = 'Assertion failed: expected 2 but was 1' } + ) { + $script:transcript = $Transcript + Invoke-RunTestsWithCancellationDetection -parameters @{ appName = 'API Tests'; tenant = 'default' } | + Should -BeFalse + Should -Invoke Write-Host -Times 0 -ParameterFilter { $Object -like '*TRANSIENT TEST PLATFORM RACE*' } + } + + It "emits the platform marker for established runtime races" -ForEach @( + @{ Transcript = 'Cannot open page 130455' } + @{ Transcript = 'InvokeInteractions failed with status code 500' } + @{ Transcript = 'at InteractionManager.InvokeInteractions in InteractionManager.cs:line 203' } + @{ Transcript = "ObjName:Command Line Test Tool, ObjID:130455, Type:Form, MethodName:ExtensionId_a45_OnValidate`nOffset and length were out of bounds for the array" } + @{ Transcript = "ObjName:Command Line Test Tool, ObjID:130455, Type:Form, MethodName:ExtensionId_a45_OnValidate`nNullable object must have a value." } + ) { + $script:transcript = $Transcript + Invoke-RunTestsWithCancellationDetection -parameters @{ appName = 'API Tests'; tenant = 'default' } | + Should -BeFalse + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like '*TRANSIENT TEST PLATFORM RACE*' } + } +} + Describe "ParallelTestExecution warmup dispatch" { BeforeAll { Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force } + It "defers the Disabled pass when the warmup app uses clean-codeunit execution" { + InModuleScope ParallelTestExecution { + $script:skipDisabledPass = $false + Mock Start-TestAppDispatch { + $script:skipDisabledPass = $SkipAutomaticDisabledPass.IsPresent + } + Mock Wait-ForAllTestJobs { } + + $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } + $result = Invoke-WarmupDispatch -Parameters @{ containerName = 'c' } ` + -Pending @('API Tests', 'Other Tests') ` + -AppIdByName @{ 'API Tests' = 'api-id'; 'Other Tests' = 'other-id' } ` + -Tenants @('default', 'tenant2') -ScriptPath 'unused.ps1' -TestType 'UnitTest' ` + -State $state -CleanTenantAppNames @('API Tests') + + $result | Should -Be @('Other Tests') + $script:skipDisabledPass | Should -BeTrue + } + } + It "dispatches the first app alone and awaits it before fanning out the rest" { # The first app must run alone and be awaited before any parallel dispatch. This asserts # exactly that ordering: dispatch(first) -> wait -> rest. InModuleScope ParallelTestExecution { $script:events = [System.Collections.Generic.List[string]]::new() - Mock Get-AvailableBcTenants { @('default', 'tenant2') } + Mock Get-AvailableBcTenantInfo { + @( + [PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' } + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + ) + } Mock Get-BcContainerAppInfo { @('Big', 'Medium', 'Small') | ForEach-Object { [PSCustomObject]@{ IsInstalled = $true; Name = $_; AppId = "id-$_" } } } Mock Wait-ForFreeTenant { 'tenant2' } + Mock Get-RequiredDisabledWorkItems { @() } Mock Merge-TenantTestResults { } Mock Start-TestAppDispatch { $script:events.Add("dispatch:$AppName") } Mock Wait-ForAllTestJobs { $script:events.Add('wait'); $true } @@ -179,7 +1280,9 @@ Describe "ParallelTestExecution warmup dispatch" { InModuleScope ParallelTestExecution { $script:events = [System.Collections.Generic.List[string]]::new() - Mock Get-AvailableBcTenants { @('default') } + Mock Get-AvailableBcTenantInfo { + @([PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' }) + } Mock Get-BcContainerAppInfo { @('Big', 'Medium') | ForEach-Object { [PSCustomObject]@{ IsInstalled = $true; Name = $_; AppId = "id-$_" } @@ -187,6 +1290,7 @@ Describe "ParallelTestExecution warmup dispatch" { } Mock Wait-ForFreeTenant { 'default' } Mock Merge-TenantTestResults { } + Mock Get-RequiredDisabledWorkItems { @() } Mock Start-TestAppDispatch { $script:events.Add("dispatch:$AppName") } Mock Wait-ForAllTestJobs { $script:events.Add('wait'); $true } @@ -242,13 +1346,15 @@ Describe "ParallelTestExecution warmup dispatch" { InModuleScope ParallelTestExecution { Mock Start-TestAppDispatch { } # Simulate Wait-ForAllTestJobs classifying the warmup app as a transient race. - Mock Wait-ForAllTestJobs { $State.transient = @('A'); $true } + Mock Wait-ForAllTestJobs { + $State.transient = @([PSCustomObject]@{ Key = 'A'; Tenant = 'default' }) + } $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } $result = Invoke-WarmupDispatch -Parameters @{ containerName = 'c' } ` -Pending @('A', 'B', 'C') -AppIdByName @{ A = 'id-A'; B = 'id-B'; C = 'id-C' } ` -Tenants @('default', 'tenant2') -ScriptPath 'unused.ps1' -TestType 'Legacy' -State $state $result | Should -Be @('B', 'C') - $state.transient | Should -Contain 'A' + $state.transient.Key | Should -Contain 'A' $state.hasFailures | Should -BeFalse } } @@ -259,20 +1365,35 @@ Describe "ParallelTestExecution failed-app rerun scheduling" { Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force } - It "re-runs a failed app on a different tenant than the one it failed on" { + It "re-runs a failed app on a different tenant with clean activation " -ForEach @( + @{ Lane = 'Legacy'; Enabled = $false } + @{ Lane = 'UnitTest'; Enabled = $false } + @{ Lane = 'UnitTest'; Enabled = $true } + ) { # A failed app is retried once, and never on the tenant it failed on: tests are not # guaranteed to clean up after themselves, so residue from the failed run could # re-trigger the same failure and make the retry worthless. - InModuleScope ParallelTestExecution { + InModuleScope ParallelTestExecution -Parameters @{ Lane = $Lane; Enabled = $Enabled } { + $script:activationUnderTest = $Enabled + Mock Get-ALGoSetting { $script:activationUnderTest } -ParameterFilter { + $Key -eq 'enableCleanTestCodeunitExecution' + } + Mock Reset-BcTestTenant { } $script:dispatched = [System.Collections.Generic.List[object]]::new() $script:failed = $false - Mock Get-AvailableBcTenants { @('default', 'tenant2') } + Mock Get-AvailableBcTenantInfo { + @( + [PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' } + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + ) + } Mock Get-BcContainerAppInfo { @('Big', 'Small') | ForEach-Object { [PSCustomObject]@{ IsInstalled = $true; Name = $_; AppId = "id-$_" } } } + Mock Get-RequiredDisabledWorkItems { @() } Mock Invoke-WarmupDispatch { @($Pending) } Mock Get-AppRerunBudget { 1 } Mock Wait-ForAllTestJobs { $true } @@ -282,7 +1403,12 @@ Describe "ParallelTestExecution failed-app rerun scheduling" { @('default', 'tenant2') | Where-Object { $_ -ne $excludeTenant } | Select-Object -First 1 } Mock Start-TestAppDispatch { - $script:dispatched.Add([PSCustomObject]@{ App = $AppName; Tenant = $Tenant; Suffix = $FileSuffix }) + $script:dispatched.Add([PSCustomObject]@{ + App = $AppName + Tenant = $Tenant + Suffix = $FileSuffix + SkipDisabled = $SkipAutomaticDisabledPass.IsPresent + }) # 'Big' fails on its first dispatch, exactly once. if ($AppName -eq 'Big' -and -not $script:failed) { $script:failed = $true @@ -294,12 +1420,13 @@ Describe "ParallelTestExecution failed-app rerun scheduling" { $params = @{ containerName = "ut-$([guid]::NewGuid().ToString('N'))"; tenant = 'default' } $result = Invoke-ParallelTestExecution -parameters $params -scriptPath 'unused.ps1' ` - -testType 'Legacy' -appNamesToTest @('Big', 'Small') + -testType $Lane -appNamesToTest @('Big', 'Small') $rerun = $script:dispatched | Where-Object { $_.Suffix } $rerun.App | Should -Be 'Big' $rerun.Tenant | Should -Be 'tenant2' $rerun.Suffix | Should -Be 'rerun1' + $rerun.SkipDisabled | Should -Be $Enabled # The rerun passed, so the run as a whole passed. $result | Should -BeTrue } @@ -454,7 +1581,12 @@ Describe "ParallelTestExecution rerun budget is limited to pull request builds" Set-Content -Path $staleFile -Value '' $script:raced = $false - Mock Get-AvailableBcTenants { @('default', 'tenant2') } + Mock Get-AvailableBcTenantInfo { + @( + [PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' } + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + ) + } Mock Get-BcContainerAppInfo { @([PSCustomObject]@{ IsInstalled = $true; Name = 'A'; AppId = 'id-A' }) } Mock Invoke-WarmupDispatch { @($Pending) } Mock Wait-ForAllTestJobs { } @@ -464,7 +1596,9 @@ Describe "ParallelTestExecution rerun budget is limited to pull request builds" if ($FileSuffix -and -not $script:raced) { # The rerun job hits the platform race. $script:raced = $true - $State.transient = @($State.transient) + @($AppName) + $State.transient = @($State.transient) + @( + [PSCustomObject]@{ Key = $AppName; Tenant = $Tenant } + ) } } # Pre-seed the state as though 'A' already had its rerun dispatched. @@ -475,10 +1609,11 @@ Describe "ParallelTestExecution rerun budget is limited to pull request builds" rerun = @(); rerunDone = @{}; rerunBudget = 1; tenantCount = 2 } $state.rerunDone['A'] = 'rerun1' - $state.transient = @('A') + $state.transient = @([PSCustomObject]@{ Key = 'A'; Tenant = 'tenant2' }) # Exercise just the promotion path the loop performs. - foreach ($appName in @($state.transient)) { + foreach ($transient in @($state.transient)) { + $appName = $transient.Key if ($state.rerunDone.ContainsKey($appName)) { Remove-RerunResultFile -parameters $params -suffix $state.rerunDone[$appName] } @@ -518,7 +1653,12 @@ Describe "ParallelTestExecution rerun budget is limited to pull request builds" $script:dispatched = [System.Collections.Generic.List[object]]::new() $script:failed = $false - Mock Get-AvailableBcTenants { @('default', 'tenant2') } + Mock Get-AvailableBcTenantInfo { + @( + [PSCustomObject]@{ Id = 'default'; DatabaseName = 'default' } + [PSCustomObject]@{ Id = 'tenant2'; DatabaseName = 'tenant2' } + ) + } Mock Get-BcContainerAppInfo { @('Big', 'Small') | ForEach-Object { [PSCustomObject]@{ IsInstalled = $true; Name = $_; AppId = "id-$_" } @@ -526,6 +1666,7 @@ Describe "ParallelTestExecution rerun budget is limited to pull request builds" } Mock Invoke-WarmupDispatch { @($Pending) } Mock Wait-ForAllTestJobs { $true } + Mock Get-RequiredDisabledWorkItems { @() } Mock Merge-TenantTestResults { } Mock Wait-ForFreeTenant { 'default' } Mock Start-TestAppDispatch { diff --git a/build/scripts/tests/Remove-ApiTestPassword.Test.ps1 b/build/scripts/tests/Remove-ApiTestPassword.Test.ps1 new file mode 100644 index 00000000000..72eab0f130c --- /dev/null +++ b/build/scripts/tests/Remove-ApiTestPassword.Test.ps1 @@ -0,0 +1,223 @@ +Describe 'API test credential cleanup' { + BeforeAll { + $script:cleanupScript = Join-Path $PSScriptRoot '..\Remove-ApiTestPassword.ps1' + $script:passwordPath = Join-Path $PSScriptRoot 'unused\ApiTestPassword' + $script:previousExitCode = Get-Variable -Name LASTEXITCODE -Scope Global -ValueOnly -ErrorAction SilentlyContinue + function docker { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) + $null = $Arguments + throw 'Docker must be mocked; these tests never contact a container.' + } + } + + AfterAll { + $global:LASTEXITCODE = $script:previousExitCode + } + + BeforeEach { + $script:events = [System.Collections.Generic.List[string]]::new() + Mock Test-Path { $true } + Mock docker { + $global:LASTEXITCODE = 0 + if ($Arguments[0] -eq 'container') { + 'test-container-id' + } + else { + $script:events.Add('stop') + } + } + Mock Remove-Item { $script:events.Add('delete') } + } + + It 'stops all consumers before deleting the exact backing file' { + . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath + + ($script:events -join ',') | Should -Be 'stop,delete' + Should -Invoke docker -Times 1 -Exactly -ParameterFilter { + $Arguments[0] -eq 'container' -and $Arguments -contains '--all' -and + $Arguments -contains 'name=^/test-container$' + } + Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { + $LiteralPath -eq $script:passwordPath -and $Force -and $ErrorAction -eq 'Stop' + } + } + + It 'does nothing when the password file is missing' { + Mock Test-Path { $false } + + . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath + + Should -Invoke docker -Times 0 + Should -Invoke Remove-Item -Times 0 + } + + It 'removes a leftover backing file when the container is already absent' { + Mock docker { $global:LASTEXITCODE = 0 } + + . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath + + ($script:events -join ',') | Should -Be 'delete' + Should -Invoke docker -Times 0 -ParameterFilter { $Arguments[0] -eq 'stop' } + } + + It 'also stops an already-stopped container idempotently before deletion' { + . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath + + Should -Invoke docker -Times 1 -Exactly -ParameterFilter { + ($Arguments -join ' ') -eq 'stop --time 30 test-container-id' + } + Should -Invoke Remove-Item -Times 1 -Exactly + } + + It 'fails without deleting when Docker cannot enumerate containers' { + Mock docker { $global:LASTEXITCODE = 1 } + + { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | + Should -Throw '*Could not determine*' + Should -Invoke Remove-Item -Times 0 + } + + It 'fails without deleting while consumers cannot be stopped' { + Mock docker { $global:LASTEXITCODE = 1 } -ParameterFilter { $Arguments[0] -eq 'stop' } + + { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | + Should -Throw '*Could not stop API test consumers*' + Should -Invoke Remove-Item -Times 0 + } + + It 'fails without stopping or deleting if container identity is ambiguous' { + Mock docker { + $global:LASTEXITCODE = 0 + 'first-id', 'second-id' + } + + { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | + Should -Throw '*more than one container*' + Should -Invoke docker -Times 0 -ParameterFilter { $Arguments[0] -eq 'stop' } + Should -Invoke Remove-Item -Times 0 + } + + It 'surfaces deletion failure rather than reporting successful cleanup' { + Mock Remove-Item { throw 'Access denied' } + + { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | + Should -Throw '*Access denied*' + } + + It 'rejects a path that does not name the credential file' { + { . $script:cleanupScript -ContainerName 'test-container' -FilePath (Join-Path $TestDrive 'other-file') } | + Should -Throw '*exact ApiTestPassword file*' + Should -Invoke docker -Times 0 + Should -Invoke Remove-Item -Times 0 + } +} + +Describe 'API test credential workflow lifetime' { + BeforeAll { + $script:workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\..\.github\workflows\_BuildALGoProject.yaml') -Raw + $script:setup = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\NewBcContainer.ps1') -Raw + } + + It 'delegates credential materialization without host or container staging copies' { + $script:setup | Should -Match "Import-Module .*'ApiTestCredential.psm1'" + $script:setup | Should -Match 'Write-ApiTestPassword -ContainerName \$parameters.ContainerName -Credential \$parameters.credential' + $script:setup | Should -Not -Match 'Copy-FileToBcContainer|WriteAllText|GetNetworkCredential|GetTempPath' + } + + It 'leaves generated workflow cleanup unmodified' { + $script:workflow | Should -Not -Match '- name: Remove API test credential|Remove-ApiTestPassword.ps1' + $script:workflow | Should -Match '(?s)- name: Cleanup\r?\n\s+if: always\(\).*?uses: microsoft/AL-Go/Actions/PipelineCleanup@' + } +} + +Describe 'API test credential pipeline finalizer' { + BeforeAll { + $script:finalizer = Join-Path $PSScriptRoot '..\PipelineFinalize.ps1' + $script:previousPasswordPath = $env:BCAppsApiTestPasswordPath + $script:previousPasswordContainer = $env:BCAppsApiTestPasswordContainer + $script:previousExitCode = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction SilentlyContinue + function docker { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) + $null = $Arguments + throw 'Docker must be mocked.' + } + } + + BeforeEach { + $env:BCAppsApiTestPasswordPath = Join-Path $TestDrive 'ApiTestPassword' + $env:BCAppsApiTestPasswordContainer = 'recorded-container' + Mock Test-Path { $true } + Mock docker { + $global:LASTEXITCODE = 0 + if ($Arguments[0] -eq 'container') { 'recorded-container-id' } + } + Mock Remove-Item { + Should -Invoke docker -Times 1 -ParameterFilter { $Arguments[0] -eq 'stop' } + } + Mock Write-Host {} + } + + AfterAll { + $env:BCAppsApiTestPasswordPath = $script:previousPasswordPath + $env:BCAppsApiTestPasswordContainer = $script:previousPasswordContainer + $global:LASTEXITCODE = $script:previousExitCode + } + + It 'accepts no arguments and stops the recorded consumers before deleting the backing file' { + $path = $env:BCAppsApiTestPasswordPath + . $script:finalizer + Should -Invoke docker -Times 1 -Exactly -ParameterFilter { + $Arguments -contains 'name=^/recorded-container$' + } + Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq $path } + $env:BCAppsApiTestPasswordPath | Should -BeNullOrEmpty + $env:BCAppsApiTestPasswordContainer | Should -BeNullOrEmpty + } + + It 'does nothing when no credential was provisioned' { + $env:BCAppsApiTestPasswordPath = $null + . $script:finalizer + Should -Invoke docker -Times 0 + Should -Invoke Remove-Item -Times 0 + } + + It 'is idempotent when standard teardown already removed the credential' { + Mock Test-Path { $false } + . $script:finalizer + . $script:finalizer + Should -Invoke docker -Times 0 + Should -Invoke Remove-Item -Times 0 + $env:BCAppsApiTestPasswordPath | Should -BeNullOrEmpty + } + + It 'preserves cleanup identity and propagates a failure without deleting while consumers remain' { + Mock docker { $global:LASTEXITCODE = 1 } -ParameterFilter { $Arguments[0] -eq 'stop' } + { . $script:finalizer } | Should -Throw '*Could not stop API test consumers*' + Should -Invoke Remove-Item -Times 0 + $env:BCAppsApiTestPasswordContainer | Should -Be 'recorded-container' + $env:BCAppsApiTestPasswordPath | Should -Be (Join-Path $TestDrive 'ApiTestPassword') + } + + It 'propagates deletion failure without forgetting the recorded path' { + Mock Remove-Item { throw 'Synthetic deletion failure' } + { . $script:finalizer } | Should -Throw '*Synthetic deletion failure*' + $env:BCAppsApiTestPasswordPath | Should -Be (Join-Path $TestDrive 'ApiTestPassword') + } + + It 'runs every registered project wrapper through the AL-Go scriptblock invocation contract' { + $projects = Join-Path $PSScriptRoot '..\..\projects' + $wrappers = @(Get-ChildItem -LiteralPath $projects -Directory | ForEach-Object { + $setup = Join-Path $_.FullName '.AL-Go\NewBcContainer.ps1' + if ([IO.File]::Exists($setup)) { + Get-Item -LiteralPath (Join-Path $_.FullName '.AL-Go\PipelineFinalize.ps1') + } + }) + $wrappers.Count | Should -BeGreaterThan 0 + foreach ($wrapper in $wrappers) { + $env:BCAppsApiTestPasswordPath = Join-Path $TestDrive 'ApiTestPassword' + $env:BCAppsApiTestPasswordContainer = 'recorded-container' + Invoke-Command -ScriptBlock (Get-Command $wrapper.FullName).ScriptBlock + } + Should -Invoke Remove-Item -Times $wrappers.Count -Exactly + } +}