Public/Test-DhReport.ps1

function Test-DhReport {
    <#
    .SYNOPSIS
        One command for every DashHtml check: a report object, a build script, a layout YAML or a built HTML file.

    .DESCRIPTION
        v2.6.0. Runs the checks that apply to what it is given and lists the findings. It never throws on a
        finding and never changes a file.

          Report object (-Report) Links (tables named by charts, links and actions exist), Menu (the
                                    -StrictNav lint, run even when -StrictNav is off), Size (estimated
                                    data size per table and tree table), Baseline (a table whose rows are
                                    nearly all New, or whose whole baseline was removed: probably a wrong
                                    -KeyField).
          .ps1 script Script (the Update-DhScript audit: ACTION / advisory / info). The
                                    script is parsed, never run.
          .yaml / .yml / .json Schema (the layout against Export-DhYamlSchema; each failing table,
                                    block or key is named), then Links and Menu are not available (the
                                    data is not in the layout).
          .html Runtime (built with an older DashHtml: the fixes it misses), Size.
                                    With -Deep also the accessibility and contrast audits (needs Edge or
                                    Chrome and the Tools folder of a DashHtml checkout).

        Severities: error (the file cannot be used as is), ACTION (an upgrade change that alters the
        result), warning, advisory, info.

    .PARAMETER Path
        Files to check (.ps1, .yaml, .yml, .json, .html). Wildcards and folders (searched recursively for
        those extensions) are accepted.

    .PARAMETER Report
        A report object from New-DhDashboard (also from the pipeline).

    .PARAMETER Strict
        Set $LASTEXITCODE to 1 when any finding is an error, an ACTION or a warning (for CI and Build.ps1).

    .PARAMETER PassThru
        Return the findings as objects (Check, Severity, Target, Message) instead of printing a summary.

    .PARAMETER Deep
        For .html files, also run Tools\Test-DhA11y.ps1 and Tools\Test-DhContrast.ps1.

    .PARAMETER SizeBudgetMB
        Size above which a report or file is reported (default 15, as Export-DhDashboard -SizeBudgetMB).

    .EXAMPLE
        Test-DhReport -Path .\Reports\ad-audit.ps1

    .EXAMPLE
        $report | Test-DhReport -PassThru | Where-Object Severity -eq 'warning'

    .EXAMPLE
        Test-DhReport -Path .\out\*.html -Strict # CI: fails when a report was built with an older runtime
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Console summary for a human; structured output is available via -PassThru.')]
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param(
        [Parameter(ParameterSetName = 'Path', Position = 0)]
        [string[]] $Path = @(),
        [Parameter(ParameterSetName = 'Report', Mandatory, ValueFromPipeline)]
        [System.Collections.Specialized.OrderedDictionary] $Report,
        [switch] $Strict,
        [switch] $PassThru,
        [switch] $Deep,
        [ValidateRange(1, 100000)]
        [int]    $SizeBudgetMB = 15
    )

    begin {
        $all = [System.Collections.Generic.List[object]]::new()
        $dh = Get-Module DashHtml | Select-Object -Last 1
        if (-not $dh) { $dh = Import-Module DashHtml -PassThru -ErrorAction Stop }
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'Report') {
            foreach ($f in (Test-DhReportObject -Report $Report -DashHtml $dh -SizeBudgetMB $SizeBudgetMB -Target ([string]$Report.Title))) { $all.Add($f) }
            return
        }
        $files = foreach ($p in @($Path)) {
            if (Test-Path -LiteralPath $p -PathType Container) {
                Get-ChildItem -LiteralPath $p -Recurse -File | Where-Object { $_.Extension -in '.ps1', '.yaml', '.yml', '.json', '.html' }
            } else {
                $found = @(Get-Item -Path $p -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer })
                if (-not $found.Count) { $all.Add((New-DhFinding 'File' 'error' $p 'File not found.')) }
                $found
            }
        }
        foreach ($file in @($files)) {
            switch ($file.Extension.ToLowerInvariant()) {
                '.ps1'  { foreach ($f in (Test-DhReportScript -File $file)) { $all.Add($f) } }
                '.html' { foreach ($f in (Test-DhReportHtml -File $file -DashHtml $dh -SizeBudgetMB $SizeBudgetMB -Deep:$Deep)) { $all.Add($f) } }
                default { foreach ($f in (Test-DhReportLayout -File $file)) { $all.Add($f) } }
            }
        }
    }

    end {
        if ($Strict -and @($all | Where-Object { $_.Severity -in 'error', 'ACTION', 'warning' }).Count) { $global:LASTEXITCODE = 1 }
        elseif ($Strict) { $global:LASTEXITCODE = 0 }
        if ($PassThru) { return $all.ToArray() }
        if ($all.Count -eq 0) { Write-Host 'Test-DhReport: no findings.' -ForegroundColor Green; return }
        $colour = @{ error = 'Red'; ACTION = 'Red'; warning = 'Yellow'; advisory = 'Cyan'; info = 'Gray' }
        foreach ($g in ($all | Group-Object Target)) {
            Write-Host ''
            Write-Host $g.Name -ForegroundColor White
            foreach ($f in $g.Group) {
                Write-Host (' {0,-8} {1,-9} {2}' -f $f.Severity, $f.Check, $f.Message) -ForegroundColor $colour[$f.Severity]
            }
        }
        $counts = ($all | Group-Object Severity | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', '
        Write-Host ''
        Write-Host "Test-DhReport: $($all.Count) finding(s): $counts."
    }
}

