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())"
}