Public/Get-QAFrameworkRunConfiguration.ps1

function Get-QAFrameworkRunConfiguration {
    <#
    .SYNOPSIS
        Builds the effective QAFramework run configuration.
    .DESCRIPTION
        Merges the configuration layers in the following precedence order, highest first:

          1. Explicit cmdlet parameters
          2. Test run labels coming from QAOps (Get-QAOpsTestRunContext)
          3. The supplementary file SupplementaryFiles/qaframework.overrides.json
          4. TestPackagePipeline/qaframework.config.json
          5. The built-in legacy defaults

        Labels are read from the test run context using the 'qaframework.' prefix, for
        example 'qaframework.keywords' or 'qaframework.maxTestsInCluster'. Label values
        are strings, so lists are comma separated and booleans accept true/false/1/0.
    .PARAMETER TestPackageContentPath
        Root of the test package content, used to locate the configuration files.
    .PARAMETER Keywords
        Only run tests carrying at least one of these keywords.
    .PARAMETER ExcludeKeywords
        Drop tests carrying any of these keywords.
    .PARAMETER Squads
        Only run tests owned by at least one of these squads.
    .PARAMETER ExcludeSquads
        Drop tests owned by any of these squads.
    .PARAMETER AgentCapacity
        Maximum summed test weight running on a single agent. Legacy default is 3.
    .PARAMETER MaxTestsInCluster
        Maximum number of tests running simultaneously in the whole cluster.
    .PARAMETER TestTimeoutSeconds
        Per-test maximum runtime. Legacy default is 7200 (2 hours).
    .PARAMETER SchedulerTickSeconds
        Interval between scheduler polls.
    .PARAMETER PreRunFilter
        'all', 'none' or a regular expression selecting which PreRun fixtures execute.
    .PARAMETER IncludeNonConcurrent
        Run the tests marked CanRunConcurrently(false) in their own exclusive phase.
    .PARAMETER DisableFailoverRun
        Skip the failover phases even when the cluster has failover pairs.
    .PARAMETER RerunFailedTests
        Re-queue failed tests once at the end of the run.
    .PARAMETER ForceRunNonCentralized
        Legacy force flag that keeps non-centralized tests on a centralized cluster.
    .PARAMETER LogCollectionOnFailure
        Trigger the log collector once, on the first failing agent.
    .PARAMETER RtManagerRoot
        Root folder of the RTManager settings on a DataMiner agent.
    .PARAMETER SkipTestRunContext
        Do not query QAOps for test run labels. Used by tests and by offline runs.
    .EXAMPLE
        Get-QAFrameworkRunConfiguration -TestPackageContentPath 'C:\QAOps\Content' -Keywords 'Alarming'
    .OUTPUTS
        PSCustomObject holding the effective configuration.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [string]$TestPackageContentPath,

        [string[]]$Keywords,
        [string[]]$ExcludeKeywords,
        [string[]]$Squads,
        [string[]]$ExcludeSquads,

        [int]$AgentCapacity,
        [int]$MaxTestsInCluster,
        [int]$TestTimeoutSeconds,
        [int]$SchedulerTickSeconds,

        [string]$PreRunFilter,

        [bool]$IncludeNonConcurrent,
        [bool]$DisableFailoverRun,
        [bool]$RerunFailedTests,
        [bool]$ForceRunNonCentralized,
        [bool]$LogCollectionOnFailure,

        [string]$RtManagerRoot,

        [switch]$SkipTestRunContext
    )

    # Layer 5 - legacy defaults.
    $config = [ordered]@{
        agentCapacity          = 3
        maxTestsInCluster      = [int]::MaxValue
        testTimeoutSeconds     = 7200
        schedulerTickSeconds   = 10
        includeNonConcurrent   = $true
        disableFailoverRun     = $false
        preRunFilter           = 'all'
        rerunFailedTests       = $false
        forceRunNonCentralized = $false
        logCollectionOnFailure = $false
        rtManagerRoot          = 'C:\RTManager\'
        keywords               = [string[]]@()
        excludeKeywords        = [string[]]@()
        squads                 = [string[]]@()
        excludeSquads          = [string[]]@()
        systemSettings         = @{}
        sources                = [System.Collections.Generic.List[string]]::new()
    }

    $applyDocument = {
        param([object]$Document, [string]$Origin)

        if ($null -eq $Document) { return }

        $config['sources'].Add($Origin)

        foreach ($property in $Document.PSObject.Properties) {
            $key = $property.Name
            $value = $property.Value
            if ($null -eq $value) { continue }

            switch -Regex ($key) {
                '^filters$' {
                    # Nested filter block in the config file.
                    foreach ($filter in $value.PSObject.Properties) {
                        if ($null -ne $filter.Value) {
                            $config[$filter.Name] = [string[]]@($filter.Value)
                        }
                    }
                    continue
                }
                '^systemSettings$' {
                    $merged = @{}
                    foreach ($existing in $config['systemSettings'].Keys) { $merged[$existing] = $config['systemSettings'][$existing] }
                    foreach ($setting in $value.PSObject.Properties) { $merged[$setting.Name] = $setting.Value }
                    $config['systemSettings'] = $merged
                    continue
                }
                default {
                    $match = $config.Keys | Where-Object { $_ -eq $key } | Select-Object -First 1
                    if (-not $match) {
                        Write-Verbose "Ignoring unknown QAFramework configuration key '$key' from $Origin."
                        continue
                    }

                    $current = $config[$match]
                    if ($current -is [string[]]) { $config[$match] = [string[]]@($value) }
                    elseif ($current -is [bool]) { $config[$match] = [bool]$value }
                    elseif ($current -is [int]) { $config[$match] = [int]$value }
                    else { $config[$match] = $value }
                }
            }
        }
    }

    $readJson = {
        param([string]$Path)
        if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $null }
        $raw = Get-Content -LiteralPath $Path -Raw
        if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
        try { return $raw | ConvertFrom-Json }
        catch { throw "QAFramework configuration file '$Path' is not valid JSON: $($_.Exception.Message)" }
    }

    if ($TestPackageContentPath) {
        # Layer 4 - package configuration file.
        $configPath = @(
            (Join-Path $TestPackageContentPath 'TestPackagePipeline\qaframework.config.json')
            (Join-Path $TestPackageContentPath 'qaframework.config.json')
        ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1

        & $applyDocument (& $readJson $configPath) $configPath

        # Layer 3 - supplementary file override, shipped with the test run.
        $overridePath = @(
            (Join-Path $TestPackageContentPath 'SupplementaryFiles\qaframework.overrides.json')
            (Join-Path $TestPackageContentPath 'qaframework.overrides.json')
        ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1

        & $applyDocument (& $readJson $overridePath) $overridePath
    }

    # Layer 2 - QAOps test run labels.
    if (-not $SkipTestRunContext) {
        $context = $null
        if (Get-Command -Name 'Get-QAOpsTestRunContext' -ErrorAction SilentlyContinue) {
            try { $context = Get-QAOpsTestRunContext }
            catch { Write-Verbose "Get-QAOpsTestRunContext failed: $($_.Exception.Message). Continuing without test run labels." }
        }

        if ($context -and $context.Labels) {
            $labelDocument = [ordered]@{}

            foreach ($entry in $context.Labels.GetEnumerator()) {
                if ($entry.Key -notmatch '^(?i:qaframework)\.(.+)$') { continue }
                $key = $Matches[1]

                $match = $config.Keys | Where-Object { $_ -eq $key } | Select-Object -First 1
                if (-not $match) {
                    Write-Verbose "Ignoring unknown QAFramework label 'qaframework.$key'."
                    continue
                }

                $current = $config[$match]
                $raw = [string]$entry.Value

                if ($current -is [string[]]) {
                    $labelDocument[$match] = [string[]]@($raw -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
                }
                elseif ($current -is [bool]) {
                    $labelDocument[$match] = ($raw -match '^(?i:true|1|yes)$')
                }
                elseif ($current -is [int]) {
                    $parsed = 0
                    if ([int]::TryParse($raw, [ref]$parsed)) { $labelDocument[$match] = $parsed }
                }
                else {
                    $labelDocument[$match] = $raw
                }
            }

            if ($labelDocument.Count -gt 0) {
                & $applyDocument ([pscustomobject]$labelDocument) 'test run labels'
            }
        }
    }

    # Layer 1 - explicit parameters.
    $parameterMap = @{
        Keywords               = 'keywords'
        ExcludeKeywords        = 'excludeKeywords'
        Squads                 = 'squads'
        ExcludeSquads          = 'excludeSquads'
        AgentCapacity          = 'agentCapacity'
        MaxTestsInCluster      = 'maxTestsInCluster'
        TestTimeoutSeconds     = 'testTimeoutSeconds'
        SchedulerTickSeconds   = 'schedulerTickSeconds'
        PreRunFilter           = 'preRunFilter'
        IncludeNonConcurrent   = 'includeNonConcurrent'
        DisableFailoverRun     = 'disableFailoverRun'
        RerunFailedTests       = 'rerunFailedTests'
        ForceRunNonCentralized = 'forceRunNonCentralized'
        LogCollectionOnFailure = 'logCollectionOnFailure'
        RtManagerRoot          = 'rtManagerRoot'
    }

    $overrides = [ordered]@{}
    foreach ($parameter in $parameterMap.Keys) {
        if ($PSBoundParameters.ContainsKey($parameter)) {
            $overrides[$parameterMap[$parameter]] = $PSBoundParameters[$parameter]
        }
    }

    if ($overrides.Count -gt 0) {
        & $applyDocument ([pscustomobject]$overrides) 'parameters'
    }

    if ($config['agentCapacity'] -lt 1) { $config['agentCapacity'] = 1 }
    if ($config['maxTestsInCluster'] -lt 1) { $config['maxTestsInCluster'] = [int]::MaxValue }
    if ($config['testTimeoutSeconds'] -lt 1) { $config['testTimeoutSeconds'] = 7200 }
    if ($config['schedulerTickSeconds'] -lt 1) { $config['schedulerTickSeconds'] = 1 }
    if ([string]::IsNullOrWhiteSpace($config['preRunFilter'])) { $config['preRunFilter'] = 'all' }

    $config['sources'] = [string[]]@($config['sources'])

    return [pscustomobject]$config
}