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'."
    }
}