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 SymbolExportFolder Host folder to copy the installed toolkit .app files into, so a host (AL Tool) compile can resolve test-toolkit symbols that the artifact does not ship on the host. This is required for test apps on country 'w1', whose 'System Application Test Library' and 'Tests-TestLibraries' exist only inside the container. The apps are bridged out through the container's C:\run\my host mount ('docker cp' is not supported against a running hyperv-isolated container). .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] $SymbolExportFolder, [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. # 'Application Test Library' and 'Agent Test Library' must be present: on BC 28+ the in-set apps # 'AI Test Toolkit' / 'Tests-TestLibraries' declare a dependency on 'Application Test Library' (>= 28.0.0.0), # so if it is not also published+synced here, syncing those dependents fails with "no synchronized # extension could be found to satisfy the dependency definition for Application Test Library by Microsoft". $libraryPatterns = @( 'Any', 'Library Assert', 'Library Variable Storage', 'Test Runner', 'Test Framework', 'Application Test Library', 'System Application Test Library', 'Business Foundation Test Libraries', 'Permissions Mock', 'Agent Test Library', '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]" } } # Bridge the resolved toolkit .app files out to the host (for an AL Tool host compile) by # copying them into the C:\run\my bind mount; the host side reads them from the host share. if ($ExportDir) { if (Test-Path -LiteralPath $ExportDir) { Remove-Item -LiteralPath $ExportDir -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Force -Path $ExportDir | Out-Null foreach ($a in $ordered) { Copy-Item -LiteralPath $a.File -Destination $ExportDir -Force } Write-Output "Exported $($ordered.Count) toolkit symbol app(s) to the host share." } } # The toolkit .app files only exist inside the container; bridge them to the host through the # C:\run\my bind mount when an export folder is requested (the in-container copy above writes to # 'C:\run\my\<sub>', which surfaces on the host under Get-BcContainerHostShare). $exportSubfolder = 'albuild-test-symbols' $containerExportDir = if ($SymbolExportFolder) { "C:\run\my\$exportSubfolder" } else { $null } $output = Invoke-BcContainerCommand -ContainerName $Name -ScriptBlock $script -DockerExecutable $DockerExecutable -Variables @{ SearchRoots = $SearchRoots ServerInstance = $ServerInstance Patterns = $patterns ExportDir = $containerExportDir } Write-ALbuildLog -Level Success "Test toolkit installed in '$Name'.`n$("$output".Trim())" if ($SymbolExportFolder) { $hostExport = Join-Path (Get-BcContainerHostShare -Name $Name) $exportSubfolder $exported = @(Get-ChildItem -LiteralPath $hostExport -Filter '*.app' -File -ErrorAction SilentlyContinue) if ($exported.Count -eq 0) { throw "No toolkit symbol apps were exported to the host share '$hostExport'." } if (-not (Test-Path -LiteralPath $SymbolExportFolder)) { New-Item -ItemType Directory -Force -Path $SymbolExportFolder | Out-Null } foreach ($app in $exported) { Copy-Item -LiteralPath $app.FullName -Destination $SymbolExportFolder -Force } Remove-Item -LiteralPath $hostExport -Recurse -Force -ErrorAction SilentlyContinue Write-ALbuildLog "Exported $($exported.Count) test toolkit symbol app(s) to '$SymbolExportFolder'." } } |