Public/Test-ServiceWorkerHealth.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Test-ServiceWorkerHealth {
    <#
    .SYNOPSIS
        Fetches a deployed /sw.js and reports skipWaiting mode, clientsClaim,
        denylist coverage, navigateFallback, precache count, and a content hash.
    .DESCRIPTION
        Background: diagnosing t/1126 required manual curl + grep against
        production to assemble the SW config picture. This cmdlet collapses
        that workflow to one call and returns a structured pass/fail object.
 
        Key check: skipWaiting must be 'auto' (called inside the install/activate
        event). With registerType: 'prompt' on the client side, message-based
        skipWaiting is broken — the SW will install but never activate, and
        clients see stale assets indefinitely. The cmdlet flags this.
    .PARAMETER BaseUrl
        Target server URL. Defaults to current production.
    .PARAMETER ExpectedDenylist
        Regex patterns that MUST appear in the SW's navigateFallbackDenylist.
        Default protects auth + API routes from SPA-fallback hijacking.
    .PARAMETER Detailed
        Print the full precache manifest table to host (in addition to the
        structured ServiceWorkerHealth object).
    .PARAMETER TimeoutSec
        HTTP request timeout (1-60, default 15).
    .EXAMPLE
        Test-ServiceWorkerHealth
    .EXAMPLE
        Test-ServiceWorkerHealth -BaseUrl https://staging.example.com -Detailed
    .EXAMPLE
        # Verify additional patterns are gated against:
        Test-ServiceWorkerHealth -ExpectedDenylist @('\.auth', 'api', '/healthz', '/metrics')
    .LINK
        Show-AITriadHelp
    .LINK
        Test-TaxEditorHealth
    .LINK
        Test-TaxEditorEndpoints
    .LINK
        Test-AnonymousDebateFlow
    .LINK
        Test-PersonaEndpoints
    .LINK
        Get-FreeTierStatus
    .LINK
        Invoke-TaxEditorSmokeTest
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$BaseUrl = (Get-TaxEditorBaseUrl),

        [string[]]$ExpectedDenylist = @('\.auth', 'api'),

        [switch]$Detailed,

        [ValidateRange(1, 60)]
        [int]$TimeoutSec = 15
    )

    Set-StrictMode -Version Latest

    $BaseUrl = $BaseUrl.TrimEnd('/')
    $SwUrl   = "$BaseUrl/sw.js"
    $Result  = [ServiceWorkerHealth]::new()
    $Result.BaseUrl   = $BaseUrl
    $Result.Denylist  = @()
    $Result.MissingDenylist = @()
    $Result.PrecacheManifest = @()
    $Result.Checks    = @()

    # ── Fetch ────────────────────────────────────────────────
    $Body = $null
    try {
        $Resp = Invoke-WebRequest -Uri $SwUrl -Method Get `
            -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop `
            -Headers @{ 'Accept' = 'application/javascript, text/javascript, */*' }
        $Result.StatusCode = [int]$Resp.StatusCode
        $Result.FetchedOk  = ($Result.StatusCode -eq 200)
        $Body = if ($Resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($Resp.Content) } else { [string]$Resp.Content }
        $Result.Bytes = if ($Body) { $Body.Length } else { 0 }
    } catch {
        $Result.StatusCode = 0
        if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
            try { $Result.StatusCode = [int]$_.Exception.Response.StatusCode } catch { }
        }
        $Result.FetchedOk = $false
        $Result.Checks = @(
            [ServiceWorkerHealthCheck]@{ Name = 'fetched'; Pass = $false; Detail = $_.Exception.Message }
        )
        $Result.OverallPass = $false
        Write-Host ("SW Health Check — {0}" -f $BaseUrl) -ForegroundColor White
        Write-Host (" Fetch failed: HTTP {0} — {1}" -f $Result.StatusCode, $_.Exception.Message) -ForegroundColor Red
        return $Result
    }

    # ── Hash ─────────────────────────────────────────────────
    $Sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $HashBytes = $Sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Body))
        $Result.Hash = -join ($HashBytes[0..3] | ForEach-Object { '{0:x2}' -f $_ })
    } finally { $Sha.Dispose() }

    # ── skipWaiting mode ─────────────────────────────────────
    # 'auto' = skipWaiting() invoked unconditionally (install handler / top level)
    # 'message' = invoked only inside a message-event listener gated on SKIP_WAITING
    # 'none' = no skipWaiting found
    # Detection: presence of a SKIP_WAITING message contract is the high-confidence
    # signal for 'message' mode (vite-plugin-pwa with registerType: 'prompt' always
    # emits it). When that's present, skipWaiting() in the file is gated even if it
    # also appears at top level. Pre-check whether skipWaiting is called at all.
    $HasSkipWaitingCall = $Body -match 'skipWaiting\s*\(\s*\)'
    # cmatch = case-sensitive: SKIP_WAITING is the message-contract constant,
    # distinct from the camelCase skipWaiting() function name.
    $HasMessageContract = $Body -cmatch 'SKIP_WAITING'
    $Result.SkipWaitingMode = if ($HasMessageContract) { 'message' }
                              elseif ($HasSkipWaitingCall) { 'auto' }
                              else { 'none' }

    # ── clientsClaim ─────────────────────────────────────────
    $Result.ClientsClaim = ($Body -match 'clientsClaim\s*\(\s*\)') -or
                           ($Body -match 'self\.clients\.claim\s*\(\s*\)')

    # ── navigateFallback ─────────────────────────────────────
    if ($Body -match "navigateFallback\s*:\s*[`"']([^`"']+)[`"']") {
        $Result.NavigateFallback = $Matches[1]
    }

    # ── navigateFallbackDenylist ─────────────────────────────
    $DenylistFound = @()
    if ($Body -match 'navigateFallbackDenylist\s*:\s*\[([^\]]*)\]') {
        $ArrBody = $Matches[1]
        # Extract regex literals (/.../[flags]) and string literals ('...' or "...")
        $Regexes = [regex]::Matches($ArrBody, '/((?:[^/\\]|\\.)+)/[gimsuy]*')
        $Strings = [regex]::Matches($ArrBody, "[`"']([^`"']+)[`"']")
        $DenylistFound += @($Regexes | ForEach-Object { $_.Groups[1].Value })
        $DenylistFound += @($Strings | ForEach-Object { $_.Groups[1].Value })
    }
    $Result.Denylist = @($DenylistFound)
    $Missing = [System.Collections.Generic.List[string]]::new()
    foreach ($Expected in $ExpectedDenylist) {
        $Hit = $false
        foreach ($Found in $DenylistFound) {
            if ($Found -match [regex]::Escape($Expected) -or $Expected -match [regex]::Escape($Found)) {
                $Hit = $true; break
            }
        }
        if (-not $Hit) { $Missing.Add($Expected) }
    }
    $Result.MissingDenylist = @($Missing)

    # ── Precache manifest ────────────────────────────────────
    # Workbox precache entries look like {"revision":"hash","url":"/path"} or
    # {url:"/path",revision:"hash"} (field order varies by build). Find any {...}
    # block in the body that has a url:"..." field — non-precache blocks won't
    # match because they don't carry that exact field shape.
    $PrecacheCount = 0
    $PrecacheEntries = @()
    $Blocks = [regex]::Matches($Body, '\{[^{}]*"url"\s*:\s*"[^"]+"[^{}]*\}')
    foreach ($B in $Blocks) {
        $block = $B.Value
        $url = $null; $rev = $null
        if ($block -match '"url"\s*:\s*"([^"]+)"') { $url = $Matches[1] }
        if ($block -match '"revision"\s*:\s*"([^"]*)"') { $rev = $Matches[1] }
        if ($url) {
            $PrecacheCount++
            $PrecacheEntries += [PSCustomObject]@{ Url = $url; Revision = $rev }
        }
    }
    $Result.PrecacheCount    = $PrecacheCount
    $Result.PrecacheManifest = @($PrecacheEntries)

    # ── Compose pass/fail checks ─────────────────────────────
    $Checks = [System.Collections.Generic.List[ServiceWorkerHealthCheck]]::new()
    $Checks.Add([ServiceWorkerHealthCheck]@{ Name='fetched';      Pass=$Result.FetchedOk; Detail="HTTP $($Result.StatusCode)" })
    $Checks.Add([ServiceWorkerHealthCheck]@{ Name='skipWaiting';  Pass=($Result.SkipWaitingMode -eq 'auto'); Detail=$Result.SkipWaitingMode })
    $Checks.Add([ServiceWorkerHealthCheck]@{ Name='clientsClaim'; Pass=$Result.ClientsClaim; Detail=$(if ($Result.ClientsClaim) { 'present' } else { 'missing' }) })
    $Checks.Add([ServiceWorkerHealthCheck]@{ Name='denylist';     Pass=(@($Result.MissingDenylist).Count -eq 0); Detail=$(if (@($Result.MissingDenylist).Count -eq 0) { 'all expected patterns present' } else { 'missing: ' + (($Result.MissingDenylist) -join ', ') }) })
    $Result.Checks = $Checks.ToArray()
    $Result.OverallPass = ($Checks | Where-Object { -not $_.Pass } | Measure-Object).Count -eq 0

    # ── Render ───────────────────────────────────────────────
    $Color     = $Result.OverallPass ? 'Green' : 'Yellow'
    $SkipColor = ($Result.SkipWaitingMode -eq 'auto') ? 'Green' : 'Yellow'
    $ClaimColor = $Result.ClientsClaim ? 'Green' : 'Yellow'
    $skipMark = ($Result.SkipWaitingMode -eq 'auto') ? 'OK' : 'WARN'
    $claimText = $Result.ClientsClaim ? 'present' : 'missing'
    Write-Host ("SW Health Check — {0}" -f $BaseUrl) -ForegroundColor White
    Write-Host (" skipWaiting: {0} [{1}]" -f $Result.SkipWaitingMode, $skipMark) -ForegroundColor $SkipColor
    Write-Host (" clientsClaim: {0}" -f $claimText) -ForegroundColor $ClaimColor
    if ($Result.Denylist.Count -gt 0) {
        $denyDisplay = (($Result.Denylist | ForEach-Object { $_ }) -join ', ')
        Write-Host (" Denylist: {0}" -f $denyDisplay) -ForegroundColor Gray
    }
    if (@($Result.MissingDenylist).Count -gt 0) {
        Write-Host (" Missing patterns: {0}" -f (($Result.MissingDenylist) -join ', ')) -ForegroundColor Yellow
    }
    if ($Result.NavigateFallback) {
        Write-Host (" Fallback: {0}" -f $Result.NavigateFallback) -ForegroundColor Gray
    }
    Write-Host (" Precached: {0} assets" -f $Result.PrecacheCount) -ForegroundColor Gray
    Write-Host (" Hash: {0}" -f $Result.Hash) -ForegroundColor DarkGray
    Write-Host (" OverallPass: {0}" -f $Result.OverallPass) -ForegroundColor $Color

    if ($Detailed -and $Result.PrecacheManifest.Count -gt 0) {
        Write-Host ''
        Write-Host ' Precache manifest:' -ForegroundColor White
        $Result.PrecacheManifest | Format-Table Url, Revision -AutoSize | Out-Host
    }

    $Result
}