function New-DhFinding {
    param([string] $Check, [string] $Severity, [string] $Target, [string] $Message)
    [pscustomobject]@{ Check = $Check; Severity = $Severity; Target = $Target; Message = $Message }
}

function Test-DhReportObject {
    <# Links, Menu, Size and Baseline checks on a built report object. #>
    param($Report, $DashHtml, [int] $SizeBudgetMB, [string] $Target)
    $known = @($script:DhTypeToCmdlet.Keys)
    foreach ($m in @(& $DashHtml { param($r, $k) Test-DhReportLinks -Report $r -KnownBlockTypes $k } $Report $known)) {
        New-DhFinding 'Links' 'warning' $Target $m
    }
    foreach ($m in @(& $DashHtml { param($r) Test-DhNavLint -Report $r } $Report)) {
        New-DhFinding 'Menu' 'warning' $Target $m
    }

    # Size: the JSON of a sample of rows, scaled to the row count
    $sizes = [System.Collections.Generic.List[object]]::new()
    $estimate = {
        param([object[]] $Rows)
        $n = @($Rows).Count
        if (-not $n) { return 0 }
        $sample = @($Rows | Select-Object -First 200)
        $json = ConvertTo-Json -InputObject $sample -Compress -Depth 6 -WarningAction SilentlyContinue
        [long]([Text.Encoding]::UTF8.GetByteCount($json) / $sample.Count * $n)
    }
    foreach ($t in @($Report.Tables)) { $sizes.Add([pscustomobject]@{ Id = [string]$t.Id; Bytes = (& $estimate @($t.Data)); Rows = @($t.Data).Count }) }
    if ($Report.Contains('Blocks')) {
        foreach ($b in @($Report.Blocks | Where-Object { $_.BlockType -eq 'treetable' })) {
            $sizes.Add([pscustomobject]@{ Id = [string]$b.Id; Bytes = (& $estimate @($b.Rows)); Rows = @($b.Rows).Count })
        }
    }
    $total = ($sizes | Measure-Object Bytes -Sum).Sum
    if ($total -gt [long]$SizeBudgetMB * 1MB) {
        $top = @($sizes | Sort-Object Bytes -Descending | Select-Object -First 3 | ForEach-Object { "'$($_.Id)' ($($_.Rows) rows, about $([math]::Round($_.Bytes / 1MB, 1)) MB)" }) -join ', '
        New-DhFinding 'Size' 'warning' $Target "The data is about $([math]::Round($total / 1MB, 1)) MB, over $SizeBudgetMB MB. Biggest: $top. Consider Export-DhDashboard -CompressData, fewer columns or a split report."
    }

    # Baseline sanity: almost everything New, or the whole baseline Removed, usually means a wrong -KeyField
    foreach ($t in @($Report.Tables | Where-Object { $_.Contains('Baseline') -and $_.Baseline })) {
        $c = $t.Baseline.Counts
        $rows = @($t.Data).Count
        $baseRows = [int]$c.Same + [int]$c.Changed + [int]$c.Removed
        if ($rows -ge 10 -and $baseRows -gt 0 -and [int]$c.New -gt 0.9 * $rows) {
            New-DhFinding 'Baseline' 'warning' $Target "Table '$($t.Id)': $($c.New) of $rows rows are New against a baseline of $baseRows rows. Check -KeyField '$($t.Baseline.KeyField)' (same property, same values, in both runs?)."
        } elseif ($baseRows -gt 0 -and [int]$c.Removed -eq $baseRows) {
            New-DhFinding 'Baseline' 'warning' $Target "Table '$($t.Id)': every baseline row ($baseRows) was removed. Check -KeyField '$($t.Baseline.KeyField)'."
        }
    }
}

