Private/Kinds/Website.ps1

# The Website Kind: whether a page loads when a browser loads it, and what happened at
# every host it needed on the way.
#
# A website is not one request. The HttpsEndpoint Kind asks one address one question; a
# page is dozens of requests to a sign-in host, a CDN and an API, and one of them failing
# is the usual reason "the intranet does not load". What a browser met on the way is in
# its net log, so this Kind has a browser load the page and write one: Gutcheck starts its
# own Edge (Chrome where there is no Edge), headless, with a profile folder of its own
# that is deleted afterwards, and reads the net log it wrote.
#
# A Check that acts, like the disk write test: it loads a page and writes two things, both
# Gutcheck's own - the throwaway profile, which it deletes, and the net log, which it keeps
# in the Report folder for second level. The User's browser, profile, cookies and
# extensions are not touched; the browser process Gutcheck started is stopped when the
# page takes longer than the time limit, which is part of the measurement.
#
# The net log is recorded in Chromium's default capture mode, which leaves out cookies and
# credentials. It still holds every address the page loaded, so it is Customer data.
#
# Each entry of the Sites parameter is an object:
# Url http:// or https:// address of the page (required)
# Title what the Report calls the site (default: its host and path)
# App the application the site belongs to; its Findings stand under it
# ExpectStatus the final statuses that count as answered: numbers, or ranges written
# "200-399" (default 200-399)

# The browser's switches. The first group is what makes it a browser of its own: no window,
# no profile but the throwaway one, nothing of the User's. The second keeps Edge from
# fetching for itself while it loads the page - updates, sync, extensions, its first-run
# screens - which the net log would otherwise be full of. It does some all the same
# (SmartScreen, its time service, measured 2026-10-14), which is why the parse attributes
# requests to the page and does not rely on these.
$script:WebsiteBrowserSwitches = @(
    '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check'
    '--disable-background-networking', '--disable-component-update', '--disable-sync'
    '--disable-extensions', '--disable-default-apps', '--disable-breakpad'
)

# How much time the page's own timers are given after it has loaded, in the browser's
# virtual time: a page that fetches more a moment after its load event gets to, and the
# real time this costs is short because virtual time runs as fast as the page lets it.
$script:WebsiteVirtualTimeMs = 8000

# How long one page may take before the browser is stopped, where a Definition says nothing.
# Not a judgement of how fast a page should be - the Judge's thresholds are that - but a
# bound on how long a hanging site can hold up the Run.
$script:WebsiteTimeLimitSeconds = 45

function ConvertTo-WebsiteTarget {
    <#
    .SYNOPSIS
        Reads one entry of the Sites parameter into what to load and what to expect.
        Pure, and reaches nothing.
    .DESCRIPTION
        Both halves of the Kind read entries through here, so the Gatherer loads exactly
        what the Judge will hold the answer against. A broken entry comes back with a
        Problem rather than being dropped: the Report names a mistyped Definition instead
        of calling a site unreachable.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Entry)

    $url   = "$(Get-DataProperty $Entry 'Url')".Trim()
    $title = "$(Get-DataProperty $Entry 'Title')".Trim()
    $app   = "$(Get-DataProperty $Entry 'App')".Trim()

    # A number, or a range written "200-399". What is neither is left out, and nothing
    # left is the default: a site answers when it ends at a page, not at an error.
    $expect = @(foreach ($item in @(Get-DataProperty $Entry 'ExpectStatus')) {
        if ($null -eq $item) { continue }
        if ("$item".Trim() -match '^(?<from>\d{3})\s*-\s*(?<to>\d{3})$') {
            [pscustomobject]@{ From = [int]$Matches['from']; To = [int]$Matches['to'] }
            continue
        }
        $number = ConvertTo-Number $item
        if ($null -ne $number) { [pscustomobject]@{ From = [int]$number; To = [int]$number } }
    })
    if (-not $expect.Count) { $expect = @([pscustomobject]@{ From = 200; To = 399 }) }

    $checked = Test-WebsiteAddress -Address $url
    $uri = $checked.Uri
    $problem = $null
    if ($checked.Refused) { $problem = 'NotWebAddress' }

    $label = $title
    if (-not $label -and $uri) { $label = $uri.Host + $uri.AbsolutePath.TrimEnd('/') }
    if (-not $label) { $label = $url }

    [pscustomobject]@{
        PSTypeName   = 'Gutcheck.WebsiteTarget'
        Url          = $url
        Key          = $url
        HostName     = $(if ($uri) { $uri.Host } else { $null })
        Label        = $label
        Title        = $title
        App          = $app
        ExpectStatus = $expect
        Problem      = $problem
    }
}

function Test-WebsiteAddress {
    <#
    .SYNOPSIS
        Whether an address is one the browser can be handed: absolute, http or https,
        with a host, and nothing that could end its argument on the command line. Pure.
    .DESCRIPTION
        Refused is 'Malformed', 'Scheme' or empty; Uri the address read, where it was one.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Address)

    $uri = $null
    $refused = ''
    if ("$Address" -match '[\s"]' -or -not [uri]::TryCreate("$Address", [UriKind]::Absolute, [ref]$uri) -or -not $uri.Host) {
        $refused = 'Malformed'
        $uri = $null
    }
    elseif ($uri.Scheme -notin 'http', 'https') {
        $refused = 'Scheme'
        $uri = $null
    }
    [pscustomobject]@{ Uri = $uri; Refused = $refused }
}

function ConvertTo-WebsiteUrlCheck {
    <#
    .SYNOPSIS
        The addresses a Technician named with -Url as Website Check Definitions, one per
        address and named after its host. Throws for one that is no web address. Pure.
    .DESCRIPTION
        Built at run time and written to no Definitions file: the site a User complains
        about is examined without anybody writing a Definition for it. An address without
        a scheme is read as https, which is what a Technician typing "intranet.contoso"
        means. One that is not a web address at all stops the Run before it starts, with
        the reason: a typo must not cost a Run.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Url = @())

    $names = @{}
    foreach ($typed in @($Url | ForEach-Object { "$_".Trim() } | Where-Object { $_ })) {
        $address = $typed
        if ($address -notmatch '^[a-z][a-z0-9+.-]*://') { $address = 'https://' + $address }

        $checked = Test-WebsiteAddress -Address $address
        $uri = $checked.Uri
        $reason = switch ($checked.Refused) {
            'Malformed' { Get-Text 'Value.Website.Refused.Malformed' }
            'Scheme'    { Get-Text 'Value.Website.Refused.Scheme' }
        }
        if ($reason) { throw ((Get-Text 'Error.Website.NotAnAddress') -f $typed, $reason) }

        $name = $uri.Host
        if ($names.ContainsKey($name)) { $names[$name]++; $name = '{0} ({1})' -f $name, $names[$name] }
        else { $names[$name] = 1 }

        [pscustomobject]@{
            Name                 = $name
            Kind                 = 'Website'
            MinimumModuleVersion = $null
            Parameters           = @{ Sites = @(@{ Url = $address }) }
        }
    }
}

function Read-WebsiteUrlCheck {
    <#
    .SYNOPSIS
        Asks in the window which websites to load, and returns them as Website Checks, as
        -Url makes them. Nothing for an empty answer.
    .DESCRIPTION
        Asked where a Run's Scope is for websites and none was named. Several addresses
        are separated by spaces or commas. One that is no web address is said, with why,
        and the question is asked again: a typo must not cost the Run its website.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    while ($true) {
        $answer = Read-LanternText (Get-Text 'Console.Website.Question')
        $typed = @("$answer" -split '[\s,;]+' | Where-Object { $_ })
        if (-not $typed.Count) { return }
        try { return @(ConvertTo-WebsiteUrlCheck -Url $typed) }
        catch { Write-ConsoleLine $_.Exception.Message -Color Yellow }
    }
}

