Modules/businessdev.ALbuild.Apps/Public/Invoke-BcContainerTest.ps1

function Invoke-BcContainerTest {
    <#
    .SYNOPSIS
        Runs AL tests in a Business Central container and collects/parses the results.
 
    .DESCRIPTION
        ALbuild's built-in AL test runner. It drives the AL Test Tool page inside the container
        (no BcContainerHelper) and produces a JUnit/XUnit result file that is copied to the host,
        parsed into a summary, and optionally used to fail the build.
 
        Test apps are taken from -ProjectFolder (every AL test app found is run) or from an explicit
        -ExtensionId list. For folder-based discovery the optional ALbuild 'pipeline.config' beside
        each app.json is honoured: its 'alTestRunnerId' selects the test-runner codeunit. The
        runner codeunit otherwise defaults to 130450 (test isolation) or, with -DisableIsolation,
        130451 (isolation disabled); an explicit -TestRunnerCodeunitId overrides everything.
 
        For advanced scenarios a custom in-container invocation can still be supplied via
        -TestExecution (a script block that writes the result file to $ResultPathInContainer); when
        given, ALbuild only collects and parses the result and does not use the built-in runner.
 
    .PARAMETER Name
        Container name.
 
    .PARAMETER ProjectFolder
        Folder searched recursively for AL test apps to run. Mutually exclusive with -ExtensionId.
 
    .PARAMETER ExtensionId
        One or more app ids to run tests for, instead of discovering them from a project folder.
 
    .PARAMETER Credential
        Credentials of a SUPER user (NavUserPassword). If omitted, the containerUsername /
        containerPassword environment variables are used when present.
 
    .PARAMETER TestSuite
        Test suite name. Default 'DEFAULT'.
 
    .PARAMETER TestRunnerCodeunitId
        Explicit test-runner codeunit id, overriding pipeline.config and the isolation default.
 
    .PARAMETER DisableIsolation
        Use the isolation-disabled test runner (130451) as the default instead of 130450.
 
    .PARAMETER Tenant
        Tenant to use. Default 'default'.
 
    .PARAMETER CompanyName
        Company to run tests in. Default: the server's default company.
 
    .PARAMETER Culture
        Culture for the test run. Default 'en-US' (Microsoft tests target en-US).
 
    .PARAMETER Auth
        Client services credential type: NavUserPassword (default), Windows or AAD.
 
    .PARAMETER AzureDevOps
        Emit Azure DevOps warning log issues for failing tests.
 
    .PARAMETER ResultPath
        Host path to write the JUnit result file to. Default: ./TestResults.xml.
 
    .PARAMETER ResultPathInContainer
        Container path the run writes results to. Default: C:\bcptest\TestResults.xml.
 
    .PARAMETER TestExecution
        Optional custom in-container script block (advanced). When supplied, the built-in runner is
        not used; the block must write the result file to $ResultPathInContainer.
 
    .PARAMETER FailOnTestFailure
        Throw if any test failed.
 
    .PARAMETER DockerExecutable
        The Docker executable to use (default 'docker').
 
    .EXAMPLE
        Invoke-BcContainerTest -Name bld -ProjectFolder $env:BUILD_REPOSITORY_LOCALPATH -FailOnTestFailure
 
    .EXAMPLE
        Invoke-BcContainerTest -Name bld -ExtensionId '5e8c2f...' -Credential $cred -AzureDevOps
 
    .OUTPUTS
        PSCustomObject with Passed, Failed, Skipped, Total, Failures, Success, ResultPath.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '',
        Justification = 'Rebuilds the container credential from the containerPassword variable to authenticate the in-container test client; never persisted.')]
    [CmdletBinding(DefaultParameterSetName = 'ProjectFolder')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name,

        [Parameter(ParameterSetName = 'ProjectFolder')]
        [string] $ProjectFolder,

        [Parameter(Mandatory, ParameterSetName = 'ExtensionId')]
        [ValidateNotNullOrEmpty()] [string[]] $ExtensionId,

        [Parameter(Mandatory, ParameterSetName = 'Custom')]
        [ValidateNotNull()] [scriptblock] $TestExecution,

        [pscredential] $Credential,
        [string] $TestSuite = 'DEFAULT',
        [int] $TestRunnerCodeunitId,
        [switch] $DisableIsolation,
        [string] $Tenant = 'default',
        [string] $CompanyName = '',
        [string] $Culture = 'en-US',
        [ValidateSet('NavUserPassword', 'Windows', 'AAD')] [string] $Auth = 'NavUserPassword',
        [switch] $AzureDevOps,
        [string] $ResultPath = (Join-Path (Get-Location) 'TestResults.xml'),
        # Default into the shared mount (C:\run\my) so the result can be read on the host without
        # 'docker cp', which is unsupported against a running hyperv-isolated container.
        [string] $ResultPathInContainer = 'C:\run\my\TestResults.xml',
        [switch] $FailOnTestFailure,
        [string] $DockerExecutable = 'docker'
    )

    # Fall back to the containerUsername/containerPassword variables (set by the Create BC Container
    # task) when no credential was passed - the in-container test client must authenticate, otherwise
    # it fails with 'InvalidCredentialsError' and the session never initialises.
    if (-not $Credential -and $env:containerUsername -and $env:containerPassword) {
        $Credential = [System.Management.Automation.PSCredential]::new(
            $env:containerUsername, (ConvertTo-SecureString -String $env:containerPassword -AsPlainText -Force))
    }

    if ($PSCmdlet.ParameterSetName -eq 'Custom') {
        Invoke-BcContainerCommand -ContainerName $Name -DockerExecutable $DockerExecutable `
            -Variables @{ ResultPathInContainer = $ResultPathInContainer } -ScriptBlock $TestExecution | Out-Null
    }
    else {
        $nativeArgs = @{
            Name                  = $Name
            ResultPathInContainer = $ResultPathInContainer
            TestSuite             = $TestSuite
            DisableIsolation      = $DisableIsolation
            Tenant                = $Tenant
            CompanyName           = $CompanyName
            Culture               = $Culture
            Auth                  = $Auth
            AzureDevOps           = $AzureDevOps
            DockerExecutable      = $DockerExecutable
        }
        if ($PSBoundParameters.ContainsKey('ProjectFolder')) { $nativeArgs['ProjectFolder'] = $ProjectFolder }
        if ($PSBoundParameters.ContainsKey('ExtensionId')) { $nativeArgs['ExtensionId'] = $ExtensionId }
        if ($Credential) { $nativeArgs['Credential'] = $Credential }
        if ($PSBoundParameters.ContainsKey('TestRunnerCodeunitId')) { $nativeArgs['TestRunnerCodeunitId'] = $TestRunnerCodeunitId }
        Invoke-BcNativeContainerTest @nativeArgs
    }

    # Prefer reading the result from the host side of the shared folder (the container wrote it
    # there); fall back to 'docker cp' for a process-isolated container or a custom path.
    $hostResult = $null
    if ($ResultPathInContainer -like 'C:\run\my\*') {
        $hostResult = Join-Path (Get-BcContainerHostShare -Name $Name) (Split-Path -Path $ResultPathInContainer -Leaf)
    }
    if ($hostResult -and (Test-Path -LiteralPath $hostResult)) {
        Copy-Item -LiteralPath $hostResult -Destination $ResultPath -Force
    }
    else {
        Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -Arguments @('cp', "$($Name):$ResultPathInContainer", $ResultPath) | Out-Null
    }
    if (-not (Test-Path -LiteralPath $ResultPath)) { throw "Test result file '$ResultPath' was not produced by the test run." }

    [xml] $doc = Get-Content -LiteralPath $ResultPath -Raw -Encoding UTF8
    $summary = Get-BcTestResultSummary -Document $doc

    Write-ALbuildLog ("Tests: $($summary.Passed) passed, $($summary.Failed) failed, $($summary.Skipped) skipped.")
    foreach ($failure in $summary.Failures) { Write-ALbuildLog -Level Error $failure }

    $result = [PSCustomObject]@{
        Passed     = $summary.Passed
        Failed     = $summary.Failed
        Skipped    = $summary.Skipped
        Total      = $summary.Total
        Failures   = $summary.Failures
        Success    = $summary.Success
        ResultPath = $ResultPath
    }

    if ($FailOnTestFailure -and -not $summary.Success) {
        throw "$($summary.Failed) test(s) failed."
    }
    return $result
}