function Test-DhReportScript {
    <# The Update-DhScript audit of one script (parsed, not run). #>
    param([System.IO.FileInfo] $File)
    foreach ($r in @(Update-DhScript -Path $File.FullName -PassThru)) {
        New-DhFinding 'Script' $r.Severity "$($File.Name):$($r.Line)" "$($r.Cmdlet): $($r.Issue)"
    }
}

function Test-DhReportLayout {
    <# A layout YAML / JSON against the schema; each failing section item is named. #>
    param([System.IO.FileInfo] $File)
    $target = $File.Name
    try {
        $text = Get-Content -LiteralPath $File.FullName -Raw
        $model = if ($File.Extension -ieq '.json') { $text | ConvertFrom-Json -AsHashtable -Depth 64 } else { ConvertFrom-DhYamlText $text }
    } catch {
        return (New-DhFinding 'Schema' 'error' $target "Cannot read the layout: $($_.Exception.Message)")
    }
    if ($model -isnot [System.Collections.IDictionary]) { return (New-DhFinding 'Schema' 'error' $target 'The layout is not a mapping (expected keys such as title, tables, blocks).') }
    $schema = Export-DhYamlSchema
    $check = {
        param($doc)
        $ev = $null
        $ok = Test-Json -Json (ConvertTo-Json -InputObject $doc -Depth 64 -Compress) -Schema $schema -ErrorVariable ev -ErrorAction SilentlyContinue
        # the root accepts title or Title (anyOf), so a valid document still reports the other spelling as missing: drop that noise
        $msgs = @($ev | ForEach-Object { $_.Exception.Message -replace '^The JSON is not valid with the schema: ', '' } |
                  ForEach-Object { ($_ -replace 'Required properties \["(T|t)itle"\] are not present at \x27\x27\s*', '').Trim() } | Where-Object { $_ })
        [pscustomobject]@{ Ok = [bool]$ok; Message = ($msgs -join ' ') }
    }
    # Which keys of a failing mapping are to blame: the ones whose removal makes it pass
    $blame = {
        param([string] $Section, $Item, $Title)
        $bad = foreach ($key in @($Item.Keys)) {
            $copy = [ordered]@{}
            foreach ($k2 in $Item.Keys) { if ($k2 -cne $key) { $copy[$k2] = $Item[$k2] } }
            $doc = [ordered]@{ title = $Title }; $doc[$Section] = @($copy)
            if ((& $check $doc).Ok) { "'$key' ($(ConvertTo-Json -InputObject $Item[$key] -Compress -Depth 3))" }
        }
        @($bad)
    }
    $whole = & $check $model
    if ($whole.Ok) { return }
    $title = if ($model.Contains('title')) { $model['title'] } elseif ($model.Contains('Title')) { $model['Title'] } else { $null }
    $named = 0
    if ($null -eq $title -or "$title" -eq '') { New-DhFinding 'Schema' 'error' $target 'title: missing (every layout needs a title).'; $named++ }
    foreach ($k in @($model.Keys)) {
        if ($k -in 'title', 'Title') { continue }
        $v = $model[$k]
        # assigned in a statement, not as the value of an if: a one-item array would unroll to the item
        $items = $null
        if ($v -is [System.Collections.IList]) { $items = @($v) }
        if ($null -ne $items) {
            for ($i = 0; $i -lt $items.Count; $i++) {
                $doc = [ordered]@{ title = $(if ($null -ne $title) { $title } else { 'x' }) }
                $doc[$k] = @($items[$i])
                $r = & $check $doc
                if (-not $r.Ok) {
                    $isMap = $items[$i] -is [System.Collections.IDictionary]
                    $idv = if ($isMap) { @('id', 'Id', 'TableId', 'tableId') | Where-Object { $items[$i].Contains($_) } | Select-Object -First 1 } else { $null }
                    $label = if ($idv) { " ($idv '$($items[$i][$idv])')" } else { '' }
                    $keys = if ($isMap) { & $blame $k $items[$i] $doc.title } else { @() }
                    $why = if (@($keys).Count) { "unknown key or wrong value: $(@($keys) -join ', ')." } elseif ($r.Message) { $r.Message } else { 'does not match the schema (a required key may be missing).' }
                    New-DhFinding 'Schema' 'error' $target "$k[$i]$label`: $why"; $named++
                }
            }
        } else {
            $doc = [ordered]@{ title = $(if ($null -ne $title) { $title } else { 'x' }) }
            $doc[$k] = $v
            $r = & $check $doc
            if (-not $r.Ok) {
                $why = if (-not $r.Message -or $r.Message -match 'false schema') { 'not a known key (check the spelling).' } else { $r.Message }
                New-DhFinding 'Schema' 'error' $target "$k`: $why"; $named++
            }
        }
    }
    if (-not $named) { New-DhFinding 'Schema' 'error' $target $(if ($whole.Message) { $whole.Message } else { 'The layout does not match the schema (is the title missing?).' }) }
}