function New-WebsiteNotGivenFinding {
    <#
    .SYNOPSIS
        The Finding that says a Run for websites loaded none, because none was given.
        Nothing where one was, or the Scope was for something else. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Selection)

    if (-not [bool](Get-DataProperty $Selection 'WebsiteMissing')) { return }
    New-Finding -Category Website -Check (Get-Text 'Check.Website.Sites') -Severity INFO `
        -Value (Get-Text 'Value.Website.NoneGiven') -Hint (Get-Text 'Hint.Website.NoneGiven')
}
function Find-WebsiteBrowser {
    <#
    .SYNOPSIS
        The browser that loads the page: Edge where it is installed, Chrome where it is
        not, or $null where neither is.
    .DESCRIPTION
        Where Windows registers it (App Paths) first, then where its installer puts it,
        machine-wide and, for Chrome, per user. Edge first because it is on every Windows
        and is what the User most likely opens the site in.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $candidates = @(
        @{ Name = 'Edge';   Paths = @(
            (Join-Path "${env:ProgramFiles(x86)}" 'Microsoft\Edge\Application\msedge.exe')
            (Join-Path "$env:ProgramFiles" 'Microsoft\Edge\Application\msedge.exe')) }
        @{ Name = 'Chrome'; Paths = @(
            (Join-Path "$env:ProgramFiles" 'Google\Chrome\Application\chrome.exe')
            (Join-Path "${env:ProgramFiles(x86)}" 'Google\Chrome\Application\chrome.exe')
            (Join-Path "$env:LOCALAPPDATA" 'Google\Chrome\Application\chrome.exe')) }
    )

    foreach ($candidate in $candidates) {
        # Where Windows registers it, as the Browser Kind asks. See BrowserProfile.ps1.
        $registered = Get-BrowserExecutable -Browser $candidate.Name
        foreach ($path in @($registered) + @($candidate.Paths)) {
            $path = "$path".Trim('"', ' ')
            if (-not $path -or -not [IO.File]::Exists($path)) { continue }
            $version = $null
            try { $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($path).ProductVersion } catch { }
            return [pscustomobject]@{ Name = $candidate.Name; Path = $path; Version = $version }
        }
    }
    $null
}

function Invoke-WebsiteLoad {
    <#
    .SYNOPSIS
        Has the browser load one page with a profile of its own and a net log, and reads
        the net log. Judges nothing, and never throws.
    .DESCRIPTION
        The browser is started directly, with its arguments as one command line: through
        Start-Process with quoted arguments, Edge ended at once with exit code 13
        (observed 2026-10-14). It is given a profile folder of its own, which keeps it
        apart from a browser the User has open - Chromium runs one browser per profile -
        and is deleted afterwards. --dump-dom is what makes a headless browser end once the
        page has loaded; what it writes is the page itself, which is read off its output
        and dropped, never kept: the content of a page is not Gutcheck's.
 
        A page that is not done within the time limit is stopped, the browser's whole
        process tree with it. Its net log is then cut off mid-write, which the parse
        reads up to where it ends.
 
        The net log is written into the Report folder where there is one. Where there is
        none - a Check performed outside a Run - it goes into the throwaway folder and
        is deleted with it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Target,
        [Parameter(Mandatory)]$Browser,
        [AllowEmptyString()][string]$Folder = '',
        [int]$TimeLimitSeconds = $script:WebsiteTimeLimitSeconds
    )

    $record = [ordered]@{
        Key         = $Target.Key
        Url         = $Target.Url
        Label       = $Target.Label
        Title       = $Target.Title
        App         = $Target.App
        Browser     = $Browser.Name
        TimedOut    = $false
        ExitCode    = $null
        ElapsedMs   = $null
        NetLog      = $null
        Page        = $null
        Error       = $null
        ProfileLeft = $null
    }

    $scratch = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'gutcheck-website-' + [guid]::NewGuid().ToString('N'))
    $profileFolder = [IO.Path]::Combine($scratch, 'profile')
    # One name per host, as a file system takes it; two sites on one host each keep their own.
    $stem = 'netlog_{0}' -f ($Target.HostName -replace '[^A-Za-z0-9.-]', '_')
    $fileName = $stem + '.json'
    $logPath = [IO.Path]::Combine($scratch, $fileName)
    $kept = $Folder -and [IO.Directory]::Exists($Folder)
    if ($kept) {
        $logPath = [IO.Path]::Combine($Folder, $fileName)
        $n = 1
        while ([IO.File]::Exists($logPath)) {
            $n++
            $logPath = [IO.Path]::Combine($Folder, ('{0}_{1}.json' -f $stem, $n))
        }
    }

    $process = $null
    $watch = [Diagnostics.Stopwatch]::StartNew()
    try {
        $null = [IO.Directory]::CreateDirectory($profileFolder)
        $arguments = @($script:WebsiteBrowserSwitches) + @(
            ('--user-data-dir="{0}"' -f $profileFolder)
            ('--log-net-log="{0}"' -f $logPath)
            # Said, although it is the default: cookies and credentials stay out of the file.
            '--net-log-capture-mode=Default'
            ('--virtual-time-budget={0}' -f $script:WebsiteVirtualTimeMs)
            # What makes a headless browser end once the page has loaded. The page itself,
            # which it writes to its output, is drained below and never kept.
            '--dump-dom'
            ('"{0}"' -f $Target.Url)
        )
        $info = New-Object System.Diagnostics.ProcessStartInfo
        $info.FileName               = $Browser.Path
        $info.Arguments              = $arguments -join ' '
        $info.UseShellExecute        = $false
        $info.CreateNoWindow         = $true
        $info.RedirectStandardOutput = $true
        $info.RedirectStandardError  = $true
        $process = [Diagnostics.Process]::Start($info)
        # Read to the end so the browser never waits on a full pipe, and dropped.
        $null = $process.StandardOutput.ReadToEndAsync()
        $null = $process.StandardError.ReadToEndAsync()

        $status = Get-Text 'Console.Progress.Website'
        while (-not $process.WaitForExit(250)) {
            $seconds = $watch.Elapsed.TotalSeconds
            Write-CheckProgress -Status ($status -f $Target.Label, [int]$seconds, $TimeLimitSeconds) -Step $seconds -Of $TimeLimitSeconds
            if ($seconds -ge $TimeLimitSeconds) {
                $record.TimedOut = $true
                Stop-WebsiteBrowser -Process $process
                break
            }
        }
        $record.ElapsedMs = [math]::Round($watch.Elapsed.TotalMilliseconds)
        if ($process.HasExited) { $record.ExitCode = $process.ExitCode }

        if ([IO.File]::Exists($logPath)) {
            $text = $null
            # A browser that was stopped may hold the file a moment longer.
            for ($attempt = 0; $attempt -lt 10 -and $null -eq $text; $attempt++) {
                try { $text = [IO.File]::ReadAllText($logPath) } catch { Start-Sleep -Milliseconds 300 }
            }
            $record.Page = ConvertFrom-WebsiteNetLog -Text $text -Url $Target.Url
            if ($kept) { $record.NetLog = [IO.Path]::GetFileName($logPath) }
        }
    }
    catch {
        $record.Error = $_.Exception.GetBaseException().Message
    }
    finally {
        if ($process -and -not $process.HasExited) { Stop-WebsiteBrowser -Process $process }
        if ($process) { $process.Dispose() }
        # The profile goes, whatever happened above. Its files may be held for a moment
        # by a browser process that is still ending.
        for ($attempt = 0; $attempt -lt 20 -and [IO.Directory]::Exists($scratch); $attempt++) {
            try { [IO.Directory]::Delete($scratch, $true) } catch { Start-Sleep -Milliseconds 250 }
        }
        if ([IO.Directory]::Exists($scratch)) { $record.ProfileLeft = $scratch }
    }

    [pscustomobject]$record
}

function Stop-WebsiteBrowser {
    <#
    .SYNOPSIS
        Stops a browser Gutcheck started, with every process it started in turn.
    .DESCRIPTION
        A Chromium browser is a tree of processes. PowerShell 7 can end a tree itself;
        Windows PowerShell's .NET Framework cannot, and taskkill does it there.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][Diagnostics.Process]$Process)

    try {
        if ($PSVersionTable.PSEdition -eq 'Core') { $Process.Kill($true) }
        else { $null = & "$env:SystemRoot\System32\taskkill.exe" /PID $Process.Id /T /F 2>&1 }
        $null = $Process.WaitForExit(5000)
    }
    catch { }
}

# What Get-WebsiteData hands its Judge. See Private/DataShape.ps1.
$script:WebsiteDataShape = @{
    GatheredAt         = 'Answered'
    Browser            = 'Value'
    NoBrowser          = 'Answered'
    TimeLimitSeconds   = 'Answered'
    BrowserObserved    = 'Answered'
    BrowserPolicies    = 'List'
    Sites              = 'List'
    Prefixes           = 'List'
    Routes             = 'List'
    NetworkObserved    = 'Answered'
    UserProxy          = 'Value'
    WinHttpProxy64Text = 'Value'
    WinHttpProxy32Text = 'Value'
}

function Get-WebsiteData {
    <#
    .SYNOPSIS
        Loads each site of the Definition in a browser of Gutcheck's own and reads what
        its net log says. Decides nothing.
    .PARAMETER Observed
        What the Run knows before this Check. 'RunFolder' is the folder the Report is
        written to, where the net logs are kept; see Private/Kind.ps1.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{})

    $targets = @(@(Get-Parameter $Parameters 'Sites' @()) | Where-Object { $null -ne $_ } |
        ForEach-Object { ConvertTo-WebsiteTarget -Entry $_ } | Where-Object { -not $_.Problem })
    # How long one page may take before the browser is stopped. Not a judgement of how
    # fast it should be - the Judge's thresholds are that - but a bound on how long a
    # hanging site can hold up the Run.
    $limit = ConvertTo-Number (Get-Parameter $Parameters 'TimeLimitSeconds' $script:WebsiteTimeLimitSeconds)
    if ($null -eq $limit -or $limit -lt 1) { $limit = $script:WebsiteTimeLimitSeconds }
    $folder = "$(Get-DataProperty (Get-ObservedKindData -Observed $Observed -Kind 'RunFolder') 'Path')"
    $network = Get-ObservedKindData -Observed $Observed -Kind 'Network'
    $browsers = Get-ObservedKindData -Observed $Observed -Kind 'Browser'

    $browser = $null
    if ($targets.Count) { $browser = Find-WebsiteBrowser }
    $sites = @(if ($browser) {
        foreach ($target in $targets) {
            Invoke-WebsiteLoad -Target $target -Browser $browser -Folder $folder -TimeLimitSeconds ([int]$limit)
        }
    })

    # What the Judge decides each host's Zone from: the machine's own subnets, and the
    # adapter the route to each address the page connected to leaves through. Read, not
    # judged; see Get-OwnPrefix and Get-AddressRoute.
    $addresses = @(foreach ($site in $sites) {
        foreach ($h in (Get-DataCollection (Get-DataProperty $site 'Page') 'Hosts')) {
            if ("$(Get-DataProperty $h 'RemoteAddress')" -match '^\[?(?<address>[^\]]+?)\]?:\d+$') { $Matches['address'] }
        }
    })
    $addresses = @($addresses | Where-Object { $_ } | Select-Object -Unique)

    [pscustomobject]@{
        PSTypeName       = 'Gutcheck.Data.Website'
        # When it was read, so the Judge can say how long a certificate has left or has
        # been expired without looking at a clock of its own.
        GatheredAt       = Get-Date
        Browser          = $browser
        NoBrowser        = [bool]($targets.Count -and -not $browser)
        TimeLimitSeconds = [int]$limit
        # The browser policies as the Browser Check read them, copied as rows: which proxy
        # they force is the Judge's to say. Absent where that Check did not run.
        BrowserObserved  = $null -ne $browsers
        BrowserPolicies  = Get-DataCollection $browsers 'Policies'
        Sites            = $sites
        Prefixes         = @(if ($addresses.Count) { Get-OwnPrefix })
        Routes           = @($addresses | ForEach-Object { Get-AddressRoute -Address $_ })
    } | Add-NetworkProxyObserved -Network $network
}

function ConvertTo-WebsiteFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    $entries = @(@(Get-Parameter $Parameters 'Sites' @()) | Where-Object { $null -ne $_ })
    if (-not $entries.Count) {
        return New-Finding -Category Website -Check (Get-Text 'Check.Website.Sites') -Severity INFO `
            -Value (Get-Text 'Value.Website.NoneConfigured') -Hint (Get-Text 'Hint.Website.NoneConfigured')
    }

    $observations = Get-DataCollection $Data 'Sites'
    $limit = ConvertTo-Number (Get-DataProperty $Data 'TimeLimitSeconds')
    if ($null -eq $limit) { $limit = $script:WebsiteTimeLimitSeconds }

    foreach ($target in @($entries | ForEach-Object { ConvertTo-WebsiteTarget -Entry $_ })) {
        $check = (Get-Text 'Check.Website.Site') -f $target.Label

        $findings = @(
            if ($target.Problem) {
                New-Finding -Category Website -Check $check -Severity INFO `
                    -Value ((Get-Text 'Value.Website.InvalidEntry') -f $target.Url) -Hint (Get-Text 'Hint.Website.InvalidEntry')
            }
            elseif ([bool](Get-DataProperty $Data 'NoBrowser')) {
                # The Check could not run, and says so rather than looking like a clean result.
                New-Finding -Category Website -Check $check -Severity INFO `
                    -Value (Get-Text 'Value.Website.NoBrowser') -Hint (Get-Text 'Hint.Website.NoBrowser')
            }
            else {
                $observation = @($observations | Where-Object { "$(Get-DataProperty $_ 'Key')" -eq $target.Key }) | Select-Object -First 1
                if (-not $observation) {
                    New-UnavailableFinding -Category Website -Check $check -Hint (Get-Text 'Hint.Website.NotRequested')
                }
                else {
                    $verdict = New-WebsiteVerdict -Check $check -Target $target -Observation $observation -LimitSeconds $limit -Parameters $Parameters -At (Get-DataProperty $Data 'GatheredAt')
                    $verdict
                    $timing = New-WebsiteTimingFinding -Target $target -Observation $observation -Parameters $Parameters
                    $timing
                    New-WebsiteCertificateFinding -Target $target -Observation $observation -Parameters $Parameters -At (Get-DataProperty $Data 'GatheredAt')
                    # Where the page loaded without fault, what this Check could not see: the
                    # User's own session. A problem that only happens there is still possible.
                    if ($verdict -and $verdict.Severity -eq 'OK') {
                        New-Finding -Category Website -Check ((Get-Text 'Check.Website.OwnSession') -f $target.Label) -Severity INFO `
                            -Value (Get-Text 'Value.Website.OwnSession') -Hint (Get-Text 'Hint.Website.NetExport')
                    }
                    $others = New-WebsiteOtherHostFinding -Target $target -Observation $observation
                    $others
                    New-WebsiteProxyFinding -Target $target -Observation $observation -Data $Data -Parameters $Parameters
                    New-WebsiteAftermath -Target $target -Observation $observation -LimitSeconds $limit
                    # And each step of the page at each host, for the waterfall, each naming
                    # the Finding that judges it. ADR-0009.
                    ConvertTo-WebsiteMeasurement -Target $target -Observation $observation -Data $Data -Parameters $Parameters `
                        -Verdict $verdict -Timing $timing -OtherHosts $others
                }
            }
        )
        # The application the site belongs to, where its entry names one: the Run keeps it
        # on the Finding (Set-CheckOrigin), and the Report shows it under that application.
        foreach ($finding in $findings) {
            if ($target.App) { $finding.App = $target.App }
            $finding
        }
    }
}

