<# .SYNOPSIS Operational verification: confirm a deployed service actually serves its page with HTTP 200 and no client-side crashes. .DESCRIPTION The final "is it live and working?" gate after a Coolify deploy. Runs two layers of checks and FAILS (non-zero exit) if either is unhealthy: Layer 1 - HTTP layer (curl.exe) HEAD or GET against the FQDN, follow redirects, expect 2xx. Cheap, no browser. Catches Traefik 502, wrong ports_exposes, TLS failure, missing FQDN route. Layer 2 - Browser layer (Playwright via Node, verify-online.mjs) Real Chromium navigation. Catches client-side JS crashes (pageerror), failed main-frame requests, runtime exceptions, and verifies the page actually rendered (not a blank/error page). Saves a PNG screenshot for evidence. This script complements Test-PostDeploy.ps1 (which inspects Coolify state, container status, proxy logs, sibling DNS). Run BOTH: - Test-PostDeploy.ps1 -> "did Coolify ship it correctly?" - Test-ServiceOnline.ps1 -> "does the user actually get a working page?" .PARAMETER Fqdn Full public URL, e.g. https://cotizador.urieljareth.org. Required. .PARAMETER Path Path appended to the FQDN. Default: "/". Use a non-authenticated landing route (e.g. /, /login, /health) - Playwright will follow redirects, so / often works even when the app forces login. .PARAMETER ExpectedStatusCode HTTP status code expected from Layer 1. Default: 200. Use 302 if the landing path legitimately redirects (e.g. / -> /login). .PARAMETER ExpectTitle Optional regex the document.title must match. Example: "Cotizador|Login". .PARAMETER Screenshot Optional PNG path for the Playwright screenshot. Defaults to "$env:TEMP\coolify--.png". .PARAMETER SkipBrowser Skip Layer 2 (Playwright). Use only on headless/CI environments without a display, or when Chromium is unavailable. .PARAMETER TimeoutMs Per-attempt timeout (curl and Playwright). Default: 30000. .PARAMETER Retries Number of times to retry the FULL check with a 10s gap. Coolify + Traefik DNS challenge + warm-up can take a minute or two on first deploy. Default: 6 (so up to ~1 minute of retries). .EXAMPLE .\Test-ServiceOnline.ps1 -Fqdn https://cotizador.urieljareth.org ` -ExpectTitle "Cotizador|Login" -Retries 6 .EXAMPLE .\Test-ServiceOnline.ps1 -Fqdn https://myapp.urieljareth.org -SkipBrowser #> param( [Parameter(Mandatory = $true)] [string]$Fqdn, [string]$Path = "/", [int]$ExpectedStatusCode = 200, [string]$ExpectTitle, [string]$Screenshot, [switch]$SkipBrowser, [int]$TimeoutMs = 30000, [int]$Retries = 6 ) $ErrorActionPreference = "Stop" $here = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = (Resolve-Path (Join-Path $here "..\..")).Path $verifyNode = Join-Path $here "verify-online.mjs" $nodeModulesRoot = Join-Path $repoRoot "node_modules" function Write-Section($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan } function Get-FullUrl { param([string]$Base, [string]$P) $b = $Base.TrimEnd("/") if ($P -eq "/") { return $b + "/" } if (-not $P.StartsWith("/")) { $P = "/" + $P } return $b + $P } function Test-HttpLayer { param([string]$Url, [int]$Expected, [int]$TimeoutMs) Write-Section "Layer 1 - HTTP (curl) -> $Url" $tmp = [System.IO.Path]::GetTempFileName() try { # -L follow redirects, -o NUL discard body, -w write status to tmp, --max-time total budget. $codeField = "%{http_code}" & curl.exe -sS -L -k -o NUL ` -w $codeField ` --max-time ([math]::Floor($TimeoutMs / 1000)) ` $Url 2>$null > $tmp $raw = (Get-Content -LiteralPath $tmp -Raw -ErrorAction SilentlyContinue) $status = -1 if ([int]::TryParse(($raw -replace '\s',''), [ref]$status)) { Write-Host " HTTP status: $status" -ForegroundColor $(if ($status -eq $Expected -or ($status -ge 200 -and $status -lt 400)) { 'Green' } else { 'Red' }) return $status } Write-Host " Could not parse HTTP status. Raw: '$raw'" -ForegroundColor Red return -1 } finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } } function Test-BrowserLayer { param( [string]$Url, [string]$TitleRegex, [string]$ScreenshotPath, [int]$TimeoutMs ) Write-Section "Layer 2 - Browser (Playwright) -> $Url" if (-not (Test-Path -LiteralPath $nodeModulesRoot)) { Write-Host " node_modules not found at $nodeModulesRoot - run 'npm install' in the manager repo." -ForegroundColor Yellow Write-Host " Skipping browser layer (treat as inconclusive)." -ForegroundColor Yellow return $true # Do not fail the whole pipeline if browser deps are missing. } if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Write-Host " node not in PATH. Skipping browser layer." -ForegroundColor Yellow return $true } $nodeArgs = @($verifyNode, $Url, "--timeout", "$TimeoutMs") if ($TitleRegex) { $nodeArgs += @("--expect-title", $TitleRegex) } if ($ScreenshotPath) { $nodeArgs += @("--screenshot", $ScreenshotPath) } # Run from the manager repo root so `require('playwright')` resolves. Push-Location -LiteralPath $repoRoot $jsonOut = & node @nodeArgs 2>&1 $exitCode = $LASTEXITCODE Pop-Location # The last non-empty line that parses as JSON is the result from verify-online.mjs. $parsed = $null foreach ($line in ($jsonOut -split "`n")) { $t = $line.Trim() if (-not $t) { continue } try { $parsed = $t | ConvertFrom-Json } catch { } } if ($null -eq $parsed) { Write-Host " No JSON result from Node script (exit=$exitCode)." -ForegroundColor Red Write-Host " Raw output: $jsonOut" -ForegroundColor DarkGray return $false } Write-Host " ok : $($parsed.ok)" -ForegroundColor $(if ($parsed.ok) { 'Green' } else { 'Red' }) Write-Host " httpStatus : $($parsed.httpStatus)" Write-Host " title : $($parsed.title)" Write-Host " elapsedMs : $($parsed.elapsedMs)" if ($parsed.pageErrors -and $parsed.pageErrors.Count -gt 0) { Write-Host " pageErrors : $($parsed.pageErrors.Count)" -ForegroundColor Yellow $parsed.pageErrors | Select-Object -First 3 | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkYellow } } if ($parsed.requestFailures -and $parsed.requestFailures.Count -gt 0) { Write-Host " reqFailures : $($parsed.requestFailures.Count)" -ForegroundColor Yellow $parsed.requestFailures | Select-Object -First 3 | ForEach-Object { Write-Host " - $($_.method) $($_.url) [$($_.errorText)]" -ForegroundColor DarkYellow } } if ($parsed.consoleErrors -and $parsed.consoleErrors.Count -gt 0) { Write-Host " consoleErrors: $($parsed.consoleErrors.Count)" -ForegroundColor Yellow } if ($parsed.error) { Write-Host " error : $($parsed.error)" -ForegroundColor Red } if ($parsed.screenshot) { Write-Host " screenshot : $($parsed.screenshot)" -ForegroundColor DarkGray } return [bool]$parsed.ok } $url = Get-FullUrl -Base $Fqdn -P $Path if (-not $Screenshot) { $host_ = ([System.Uri]$Fqdn).Host $ts = (Get-Date -Format "yyyyMMdd-HHmmss") $Screenshot = Join-Path $env:TEMP "coolify-$host_-$ts.png" } $attempt = 0 $lastResult = $false while ($attempt -lt $Retries) { $attempt++ Write-Host "`n>>> Attempt $attempt/$Retries" -ForegroundColor Magenta $httpStatus = Test-HttpLayer -Url $url -Expected $ExpectedStatusCode -TimeoutMs $TimeoutMs $httpOk = ($httpStatus -ge 200 -and $httpStatus -lt 400) if (-not $httpOk) { Write-Host " HTTP layer not ready (status=$httpStatus)." -ForegroundColor Yellow } $browserOk = $true if ($httpOk -and -not $SkipBrowser) { $browserOk = Test-BrowserLayer -Url $url -TitleRegex $ExpectTitle -ScreenshotPath $Screenshot -TimeoutMs $TimeoutMs } elseif ($SkipBrowser) { Write-Host " Browser layer skipped (-SkipBrowser)." -ForegroundColor DarkGray } if ($httpOk -and $browserOk) { $lastResult = $true break } if ($attempt -lt $Retries) { Write-Host " Waiting 10s before retry..." -ForegroundColor DarkGray Start-Sleep -Seconds 10 } } Write-Section "Verdict" if ($lastResult) { Write-Host " SERVICE ONLINE: $url returned healthy HTTP and a clean page render." -ForegroundColor Green Write-Host " Screenshot: $Screenshot" -ForegroundColor DarkGray exit 0 } else { Write-Host " SERVICE NOT VERIFIED ONLINE after $attempt attempt(s)." -ForegroundColor Red Write-Host " Check: Coolify deploy logs, ports_exposes, FQDN route, TLS DNS challenge, app boot." -ForegroundColor Red exit 1 }