# Fixes by release, for "built with an older runtime". Newest first; only releases since 2.4.0.
$script:DhRuntimeFixes = [ordered]@{
    '2.6.0' = 'link columns with relative URLs and templates, windowed tree tables, compressed data, table state in the address'
    '2.5.4' = 'data fields named Keys, Values or Count were dropped'
    '2.5.3' = 'menu "More" after one row instead of two'
    '2.5.2' = 'keyboard access to collapsible sections, CSV formula guard, baseline markers'
    '2.5.1' = 'print without shadows, readable heatmap values in dark mode, contrast fixes in 10 themes'
    '2.5.0' = 'tree tables, menu keyboard support, column picker memory'
    '2.4.1' = 'banner and menu order fixes'
}

function Test-DhReportHtml {
    <# A built report: runtime version against the installed module, file size, and (-Deep) the browser audits. #>
    param([System.IO.FileInfo] $File, $DashHtml, [int] $SizeBudgetMB, [switch] $Deep)
    $target = $File.Name
    $head = [IO.File]::ReadAllText($File.FullName)
    $m = [regex]::Match($head, 'Generated by <strong>DashHtml</strong> v(\d+\.\d+\.\d+)')
    if (-not $m.Success) {
        New-DhFinding 'Runtime' 'info' $target 'Not a DashHtml report (no "Generated by DashHtml" footer).'
    } else {
        $built = [version]$m.Groups[1].Value
        $now = $DashHtml.Version
        if ($built -lt $now) {
            $missed = @($script:DhRuntimeFixes.Keys | Where-Object { [version]$_ -gt $built -and [version]$_ -le $now } | ForEach-Object { "$_ ($($script:DhRuntimeFixes[$_]))" })
            $list = if ($missed.Count) { ' Missing: ' + ($missed -join '; ') + '.' } else { '' }
            New-DhFinding 'Runtime' 'advisory' $target "Built with DashHtml $built; $now is installed. Regenerate the report to pick up the fixes.$list"
        }
    }
    if ($File.Length -gt [long]$SizeBudgetMB * 1MB) {
        New-DhFinding 'Size' 'warning' $target "The file is $([math]::Round($File.Length / 1MB, 1)) MB, over $SizeBudgetMB MB. Consider Export-DhDashboard -CompressData."
    }
    if ($Deep) {
        $tools = Join-Path (Split-Path $DashHtml.ModuleBase -Parent) 'Tools'
        $a11y = Join-Path $tools 'Test-DhA11y.ps1'; $contrast = Join-Path $tools 'Test-DhContrast.ps1'
        if (-not (Test-Path -LiteralPath $a11y)) {
            New-DhFinding 'Deep' 'info' $target "Skipped: the audits live in the Tools folder of a DashHtml checkout (not found at $tools)."
        } else {
            foreach ($r in @(& $a11y -Path $File.FullName -PassThru)) {
                New-DhFinding 'A11y' $(if ($r.Severity -eq 'error') { 'warning' } else { 'info' }) $target "$($r.Rule): $($r.Count) (e.g. $($r.Sample))"
            }
            if (Test-Path -LiteralPath $contrast) {
                foreach ($r in @(& $contrast -Path $File.FullName -PassThru)) {
                    New-DhFinding 'Contrast' 'warning' $target "$($r.Mode) $($r.Selector): $($r.Ratio):1, needs $($r.Min):1 ('$($r.Text)')"
                }
            }
        }
    }
}