function New-WebsiteVerdict {
    <#
    .SYNOPSIS
        The one Finding for one site: did the page load, and if not, why. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)]$Target,
        [Parameter(Mandatory)]$Observation,
        [double]$LimitSeconds = $script:WebsiteTimeLimitSeconds,
        [hashtable]$Parameters = @{},
        # When the page was read; how long a certificate has been expired is counted from it.
        [AllowNull()]$At
    )

    # Where a page that was sent on ended at a sign-in page: in its host or its path, a
    # word sign-in pages are called by. A rule of thumb; a Customer's own sign-in page
    # may be called otherwise, and a Definition can say how.
    $loginPattern = Get-Parameter $Parameters 'LoginPattern' '(?i)(^|[./_-])(login|logon|signin|sign-in|anmeld\w*|auth|authorize|sso|adfs|oauth2?|saml\w*)([./?_-]|\z)'

    $page     = Get-DataProperty $Observation 'Page'
    $timedOut = [bool](Get-DataProperty $Observation 'TimedOut')
    $started  = Get-DataProperty $Observation 'Error'
    $problem  = "$(Get-DataProperty $page 'Problem')"

    if ($started) {
        return New-Finding -Category Website -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.Website.NotStarted') -f (Get-DataProperty $Observation 'Browser'), $started) `
            -Hint (Get-Text 'Hint.Website.NotStarted')
    }
    if (-not $page -or $problem -eq 'Unreadable') {
        return New-Finding -Category Website -Check $Check -Severity INFO `
            -Value (Get-Text 'Value.Website.Unreadable') -Hint (Get-Text 'Hint.Website.Unreadable')
    }
    if ($problem -eq 'NoPage') {
        # Within the time limit the browser did not even get to ask: the page is what it
        # hung on, or something before it - the proxy's configuration, for one.
        if ($timedOut) {
            return New-Finding -Category Website -Check $Check -Severity FAIL `
                -Value ((Get-Text 'Value.Website.NothingWithin') -f $LimitSeconds) -Hint (Get-Text 'Hint.Website.TimedOut')
        }
        # The browser's exit code has no list of meanings; what it means here is that the
        # browser ended without asking for the page, which the sentence says beside it.
        return New-Finding -Category Website -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.Website.NotRequested') -f (Get-DataProperty $Observation 'ExitCode')) `
            -Hint (Get-Text 'Hint.Website.NotRequested')
    }

    $failure = Get-DataProperty $page 'Error'
    if ($failure) {
        $final = ConvertTo-WebsiteHostName "$(Get-DataProperty $page 'FinalUrl')"
        $value = (Get-Text 'Value.Website.NotLoaded') -f (Format-WebsiteNetError $failure), $final
        # A certificate the browser would not take: who issued it says whose it is. A
        # firewall that decrypts and re-signs is recognised by its own name here.
        if ((Get-WebsiteNetErrorKind $failure) -eq 'Certificate') {
            $facts = @((Get-DataCollection $page 'Hosts') | Where-Object { "$(Get-DataProperty $_ 'Host')" -eq $final }) | Select-Object -First 1
            $certificate = Get-DataProperty $facts 'Certificate'
            $issuer = "$(Get-DataProperty $certificate 'Issuer')"
            if ($issuer) {
                $value = (Get-Text 'Value.Website.NotLoadedCertificate') -f (Format-WebsiteNetError $failure), $final, (Get-HttpsEndpointIssuerName $issuer)
            }
            # And what the certificate says of itself: an expired one since when, one for
            # another name for which names. That is what the Technician tells the operator.
            $value += Format-WebsiteCertificateFacts -Certificate $certificate -At $At
        }
        return New-Finding -Category Website -Check $Check -Severity FAIL -Value $value -Hint (Get-WebsiteNetErrorHint $failure)
    }

    $status = ConvertTo-Number (Get-DataProperty $page 'Status')
    if ($null -eq $status) {
        if ($timedOut) {
            return New-Finding -Category Website -Check $Check -Severity FAIL `
                -Value ((Get-Text 'Value.Website.NoAnswerWithin') -f $LimitSeconds) -Hint (Get-Text 'Hint.Website.TimedOut')
        }
        return New-Finding -Category Website -Check $Check -Severity INFO `
            -Value (Get-Text 'Value.Website.Unfinished') -Hint (Get-Text 'Hint.Website.NotRequested')
    }

    $expected = @($Target.ExpectStatus)
    $wanted = @($expected | ForEach-Object { if ($_.From -eq $_.To) { "$($_.From)" } else { '{0}-{1}' -f $_.From, $_.To } }) -join ', '
    # Windows sign-in: Negotiate (Kerberos, NTLM inside it) and NTLM. What the site asked
    # for is said either way; that it ended at 401 is the sign-in failing.
    $signIn  = Get-DataProperty $page 'SignIn'
    $windows = @((Get-DataCollection $signIn 'Schemes') | Where-Object { "$_" -match '^(?i)(negotiate|ntlm|kerberos)$' })
    $answered = @($expected | Where-Object { $status -ge $_.From -and $status -le $_.To }).Count -gt 0
    if (-not $answered -and $status -eq 401 -and $windows.Count) {
        $hint = Get-Text 'Hint.Website.SignInRejected'
        if ((Get-DataProperty $signIn 'DefaultCredentials') -eq $false) { $hint = Get-Text 'Hint.Website.SignInNotSent' }
        return New-Finding -Category Website -Check $Check -Severity FAIL `
            -Value ((Get-Text 'Value.Website.SignInFailed') -f ($windows -join ', ')) -Hint $hint
    }
    if (-not $answered) {
        return New-Finding -Category Website -Check $Check -Severity FAIL `
            -Value ((Get-Text 'Value.Website.UnexpectedStatus') -f (Format-HttpStatus ([int]$status)), $wanted) `
            -Hint (Get-Text 'Hint.Website.UnexpectedStatus')
    }

    $requests = (Get-DataCollection $page 'Requests').Count
    $hosts    = @((Get-DataCollection $page 'Hosts') | Where-Object { (Get-WebsiteHostOutcome $_).Needed }).Count
    $finalUrl = "$(Get-DataProperty $page 'FinalUrl')"
    $sentOn = $finalUrl -and $finalUrl -ne "$(Get-DataProperty $page 'Url')"
    $atLogin = $false
    if ($sentOn -and $loginPattern) {
        $u = $null
        if ([uri]::TryCreate($finalUrl, [UriKind]::Absolute, [ref]$u)) {
            try { $atLogin = ($u.Host + $u.AbsolutePath) -match $loginPattern } catch { $atLogin = $false }
        }
    }
    # Not broken: the page works up to the sign-in, which Gutcheck does not do. Said so,
    # so that nobody reads "ends at a login page" as "does not load".
    if ($atLogin) {
        return New-Finding -Category Website -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.Website.LoginPage') -f (Format-HttpStatus ([int]$status)), $finalUrl) -Hint (Get-Text 'Hint.Website.LoginPage')
    }

    $value = (Get-Text 'Value.Website.Loaded') -f (Format-HttpStatus ([int]$status)), $requests, $hosts
    if ($sentOn) {
        $value = (Get-Text 'Value.Website.LoadedAt') -f (Format-HttpStatus ([int]$status)), $requests, $hosts, $finalUrl
    }
    if ($windows.Count -and $status -ne 401) { $value += (Get-Text 'Value.Website.SignedIn') -f ($windows -join ', ') }
    New-Finding -Category Website -Check $Check -Severity OK -Value $value
}

function Format-WebsiteCertificateFacts {
    <#
    .SYNOPSIS
        A certificate's names and the time it is valid, and how far $At lies outside that
        time, as the end of a Value. Nothing where the certificate says nothing. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Certificate, [AllowNull()]$At)

    $from  = Get-DataProperty $Certificate 'NotBefore'
    $until = Get-DataProperty $Certificate 'NotAfter'
    if (-not $from -or -not $until) { return '' }

    # The first few names; a certificate for a hundred names is not read for the hundredth.
    $names = @((Get-DataCollection $Certificate 'Names') | Where-Object { $_ })
    $named = (@($names | Select-Object -First 4)) -join ', '
    if ($names.Count -gt 4) { $named = (Get-Text 'Value.Website.CertificateMoreNames') -f $named, ($names.Count - 4) }

    $text = (Get-Text 'Value.Website.CertificateFacts') -f $named, [datetime]$from, [datetime]$until
    if ($At) {
        if ([datetime]$until -lt [datetime]$At) {
            $text += (Get-Text 'Value.Website.CertificateExpiredSince') -f [math]::Floor(([datetime]$At - [datetime]$until).TotalDays)
        }
        elseif ([datetime]$from -gt [datetime]$At) {
            $text += (Get-Text 'Value.Website.CertificateNotYetValid') -f [math]::Floor(([datetime]$from - [datetime]$At).TotalDays)
        }
    }
    $text
}

function New-WebsiteCertificateFinding {
    <#
    .SYNOPSIS
        A warning where the certificate of a page that loaded runs out soon. Pure.
    .DESCRIPTION
        Fourteen days by default, and not the sixty the HttpsEndpoint Kind warns at: a
        website's certificate is often one that renews itself thirty days before it runs
        out (Let's Encrypt and the like), and one that has fourteen days left is one whose
        renewal failed. A rule of thumb; a Definition can say otherwise.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Target,
        [Parameter(Mandatory)]$Observation,
        [hashtable]$Parameters = @{},
        [AllowNull()]$At
    )

    $warnDays = ConvertTo-Number (Get-Parameter $Parameters 'CertificateWarnDays' 14)
    $page = Get-DataProperty $Observation 'Page'
    if (-not $At -or -not $page -or (Get-DataProperty $page 'Error')) { return }

    $final = ConvertTo-WebsiteHostName "$(Get-DataProperty $page 'FinalUrl')"
    $facts = @((Get-DataCollection $page 'Hosts') | Where-Object { "$(Get-DataProperty $_ 'Host')" -eq $final }) | Select-Object -First 1
    $certificate = Get-DataProperty $facts 'Certificate'
    $until = Get-DataProperty $certificate 'NotAfter'
    if (-not $until) { return }

    $left = [math]::Floor(([datetime]$until - [datetime]$At).TotalDays)
    if ($left -lt 0 -or $left -ge $warnDays) { return }

    New-Finding -Category Website -Check ((Get-Text 'Check.Website.Certificate') -f $final) -Severity WARN `
        -Value ((Get-Text 'Value.Website.CertificateExpiresSoon') -f $left, [datetime]$until, (Get-HttpsEndpointIssuerName "$(Get-DataProperty $certificate 'Issuer')")) `
        -Meaning (Get-Text 'Meaning.Website.CertificateExpiresSoon') -Hint (Get-Text 'Hint.Website.CertificateExpiresSoon')
}

function New-WebsiteOtherHostFinding {
    <#
    .SYNOPSIS
        The hosts beyond the page's own where requests failed, beside a page that
        answered: a WARN naming them. Nothing where there are none. Pure.
    .DESCRIPTION
        "The page loads but stays white" is a page whose own host answers and whose CDN,
        API or sign-in host does not. Only beside a page that answered: where the page
        itself did not, that is the Finding, and the rest follows from it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation)

    $page = Get-DataProperty $Observation 'Page'
    if ($null -eq (ConvertTo-Number (Get-DataProperty $page 'Status')) -or (Get-DataProperty $page 'Error')) { return }
    $own = @((Get-DataCollection $page 'PageHosts') | ForEach-Object { "$_" })

    # Failed, or never answered: a CDN that hangs holds a page up as surely as one that
    # refuses. Each host named is a part of the Finding, by the name its Measurements carry
    # (ADR-0009): a host beside it where nothing failed names no Finding at all.
    $parts = [ordered]@{}
    $named = @(foreach ($h in (Get-DataCollection $page 'Hosts')) {
        $name = "$(Get-DataProperty $h 'Host')"
        if ($own -contains $name) { continue }
        $outcome = Get-WebsiteHostOutcome $h
        $requests = Get-DataProperty $h 'Requests'
        if (($outcome.Failed -or $outcome.Unfinished) -and $name.Trim()) { $parts[$name] = 'WARN' }
        if ($outcome.Failed) {
            $said = (Get-Text 'Value.Website.OtherHost') -f $name, $outcome.Failed, $requests, (@($outcome.Errors | ForEach-Object { Format-WebsiteNetError $_ }) -join ', ')
            # A certificate the browser would not take there: who issued it says whose it is.
            $issuer = "$(Get-DataProperty (Get-DataProperty $h 'Certificate') 'Issuer')"
            if ($issuer -and @($outcome.Errors | Where-Object { (Get-WebsiteNetErrorKind $_) -eq 'Certificate' }).Count) {
                $said = (Get-Text 'Value.Website.OtherHostIssuer') -f $said, (Get-HttpsEndpointIssuerName $issuer)
            }
            $said
        }
        if ($outcome.Unfinished) { (Get-Text 'Value.Website.OtherHostOpen') -f $name, $outcome.Unfinished, $requests }
    }) -join '; '
    if (-not $named) { return }
    New-Finding -Category Website -Check ((Get-Text 'Check.Website.OtherHosts') -f $Target.Label) -Severity WARN `
        -Value ((Get-Text 'Value.Website.OtherHostsFailed') -f $named) -Hint (Get-Text 'Hint.Website.OtherHosts') -Subjects $parts
}

function New-WebsiteProxyFinding {
    <#
    .SYNOPSIS
        The proxy the page went through, how Edge found it, and - where the Network and
        Browser Checks ran - the User's proxy, the WinHTTP proxy and the one a policy
        forces beside it. Nothing where the net log shows no proxy configuration. Pure.
    .DESCRIPTION
        WARN for what is known to cost a page its time or its answer: finding the proxy
        took long (ProxyDetectWarnMs, 2 s - a rule of thumb: the classic WPAD timeout
        that makes every first page slow is well above it, a working detection well
        below), a PAC file that could not be fetched, a proxy that asked for a sign-in.
        INFO where Edge did not take its proxy from Windows: that is a policy, and the
        answer to "it works in one program and not the other". OK otherwise.
        Where neither Check ran, the Finding states what Edge used and compares nothing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [AllowNull()]$Data, [hashtable]$Parameters = @{})

    $warnMs = ConvertTo-Number (Get-Parameter $Parameters 'ProxyDetectWarnMs' 2000)
    $proxy = Get-DataProperty (Get-DataProperty $Observation 'Page') 'Proxy'
    if (-not $proxy) { return }

    $mode = "$(Get-DataProperty $proxy 'Mode')"
    # In the words the Browsers Check uses for the proxy a policy sets: the same modes.
    if ($mode -notin 'Detect', 'Script', 'Server') { $mode = 'Direct' }
    $edge = Get-BrowserPolicyProxyText -Proxy ([pscustomobject]@{
        Mode = $mode; Server = "$(Get-DataProperty $proxy 'Server')"; Script = "$(Get-DataProperty $proxy 'PacUrl')" })
    $fromSystem = Get-DataProperty $proxy 'FromSystem'
    if ($fromSystem -eq $true)      { $edge = (Get-Text 'Value.Website.ProxyFrom.System') -f $edge }
    elseif ($fromSystem -eq $false) { $edge = (Get-Text 'Value.Website.ProxyFrom.Policy') -f $edge }
    $used = "$(Get-DataProperty $proxy 'Used')".Trim()
    if ($used) {
        $first = ($used -split ';')[0].Trim()
        $shown = $first -replace '^(?i)(PROXY|HTTPS|SOCKS5?|QUIC)\s+', ''
        if ($first -match '^(?i)DIRECT$') { $shown = Get-BrowserProxyModeText -Mode 'Direct' }
        $edge = (Get-Text 'Value.Website.ProxyUsed') -f $edge, $shown
    }

    $parts = @($edge)
    # The proxy a policy forces on the browser that loaded the page, from the policies the
    # Browsers Check read.
    $policy = $null
    $browserName = "$(Get-DataProperty (Get-DataProperty $Data 'Browser') 'Name')"
    if ($browserName) {
        $policy = Get-BrowserPolicyProxy -Data ([pscustomobject]@{ Policies = (Get-DataCollection $Data 'BrowserPolicies') }) `
            -Browser ([pscustomobject]@{ Browser = $browserName; Engine = 'Chromium' })
    }
    $network = [bool](Get-DataProperty $Data 'NetworkObserved')
    if ($network) {
        $parts += (Get-Text 'Value.Website.Proxy.User') -f (Get-UserProxyDescription (Get-DataProperty $Data 'UserProxy'))
        $view = @(Get-WinHttpProxyView -Data $Data)[0]
        $parts += (Get-Text 'Value.Website.Proxy.WinHttp') -f (Get-WinHttpProxyDescription $view.Parsed)
    }
    if ($policy) { $parts += (Get-Text 'Value.Website.Proxy.Policy') -f (Get-BrowserPolicyProxyText -Proxy $policy) }
    elseif (-not [bool](Get-DataProperty $Data 'BrowserObserved')) { $parts += Get-Text 'Value.Website.Proxy.PolicyNotCompared' }
    $value = $parts -join '; '
    $check = (Get-Text 'Check.Website.Proxy') -f $Target.Label

    if ([bool](Get-DataProperty $proxy 'SignInAsked')) {
        return New-Finding -Category Website -Check $check -Severity WARN `
            -Value ((Get-Text 'Value.Website.ProxySignIn') -f $value) -Hint (Get-Text 'Hint.Website.ProxySignIn')
    }
    $pacFailed = @((Get-DataCollection $proxy 'PacFetches') | Where-Object { (Get-DataProperty $_ 'Error') -and -not (Get-DataProperty $_ 'Wpad') }) | Select-Object -First 1
    if ($pacFailed) {
        return New-Finding -Category Website -Check $check -Severity WARN `
            -Value ((Get-Text 'Value.Website.PacFailed') -f $value, (Format-WebsiteNetError (Get-DataProperty $pacFailed 'Error'))) `
            -Hint (Get-Text 'Hint.Website.PacFailed')
    }
    $took = ConvertTo-Number (Get-DataProperty $proxy 'ResolveMs')
    if ($mode -in 'Detect', 'Script' -and $null -ne $took -and $null -ne $warnMs -and $took -gt $warnMs) {
        return New-Finding -Category Website -Check $check -Severity WARN `
            -Value ((Get-Text 'Value.Website.ProxySlow') -f $value, ($took / 1000)) -Hint (Get-Text 'Hint.Website.ProxySlow')
    }
    $differs = $fromSystem -eq $false
    if ($policy -and $network -and -not (Test-BrowserProxySame -Proxy $policy -UserProxy (Get-DataProperty $Data 'UserProxy'))) { $differs = $true }
    if ($differs) {
        return New-Finding -Category Website -Check $check -Severity INFO -Value $value `
            -Meaning (Get-Text 'Meaning.Website.ProxyDiffers') -Hint (Get-Text 'Hint.Website.ProxyDiffers')
    }
    New-Finding -Category Website -Check $check -Severity OK -Value $value
}

