Modules/businessdev.ALbuild.Containers/Public/Install-BcContainerTestToolkit.ps1
|
function Install-BcContainerTestToolkit { <# .SYNOPSIS Publishes and installs the Business Central test toolkit apps into a container. .DESCRIPTION Discovers the Microsoft test toolkit/framework app packages that ship with the artifact and publishes, synchronises and installs them, in dependency order and idempotently. With -IncludeTestLibrariesOnly only the framework and library apps are installed (not Microsoft's test-content apps). Apps are taken from the **compiled, country-specific** copies that the container produced at setup (the versioned `Microsoft_*_<version>.app` files under `C:\Applications.<country>`, e.g. `C:\Applications.DE`), then `C:\Extensions`, falling back to the source apps under `C:\Applications` only for anything without a compiled copy. Publishing a compiled app does not recompile it, so localized containers such as 'de' work - recompiling the source `Tests-TestLibraries` against a German base app fails on its VAT objects. This mirrors BcContainerHelper's GetTestToolkitApps, which globs `c:\applications.*` for the versioned package and only uses the source app when no compiled copy exists. .PARAMETER Name Container name. .PARAMETER IncludeTestLibrariesOnly Install only the test framework and libraries, not Microsoft's test-content apps. .PARAMETER SearchRoots Additional container folders to search, in preference order, after the auto-discovered compiled country folders (`C:\Applications.*`). Default 'C:\Extensions' then 'C:\Applications'. .PARAMETER ServerInstance BC server instance inside the container. Default 'BC'. .PARAMETER DockerExecutable The Docker executable to use (default 'docker'). #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name, [switch] $IncludeTestLibrariesOnly, [string[]] $SearchRoots = @('C:\Extensions', 'C:\Applications'), [string] $ServerInstance = 'BC', [string] $DockerExecutable = 'docker' ) if (-not $PSCmdlet.ShouldProcess($Name, 'Install test toolkit')) { return } # Name fragments identifying the framework/library apps and (optionally) the test-content apps. # 'Tests-TestLibraries' is the shared test-library app (Library - Random, Library - Lower # Permissions, ...) that ISV tests commonly depend on, so it belongs to the library set. $libraryPatterns = @( 'Any', 'Library Assert', 'Library Variable Storage', 'Test Runner', 'Test Framework', 'System Application Test Library', 'Business Foundation Test Libraries', 'Permissions Mock', 'AI Test Toolkit', 'Tests-TestLibraries' ) $contentPatterns = @('Tests-', 'System Application Test', 'Business Foundation Tests') $patterns = if ($IncludeTestLibrariesOnly) { $libraryPatterns } else { $libraryPatterns + $contentPatterns } Write-ALbuildLog "Installing test toolkit into '$Name' ($(if ($IncludeTestLibrariesOnly) { 'framework + libraries' } else { 'full, incl. Microsoft test content' }))..." $script = { # Match each pattern bounded by non-letters so short tokens like 'Any' match the 'Any' app # but not 'Company Hub'/'German language (Germany)'. The '_' filename separator counts as a # delimiter; the trailing guard is dropped when the pattern ends in a non-letter ('Tests-'). $matchesPattern = { param($fileName) ($fileName -notlike '*.runtime.app') -and ($Patterns | Where-Object { $lead = if ($_ -match '^[A-Za-z]') { '(?<![A-Za-z])' } else { '' } $trail = if ($_ -match '[A-Za-z]$') { '(?![A-Za-z])' } else { '' } $fileName -match ($lead + [regex]::Escape($_) + $trail) }) } # Put the compiled, country-specific folders (C:\Applications.DE, ...) the container produced # at setup at the front of the search order: their versioned apps publish without a recompile # (DE-safe). Then the caller-supplied roots (C:\Extensions, C:\Applications source). # NB: a Windows -Filter of 'Applications.*' also matches the bare 'Applications' folder # (the '.*' matches an empty extension), so match the dotted name explicitly to keep the # source 'C:\Applications' out of the compiled set. $rankedRoots = New-Object System.Collections.Generic.List[string] foreach ($d in (Get-ChildItem -Path 'C:\' -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^Applications\..+' } | Sort-Object Name)) { $rankedRoots.Add($d.FullName) } foreach ($r in $SearchRoots) { if ($rankedRoots -notcontains $r) { $rankedRoots.Add($r) } } $SearchRoots = $rankedRoots.ToArray() # Gather matching .app files from each search root, tagging the preference rank (compiled # country-folder / C:\Extensions apps rank ahead of C:\Applications source apps). $found = New-Object System.Collections.Generic.List[object] for ($rank = 0; $rank -lt $SearchRoots.Count; $rank++) { $root = $SearchRoots[$rank] if (-not (Test-Path -LiteralPath $root)) { continue } foreach ($file in (Get-ChildItem -LiteralPath $root -Filter '*.app' -Recurse -File -ErrorAction SilentlyContinue)) { if (& $matchesPattern $file.Name) { $found.Add([PSCustomObject]@{ File = $file.FullName; Rank = $rank }) } } } if ($found.Count -eq 0) { throw "No test toolkit app packages were found under: $($SearchRoots -join ', ')." } # Read each manifest; keep one file per app, preferring the lower-rank (compiled) source. $byName = @{} foreach ($entry in $found) { $info = Get-NAVAppInfo -Path $entry.File $name = "$($info.Name)" if ($byName.ContainsKey($name) -and $byName[$name].Rank -le $entry.Rank) { continue } $byName[$name] = [PSCustomObject]@{ File = $entry.File; Name = $name; Version = $info.Version Deps = @($info.Dependencies | ForEach-Object { "$($_.Name)" }); Rank = $entry.Rank Compiled = ($SearchRoots[$entry.Rank] -ne 'C:\Applications') } } $apps = @($byName.Values) # Order so every app is published after the in-set apps it depends on. $inSet = @{}; foreach ($a in $apps) { $inSet[$a.Name] = $true } $done = @{} $ordered = New-Object System.Collections.Generic.List[object] while ($ordered.Count -lt $apps.Count) { $progress = $false foreach ($a in $apps) { if ($done.ContainsKey($a.Name)) { continue } $unmet = @($a.Deps | Where-Object { $inSet.ContainsKey($_) -and -not $done.ContainsKey($_) }) if ($unmet.Count -eq 0) { $ordered.Add($a); $done[$a.Name] = $true; $progress = $true } } if (-not $progress) { foreach ($a in $apps) { if (-not $done.ContainsKey($a.Name)) { $ordered.Add($a); $done[$a.Name] = $true } }; break } } foreach ($a in $ordered) { $source = if ($a.Compiled) { 'compiled' } else { 'source (recompiled on publish)' } $published = Get-NAVAppInfo -ServerInstance $ServerInstance -Name $a.Name -Version $a.Version -ErrorAction SilentlyContinue if (-not $published) { Publish-NAVApp -ServerInstance $ServerInstance -Path $a.File -SkipVerification -Scope Global -ErrorAction Stop } $tenant = Get-NAVAppInfo -ServerInstance $ServerInstance -Name $a.Name -Version $a.Version -Tenant default -TenantSpecificProperties -ErrorAction SilentlyContinue if (-not $tenant -or $tenant.SyncState -ne 'Synced') { Sync-NAVApp -ServerInstance $ServerInstance -Name $a.Name -Version $a.Version -ErrorAction Stop $tenant = Get-NAVAppInfo -ServerInstance $ServerInstance -Name $a.Name -Version $a.Version -Tenant default -TenantSpecificProperties -ErrorAction SilentlyContinue } if ($tenant -and $tenant.IsInstalled) { Write-Output "Skipped (already installed) $($a.Name) $($a.Version)" } else { Install-NAVApp -ServerInstance $ServerInstance -Name $a.Name -Version $a.Version -ErrorAction Stop Write-Output "Installed $($a.Name) $($a.Version) [$source]" } } } $output = Invoke-BcContainerCommand -ContainerName $Name -ScriptBlock $script -DockerExecutable $DockerExecutable -Variables @{ SearchRoots = $SearchRoots ServerInstance = $ServerInstance Patterns = $patterns } Write-ALbuildLog -Level Success "Test toolkit installed in '$Name'.`n$("$output".Trim())" } |