function New-WebsiteTimingFinding {
    <#
    .SYNOPSIS
        Where the time of a page that answered went: name resolution, connection and TLS
        at its own host, the wait for its first byte, and the whole load. Pure.
    .DESCRIPTION
        Each against a parameter of the Check Definition. The defaults are rules of
        thumb, not anybody's documented limit: a name lookup, a connection or a TLS
        handshake within a network takes milliseconds, and one that takes a second is
        felt on every page; a server that has not begun to answer after three seconds is
        what a User calls "hangs"; a page that needs ten seconds in all is what they call
        slow. A step the page did not take - a connection it already had open - is
        left out rather than counted as quick.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [hashtable]$Parameters = @{})

    $page = Get-DataProperty $Observation 'Page'
    if ($null -eq (ConvertTo-Number (Get-DataProperty $page 'Status')) -or (Get-DataProperty $page 'Error')) { return }
    $timings = Get-DataProperty $page 'Timings'
    if (-not $timings) { return }

    $steps = @(
        @{ Step = 'Dns';       Ms = ConvertTo-Number (Get-DataProperty $timings 'DnsMs');       Limit = ConvertTo-Number (Get-Parameter $Parameters 'DnsWarnMs' 1000) }
        @{ Step = 'Connect';   Ms = ConvertTo-Number (Get-DataProperty $timings 'ConnectMs');   Limit = ConvertTo-Number (Get-Parameter $Parameters 'ConnectWarnMs' 1000) }
        @{ Step = 'Tls';       Ms = ConvertTo-Number (Get-DataProperty $timings 'TlsMs');       Limit = ConvertTo-Number (Get-Parameter $Parameters 'TlsWarnMs' 1000) }
        @{ Step = 'FirstByte'; Ms = ConvertTo-Number (Get-DataProperty $timings 'FirstByteMs'); Limit = ConvertTo-Number (Get-Parameter $Parameters 'FirstByteWarnMs' 3000) }
        @{ Step = 'Load';      Ms = ConvertTo-Number (Get-DataProperty $timings 'LoadMs');      Limit = ConvertTo-Number (Get-Parameter $Parameters 'LoadWarnMs' 10000) }
    )
    $shown = { param($Ms) if ($null -eq $Ms) { '-' } else { $Ms } }
    $load = $steps[4].Ms
    $loadSeconds = $(if ($null -eq $load) { '-' } else { $load / 1000 })
    if ($null -eq $steps[0].Ms -and $null -eq $steps[1].Ms -and $null -eq $steps[2].Ms) {
        $value = (Get-Text 'Value.Website.TimingsReused') -f (& $shown $steps[3].Ms), $loadSeconds
    }
    elseif ($null -eq $steps[0].Ms -and [bool](Get-DataProperty $timings 'DnsCached')) {
        # A name the browser had in its cache took no time to look up, and is no 0 ms
        # reading of the DNS server: it says where the answer came from instead.
        $value = (Get-Text 'Value.Website.TimingsCached') -f (& $shown $steps[1].Ms), (& $shown $steps[2].Ms), (& $shown $steps[3].Ms), $loadSeconds
    }
    else {
        $value = (Get-Text 'Value.Website.Timings') -f (& $shown $steps[0].Ms), (& $shown $steps[1].Ms), (& $shown $steps[2].Ms), (& $shown $steps[3].Ms), $loadSeconds
    }

    $slow = @($steps | Where-Object { $null -ne $_.Ms -and $null -ne $_.Limit -and $_.Ms -gt $_.Limit })
    $check = (Get-Text 'Check.Website.Timings') -f $Target.Label
    if (-not $slow.Count) { return New-Finding -Category Website -Check $check -Severity OK -Value $value }

    $named = @(foreach ($one in $slow) {
        switch ($one.Step) {
            'Dns'       { Get-Text 'Value.Website.Slow.Dns' }
            'Connect'   { Get-Text 'Value.Website.Slow.Connect' }
            'Tls'       { Get-Text 'Value.Website.Slow.Tls' }
            'FirstByte' { Get-Text 'Value.Website.Slow.FirstByte' }
            'Load'      { Get-Text 'Value.Website.Slow.Load' }
        }
    }) -join ', '
    # The first slow step in the order the page takes them: what comes after it waited
    # on it.
    $hint = switch ($slow[0].Step) {
        'Dns'       { Get-Text 'Hint.Website.Slow.Dns' }
        'Connect'   { Get-Text 'Hint.Website.Slow.Connect' }
        'Tls'       { Get-Text 'Hint.Website.Slow.Tls' }
        'FirstByte' { Get-Text 'Hint.Website.Slow.FirstByte' }
        'Load'      { Get-Text 'Hint.Website.Slow.Load' }
    }
    New-Finding -Category Website -Check $check -Severity WARN -Value ((Get-Text 'Value.Website.SlowSteps') -f $value, $named) -Hint $hint
}

# Each step of a page at a host as the net log parse names it, and the Metric it is drawn
# as. The span of a host's requests is drawn as Response: from the first of them sent to
# the last of them done.
$script:WebsiteStepMetric = [ordered]@{
    Dns       = 'Dns'
    Connect   = 'TcpConnect'
    Tls       = 'Tls'
    FirstByte = 'FirstByte'
    Response  = 'Response'
}

function ConvertTo-WebsiteStepMeasurement {
    <#
    .SYNOPSIS
        One step of a page as a Measurement: measured, from the cache, failed with how long
        it took to, or not measured where the net log does not show it. Pure.
    .DESCRIPTION
        A step the browser was stopped in got no answer, and has no number: its end is
        not known. One that failed with an error the browser caused itself - an abort as it
        closed the page - was not measured rather than failed: no Finding counts it either.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Step, [Parameter(Mandatory)][hashtable]$Common, [Parameter(Mandatory)][string]$Metric)

    $state = "$(Get-DataProperty $Step 'State')"
    $start = Get-DataProperty $Step 'StartMs'
    $failure = Get-DataProperty $Step 'Error'
    switch ($state) {
        'Measured' { return New-Measurement @Common -Metric $Metric -StartMs $start -Value (Get-DataProperty $Step 'Ms') }
        'Cached'   { return New-Measurement @Common -Metric $Metric -StartMs $start -State Cached }
        'Open'     { return New-Measurement @Common -Metric $Metric -StartMs $start -State Failed -ErrorText (Get-Text 'Measurement.Error.CutOff') }
        'Failed' {
            if ((Get-WebsiteNetErrorKind $failure) -eq 'Dropped') { return New-Measurement @Common -Metric $Metric -State NotMeasured }
            return New-Measurement @Common -Metric $Metric -StartMs $start -State Failed -Value (Get-DataProperty $Step 'Ms') `
                -ErrorText (Format-WebsiteNetError $failure)
        }
    }
    New-Measurement @Common -Metric $Metric -State NotMeasured
}

function ConvertTo-WebsiteMeasurement {
    <#
    .SYNOPSIS
        What the page of one site measured, for the waterfall: the page's first byte and
        whole load (Role Website), then each step at each host it needed (Role WebsiteHost),
        on one time axis from the page's start. Pure.
    .DESCRIPTION
        The page's own come first and its hosts after, each host's steps in the order a
        connection takes them. Each names the site as its Group, the address as the
        Definition gives it: the waterfall draws one card per Group.
 
        StartMs is counted from when the browser began on the page; a name looked up
        before the page's own request starts at 0 and the request a little after.
 
        Each names the Finding that judges it: at the page's own host the timing Finding
        (the verdict where the page did not load), at a host the page was sent on through
        the verdict, at another host the Finding that names the hosts where requests failed
        - or none where nothing failed there, and a view draws it grey.
 
        The Zone is the host's address's, by the machine's own subnets and the route the
        Gatherer read to it. A host reached through a proxy was not resolved or connected to
        by this machine: it has no address, and every step says which proxy it went through.
    .PARAMETER Verdict
        The Finding of whether the page loaded.
    .PARAMETER Timing
        The Finding of where the page's time went, where there is one.
    .PARAMETER OtherHosts
        The Finding naming the hosts beyond the page's own where requests failed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Target,
        [Parameter(Mandatory)]$Observation,
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        [AllowNull()]$Verdict,
        [AllowNull()]$Timing,
        [AllowNull()]$OtherHosts
    )

    $page = Get-DataProperty $Observation 'Page'
    if (-not $page -or "$(Get-DataProperty $page 'Problem')") { return }
    $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' $script:MeasurementVpnAdapterPattern
    $pageFinding = $Timing
    if (-not $pageFinding) { $pageFinding = $Verdict }

    $hosts = @((Get-DataCollection $page 'Hosts') | Where-Object { (Get-WebsiteHostOutcome $_).Needed })
    $finalHost = ConvertTo-WebsiteHostName "$(Get-DataProperty $page 'FinalUrl')"
    $own = @((Get-DataCollection $page 'PageHosts') | ForEach-Object { "$_" })

    # Where a host was reached: the address it was connected to, else the first it
    # resolved to; through a proxy, none of its own.
    $whereOf = {
        param($HostFacts)
        $address = ''; $port = $null
        if (-not "$(Get-DataProperty $HostFacts 'Proxy')") {
            $remote = "$(Get-DataProperty $HostFacts 'RemoteAddress')"
            if ($remote -match '^\[?(?<address>[^\]]+?)\]?:(?<port>\d+)$') { $address = $Matches['address']; $port = [int]$Matches['port'] }
            elseif ($remote) { $address = $remote }
            else { $address = "$(@((Get-DataCollection $HostFacts 'Addresses'))[0])" }
        }
        [pscustomobject]@{ Address = $address; Port = $port }
    }

    # The page itself: its first byte and its whole load, at the host it ended at.
    $timings = Get-DataProperty $page 'Timings'
    $finalFacts = @($hosts | Where-Object { "$(Get-DataProperty $_ 'Host')" -eq $finalHost }) | Select-Object -First 1
    $at = & $whereOf $finalFacts
    $common = @{ Subject = $Target.Url; Address = $at.Address; Role = 'Website'; Samples = 1; Port = $at.Port; Group = $Target.Url
                 Zone = (Get-GatheredAddressZone -Data $Data -Address $at.Address -VpnPattern $vpnPattern)
                 Proxy = "$(Get-DataProperty $finalFacts 'Proxy')"; Finding = $pageFinding }
    $firstByte = ConvertTo-Number (Get-DataProperty $timings 'FirstByteMs')
    if ($null -eq $firstByte) { New-Measurement @common -Metric FirstByte -State NotMeasured }
    else { New-Measurement @common -Metric FirstByte -StartMs (Get-DataProperty $timings 'FirstByteStartMs') -Value $firstByte }

    $failure = Get-DataProperty $page 'Error'
    $pageStart = Get-DataProperty $timings 'StartMs'
    $load = ConvertTo-Number (Get-DataProperty $timings 'LoadMs')
    if ($failure) {
        New-Measurement @common -Metric Response -StartMs $pageStart -State Failed -Value (Get-DataProperty $page 'ElapsedMs') `
            -ErrorText (Format-WebsiteNetError $failure)
    }
    elseif (-not [bool](Get-DataProperty $page 'Finished')) {
        New-Measurement @common -Metric Response -StartMs $pageStart -State Failed -ErrorText (Get-Text 'Measurement.Error.CutOff')
    }
    elseif ($null -eq $load) { New-Measurement @common -Metric Response -State NotMeasured }
    else { New-Measurement @common -Metric Response -StartMs $pageStart -Value $load }

    # Each host the page needed, each step of it.
    foreach ($facts in $hosts) {
        $name = "$(Get-DataProperty $facts 'Host')"
        $belongs = $null
        if ($name -eq $finalHost) { $belongs = $pageFinding }
        elseif ($own -contains $name) { $belongs = $Verdict }
        else {
            $outcome = Get-WebsiteHostOutcome $facts
            if ($outcome.Failed -or $outcome.Unfinished) { $belongs = $OtherHosts }
        }
        $at = & $whereOf $facts
        $common = @{ Subject = $name; Address = $at.Address; Role = 'WebsiteHost'; Samples = 1; Port = $at.Port; Group = $Target.Url
                     Zone = (Get-GatheredAddressZone -Data $Data -Address $at.Address -VpnPattern $vpnPattern)
                     Proxy = "$(Get-DataProperty $facts 'Proxy')"; Finding = $belongs }
        $steps = Get-DataCollection $facts 'Steps'
        foreach ($stepName in $script:WebsiteStepMetric.Keys) {
            $step = @($steps | Where-Object { "$(Get-DataProperty $_ 'Step')" -eq $stepName }) | Select-Object -First 1
            ConvertTo-WebsiteStepMeasurement -Step $step -Common $common -Metric $script:WebsiteStepMetric[$stepName]
        }
    }
}

function New-WebsiteAftermath {
    <#
    .SYNOPSIS
        What the Check itself left of a site: a load it cut off, the net log it kept, a
        profile it could not delete. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Target,
        [Parameter(Mandatory)]$Observation,
        [double]$LimitSeconds = $script:WebsiteTimeLimitSeconds
    )

    $page = Get-DataProperty $Observation 'Page'
    # Where the page had answered and was still loading more when it was stopped. Where it
    # had not, the verdict already says it was cut off.
    if ([bool](Get-DataProperty $Observation 'TimedOut') -and $null -ne (ConvertTo-Number (Get-DataProperty $page 'Status'))) {
        New-Finding -Category Website -Check ((Get-Text 'Check.Website.CutOff') -f $Target.Label) -Severity INFO `
            -Value ((Get-Text 'Value.Website.CutOff') -f $LimitSeconds) -Hint (Get-Text 'Hint.Website.CutOff')
    }

    $log = "$(Get-DataProperty $Observation 'NetLog')"
    if ($log) {
        New-Finding -Category Website -Check ((Get-Text 'Check.Website.NetLog') -f $Target.Label) -Severity INFO `
            -Value ((Get-Text 'Value.Website.NetLog') -f $log) -Meaning (Get-Text 'Meaning.Website.NetLog') `
            -Hint (Get-Text 'Hint.Website.NetLog')
    }

    $left = "$(Get-DataProperty $Observation 'ProfileLeft')"
    if ($left) {
        New-Finding -Category Website -Check ((Get-Text 'Check.Website.Profile') -f $Target.Label) -Severity INFO `
            -Value ((Get-Text 'Value.Website.ProfileLeft') -f $left) -Hint (Get-Text 'Hint.Website.ProfileLeft')
    }
}

# The net errors this Kind knows by name: what each means in words, and what kind of fault
# it is. The one place a net error is recognised by its name - its words, its Hint, whether
# it is a failure at all are all asked of this table through Get-WebsiteNetErrorKind and
# Get-WebsiteNetErrorText. By name, because the names are stable and the numbers are the
# document's. Kind is Dns, Proxy, Certificate, Connection, Other, or Dropped: the browser's
# own doing, no failure of the site. Chromium aborts what is still in flight when the page
# is closed, which the browser does once it has loaded it; and it asks its cache for a font
# or a preload before it asks the network, which it then does in a request of its own
# (ERR_CACHE_MISS, seen twice for the same address on 2026-10-14). Text is asked for when
# the words are needed.
$script:WebsiteNetError = @{
    'ERR_NAME_NOT_RESOLVED'                    = @{ Kind = 'Dns';         Text = { Get-Text 'Value.Website.NetError.NameNotResolved' } }
    'ERR_NAME_RESOLUTION_FAILED'               = @{ Kind = 'Dns';         Text = { Get-Text 'Value.Website.NetError.NameResolutionFailed' } }
    'ERR_CONNECTION_REFUSED'                   = @{ Kind = 'Connection';  Text = { Get-Text 'Value.Website.NetError.ConnectionRefused' } }
    'ERR_CONNECTION_RESET'                     = @{ Kind = 'Connection';  Text = { Get-Text 'Value.Website.NetError.ConnectionReset' } }
    'ERR_CONNECTION_CLOSED'                    = @{ Kind = 'Connection';  Text = { Get-Text 'Value.Website.NetError.ConnectionClosed' } }
    'ERR_CONNECTION_TIMED_OUT'                 = @{ Kind = 'Connection';  Text = { Get-Text 'Value.Website.NetError.ConnectionTimedOut' } }
    'ERR_TIMED_OUT'                            = @{ Kind = 'Other';       Text = { Get-Text 'Value.Website.NetError.TimedOut' } }
    'ERR_CONNECTION_FAILED'                    = @{ Kind = 'Connection';  Text = { Get-Text 'Value.Website.NetError.ConnectionFailed' } }
    'ERR_ADDRESS_UNREACHABLE'                  = @{ Kind = 'Connection';  Text = { Get-Text 'Value.Website.NetError.AddressUnreachable' } }
    'ERR_INTERNET_DISCONNECTED'                = @{ Kind = 'Connection';  Text = { Get-Text 'Value.Website.NetError.InternetDisconnected' } }
    'ERR_CERT_AUTHORITY_INVALID'               = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertAuthorityInvalid' } }
    'ERR_CERT_COMMON_NAME_INVALID'             = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertCommonNameInvalid' } }
    'ERR_CERT_DATE_INVALID'                    = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertDateInvalid' } }
    'ERR_CERT_REVOKED'                         = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertRevoked' } }
    'ERR_CERT_INVALID'                         = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertInvalid' } }
    'ERR_SSL_PROTOCOL_ERROR'                   = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.SslProtocolError' } }
    'ERR_PROXY_CONNECTION_FAILED'              = @{ Kind = 'Proxy';       Text = { Get-Text 'Value.Website.NetError.ProxyConnectionFailed' } }
    'ERR_TUNNEL_CONNECTION_FAILED'             = @{ Kind = 'Proxy';       Text = { Get-Text 'Value.Website.NetError.TunnelConnectionFailed' } }
    'ERR_MANDATORY_PROXY_CONFIGURATION_FAILED' = @{ Kind = 'Proxy';       Text = { Get-Text 'Value.Website.NetError.ProxyConfigurationFailed' } }
    'ERR_TOO_MANY_REDIRECTS'                   = @{ Kind = 'Other';       Text = { Get-Text 'Value.Website.NetError.TooManyRedirects' } }
    'ERR_ABORTED'                              = @{ Kind = 'Dropped';     Text = { Get-Text 'Value.Website.NetError.Aborted' } }
    'ERR_CACHE_MISS'                           = @{ Kind = 'Dropped' }
}

function Get-WebsiteNetErrorKind {
    <#
    .SYNOPSIS
        What kind of fault a net error is: Dns, Proxy, Certificate, Connection, Dropped
        or Other. Pure.
    .DESCRIPTION
        From the table where it is there; else from the family its name belongs to (a
        proxy's, a certificate's or TLS's), else from the range of its number.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$NetError)

    $name = "$(Get-DataProperty $NetError 'Name')".Trim()
    if ($name -and $script:WebsiteNetError.ContainsKey($name)) { return $script:WebsiteNetError[$name].Kind }
    if ($name -match '^ERR_(PROXY_|TUNNEL_|MANDATORY_PROXY_)') { return 'Proxy' }
    if ($name -match '^ERR_(CERT_|SSL_)') { return 'Certificate' }
    switch (Get-WebsiteNetErrorRange $NetError) {
        'Dns'         { return 'Dns' }
        'Certificate' { return 'Certificate' }
        'Connection'  { return 'Connection' }
    }
    'Other'
}

function Get-WebsiteNetErrorText {
    <#
    .SYNOPSIS
        What a Chromium net error means, in words: the ones of the table each by name, any
        other by what kind of error its number says it is. Pure.
    .DESCRIPTION
        The kinds are Chromium's own ranges (net/base/net_error_list.h): 1-99 the system,
        100-199 the connection, 200-299 and 700-799 certificates, 300-399 HTTP, 400-499 the
        cache, 800-899 name resolution.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$NetError)

    $name = "$(Get-DataProperty $NetError 'Name')".Trim()
    if ($name -and $script:WebsiteNetError.ContainsKey($name) -and $script:WebsiteNetError[$name].Text) {
        return (& $script:WebsiteNetError[$name].Text)
    }
    switch (Get-WebsiteNetErrorRange $NetError) {
        'System'      { return Get-Text 'Value.Website.NetErrorRange.System' }
        'Connection'  { return Get-Text 'Value.Website.NetErrorRange.Connection' }
        'Certificate' { return Get-Text 'Value.Website.NetErrorRange.Certificate' }
        'Http'        { return Get-Text 'Value.Website.NetErrorRange.Http' }
        'Cache'       { return Get-Text 'Value.Website.NetErrorRange.Cache' }
        'Dns'         { return Get-Text 'Value.Website.NetErrorRange.Dns' }
    }
    Get-Text 'Value.Website.NetErrorRange.Other'
}

function Get-WebsiteNetErrorRange {
    <#
    .SYNOPSIS
        Which of Chromium's ranges a net error's number falls in. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$NetError)

    $code = ConvertTo-Number (Get-DataProperty $NetError 'Code')
    if ($null -eq $code) { return 'Other' }
    $n = [math]::Abs($code)
    if ($n -ge 1   -and $n -le 99)  { return 'System' }
    if ($n -ge 100 -and $n -le 199) { return 'Connection' }
    if (($n -ge 200 -and $n -le 299) -or ($n -ge 700 -and $n -le 799)) { return 'Certificate' }
    if ($n -ge 300 -and $n -le 399) { return 'Http' }
    if ($n -ge 400 -and $n -le 499) { return 'Cache' }
    if ($n -ge 800 -and $n -le 899) { return 'Dns' }
    'Other'
}

function Format-WebsiteNetError {
    <#
    .SYNOPSIS
        A net error as a Technician reads it: what it means, with its name beside it - or
        its number where the document gave it no name. Never one of them alone. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$NetError)

    if ($null -eq $NetError) { return '' }
    $name = "$(Get-DataProperty $NetError 'Name')".Trim()
    if (-not $name) { $name = "$(Get-DataProperty $NetError 'Code')".Trim() }
    (Get-Text 'Value.Website.NetError') -f (Get-WebsiteNetErrorText $NetError), $name
}

function Get-WebsiteNetErrorHint {
    <#
    .SYNOPSIS
        Where a net error sends the Technician: name resolution, the proxy, the
        certificate, the connection, or second level. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$NetError)

    switch (Get-WebsiteNetErrorKind $NetError) {
        'Dns'         { return Get-Text 'Hint.Website.NotResolved' }
        'Proxy'       { return Get-Text 'Hint.Website.Proxy' }
        'Certificate' { return Get-Text 'Hint.Website.Certificate' }
        'Connection'  { return Get-Text 'Hint.Website.Connection' }
    }
    Get-Text 'Hint.Website.NotLoaded'
}

function Get-WebsiteHostOutcome {
    <#
    .SYNOPSIS
        What the requests at one host came to, from the errors the net log recorded: how
        many failed, how many the browser dropped itself, the distinct errors of those
        that failed, and whether the host was needed at all. Pure.
    .DESCRIPTION
        A host the walk reached with no request of its own - an attempt the browser raced
        and dropped - was not one the page needed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$HostFacts)

    $failed = New-Object System.Collections.Generic.List[psobject]
    $dropped = 0
    foreach ($failure in (Get-DataCollection $HostFacts 'Failures')) {
        if ((Get-WebsiteNetErrorKind $failure) -eq 'Dropped') { $dropped++ } else { $failed.Add($failure) }
    }
    [pscustomobject]@{
        Needed     = (ConvertTo-Number (Get-DataProperty $HostFacts 'Requests')) -gt 0
        Failed     = $failed.Count
        Dropped    = $dropped
        Unfinished = [int](ConvertTo-Number (Get-DataProperty $HostFacts 'Unfinished'))
        Errors     = @($failed | Group-Object { "$(Get-DataProperty $_ 'Code')" } | ForEach-Object { $_.Group[0] })
    }
}

function ConvertTo-WebsiteSection {
    <#
    .SYNOPSIS
        One table per site: every host its page needed and what happened at it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    foreach ($site in (Get-DataCollection $Data 'Sites')) {
        $hosts = Get-DataCollection (Get-DataProperty $site 'Page') 'Hosts'
        if (-not $hosts.Count) { continue }

        $section = New-Section -Title ((Get-Text 'Title.Website.Hosts') -f (Get-DataProperty $site 'Label')) `
            -Help (Get-Text 'Help.Website.Hosts') -Row @(
            foreach ($h in $hosts) {
                $outcome = Get-WebsiteHostOutcome $h
                if (-not $outcome.Needed) { continue }
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Website.Host')]       = Get-DataProperty $h 'Host'
                $row[(Get-Text 'Column.Website.Requests')]   = Get-DataProperty $h 'Requests'
                $row[(Get-Text 'Column.Website.Failed')]     = $outcome.Failed
                $row[(Get-Text 'Column.Website.Open')]       = Get-DataProperty $h 'Unfinished'
                $row[(Get-Text 'Column.Website.Addresses')]  = (Get-DataCollection $h 'Addresses') -join ', '
                $row[(Get-Text 'Column.Website.Dns')]        = Format-WebsiteStepCell -Done (Get-DataProperty $h 'Resolved') `
                    -Failure (Get-DataProperty $h 'ResolveError') -Shown (Get-Text 'Value.Website.Resolved')
                $row[(Get-Text 'Column.Website.Connection')] = Format-WebsiteStepCell -Done (Get-DataProperty $h 'Connected') `
                    -Failure (Get-DataProperty $h 'ConnectError') -Shown ((Get-Text 'Value.Website.Connected') -f (Get-DataProperty $h 'RemoteAddress'))
                $row[(Get-Text 'Column.Website.Tls')]        = Format-WebsiteStepCell -Done ([bool](Get-DataProperty $h 'Tls')) `
                    -Failure (Get-DataProperty $h 'TlsError') -Shown "$(Get-DataProperty $h 'Tls')"
                $row[(Get-Text 'Column.Website.Issuer')]     = Get-HttpsEndpointIssuerName "$(Get-DataProperty (Get-DataProperty $h 'Certificate') 'Issuer')"
                $until = Get-DataProperty (Get-DataProperty $h 'Certificate') 'NotAfter'
                $row[(Get-Text 'Column.Website.CertificateUntil')] = $(if ($until) { '{0:dd.MM.yyyy}' -f [datetime]$until } else { '' })
                $row[(Get-Text 'Column.Website.Errors')]     = @($outcome.Errors | ForEach-Object { Format-WebsiteNetError $_ }) -join '; '
                [pscustomobject]$row
            })
        if ($section -and "$(Get-DataProperty $site 'App')") { $section.App = "$(Get-DataProperty $site 'App')" }
        $section
    }
}

function Format-WebsiteStepCell {
    <#
    .SYNOPSIS
        One step at a host, for a Section: what it came to, the error it failed with, or
        nothing where the net log does not say. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Done, [AllowNull()]$Failure, [AllowEmptyString()][string]$Shown)

    if ($Done -eq $true) { return $Shown }
    if ($Failure) { return Format-WebsiteNetError $Failure }
    ''
}