param( [string]$GatewayBaseUrl = "https://stella-ops.local", [string]$AdvisoryAiBaseUrl = "http://advisoryai.stella-ops.local", [string]$Tenant = "stellaops", [int]$TimeoutSeconds = 20, [switch]$SkipUiResponsivenessProbe ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" function Invoke-Probe { param( [Parameter(Mandatory = $true)] [string]$Name, [Parameter(Mandatory = $true)] [string]$Method, [Parameter(Mandatory = $true)] [string]$Path, [string]$Body ) $headersFile = [System.IO.Path]::GetTempFileName() $bodyFile = [System.IO.Path]::GetTempFileName() try { $curlArgs = @( "-k" "-sS" "--noproxy" "*" "-D" $headersFile "-o" $bodyFile "-X" $Method "--max-time" $TimeoutSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture) "-H" "Accept: application/json" "-H" "X-Tenant: $Tenant" "-H" "X-StellaOps-Tenant: $Tenant" "-H" "X-Stella-Tenant: $Tenant" ) if (-not [string]::IsNullOrWhiteSpace($Body)) { $curlArgs += @( "-H" "Content-Type: application/json" "--data-raw" $Body ) } $url = "$GatewayBaseUrl$Path" $curlArgs += @("-w", "%{http_code}", $url) $statusText = (& curl.exe @curlArgs).Trim() if ($LASTEXITCODE -ne 0) { throw "curl failed for $Name ($Method $Path) with exit code $LASTEXITCODE." } if ($statusText -notmatch "^\d{3}$") { throw "Unable to parse HTTP status for $Name ($Method $Path). Raw status text: '$statusText'." } $statusCode = [int]$statusText $contentTypeLine = Get-Content -LiteralPath $headersFile | Where-Object { $_ -match "^[Cc]ontent-[Tt]ype:" } | Select-Object -Last 1 $contentTypeLine = [string]$contentTypeLine $contentType = if ([string]::IsNullOrWhiteSpace($contentTypeLine)) { "" } else { (($contentTypeLine -replace "^[Cc]ontent-[Tt]ype:\s*", "") -replace "\r", "").Trim() } $responseBody = [string](Get-Content -LiteralPath $bodyFile -Raw -ErrorAction SilentlyContinue) if ($null -eq $responseBody) { $responseBody = "" } if ($responseBody.Length -gt 800) { $responseBody = $responseBody.Substring(0, 800) } return [pscustomobject]@{ Name = $Name Method = $Method Path = $Path Url = $url StatusCode = $statusCode ContentType = $contentType BodyPreview = $responseBody } } finally { Remove-Item -LiteralPath $headersFile -ErrorAction SilentlyContinue Remove-Item -LiteralPath $bodyFile -ErrorAction SilentlyContinue } } function Invoke-UiResponsivenessProbe { param( [Parameter(Mandatory = $true)] [string]$BaseUrl ) $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\\..\\..") $webNodeModules = Join-Path $repoRoot "src\\Web\\StellaOps.Web\\node_modules" if (-not (Test-Path -LiteralPath $webNodeModules)) { throw "UI responsiveness probe requires $webNodeModules. Install web dependencies first." } $probeScriptPath = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), ".cjs") $probeScript = @' const { chromium } = require("playwright"); const targetUrl = process.argv[2]; if (!targetUrl) { throw new Error("Missing target URL argument."); } const evaluateWithTimeout = async (page, timeoutMs) => { return Promise.race([ page.evaluate(() => ({ readyState: document.readyState, title: document.title, location: window.location.href, appRootPresent: !!document.querySelector("app-root"), globalSearchInputCount: document.querySelectorAll('app-global-search input[type="text"]').length, })), new Promise((_, reject) => { setTimeout(() => reject(new Error(`ui-evaluate-timeout-${timeoutMs}ms`)), timeoutMs); }), ]); }; (async () => { const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); try { const context = await browser.newContext({ ignoreHTTPSErrors: true }); const page = await context.newPage(); await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(3000); const probe = await evaluateWithTimeout(page, 10000); console.log(JSON.stringify(probe)); } finally { await browser.close(); } })().catch((error) => { console.error(error?.stack ?? String(error)); process.exit(1); }); '@ $originalNodePath = $env:NODE_PATH try { Set-Content -LiteralPath $probeScriptPath -Value $probeScript -Encoding UTF8 $env:NODE_PATH = $webNodeModules $probeOutput = (& node $probeScriptPath $BaseUrl 2>&1) if ($LASTEXITCODE -ne 0) { throw "UI responsiveness probe failed for $BaseUrl. Output: $probeOutput" } $probeJsonLine = [string]($probeOutput | Select-Object -Last 1) $probe = $probeJsonLine | ConvertFrom-Json if ([string]::IsNullOrWhiteSpace($probe.title)) { throw "UI responsiveness probe returned an empty document title for $BaseUrl." } Write-Host "[OK] UI responsiveness probe: title='$($probe.title)' readyState=$($probe.readyState) url=$($probe.location)" } finally { Remove-Item -LiteralPath $probeScriptPath -ErrorAction SilentlyContinue if ($null -eq $originalNodePath) { Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue } else { $env:NODE_PATH = $originalNodePath } } } $rebuildProbeHeaders = @( "-H", "Accept: application/json", "-H", "Content-Type: application/json", "-H", "X-StellaOps-Tenant: $Tenant", "-H", "X-StellaOps-Scopes: advisory-ai:admin advisory-ai:operate advisory-ai:view", "-H", "X-StellaOps-Actor: header-search-smoke" ) $rebuildHeadersFile = [System.IO.Path]::GetTempFileName() $rebuildBodyFile = [System.IO.Path]::GetTempFileName() try { $rebuildStatus = (& curl.exe -sS --noproxy "*" -D $rebuildHeadersFile -o $rebuildBodyFile -X POST --max-time $TimeoutSeconds @rebuildProbeHeaders -w "%{http_code}" "$AdvisoryAiBaseUrl/v1/search/index/rebuild").Trim() if ($LASTEXITCODE -ne 0) { throw "curl failed for unified search rebuild probe." } if ($rebuildStatus -notmatch "^\d{3}$") { throw "Unable to parse HTTP status for unified search rebuild probe. Raw status text: '$rebuildStatus'." } $rebuildStatusCode = [int]$rebuildStatus $rebuildBodyRaw = [string](Get-Content -LiteralPath $rebuildBodyFile -Raw -ErrorAction SilentlyContinue) if ($null -eq $rebuildBodyRaw) { $rebuildBodyRaw = "" } if ($rebuildStatusCode -ne 200) { throw "Unified search rebuild probe failed: status=$rebuildStatusCode body='$rebuildBodyRaw'." } $rebuildPayload = $rebuildBodyRaw | ConvertFrom-Json $chunkCount = [int]$rebuildPayload.chunkCount if ($chunkCount -le 0) { throw "Unified search rebuild returned no chunks (chunkCount=$chunkCount). Check KnowledgeSearch connection and adapter ingestion wiring." } Write-Host "[OK] Unified search ingestion rebuild: status=$rebuildStatusCode chunkCount=$chunkCount" } finally { Remove-Item -LiteralPath $rebuildHeadersFile -ErrorAction SilentlyContinue Remove-Item -LiteralPath $rebuildBodyFile -ErrorAction SilentlyContinue } $probes = @( @{ Name = "Platform envsettings route" Method = "GET" Path = "/platform/envsettings.json" Body = $null AllowedStatusCodes = @(200) }, @{ Name = "Platform i18n route" Method = "GET" Path = "/platform/i18n/en-US.json" Body = $null AllowedStatusCodes = @(200) }, @{ Name = "Unified search query route" Method = "POST" Path = "/api/v1/search/query" Body = '{"q":"header route smoke","limit":5}' AllowedStatusCodes = @(200, 400, 401, 403, 422, 429) }, @{ Name = "Advisory search history route" Method = "GET" Path = "/api/v1/advisory-ai/search/history" Body = $null AllowedStatusCodes = @(200, 204, 400, 401, 403) } ) foreach ($probe in $probes) { $result = Invoke-Probe -Name $probe.Name -Method $probe.Method -Path $probe.Path -Body $probe.Body if ($result.StatusCode -eq 200 -and $result.ContentType -like "text/html*") { throw "$($probe.Name) returned HTML (likely SPA fallback) for $($probe.Path)." } if (-not $probe.AllowedStatusCodes.Contains($result.StatusCode)) { throw "$($probe.Name) failed for $($probe.Path): status=$($result.StatusCode), contentType='$($result.ContentType)', body='$($result.BodyPreview)'." } if ($probe.Path -eq "/platform/i18n/en-US.json" -and $result.ContentType -notlike "application/json*") { throw "$($probe.Name) must return JSON. Received contentType='$($result.ContentType)'." } Write-Host "[OK] $($probe.Name): status=$($result.StatusCode) path=$($probe.Path)" } if (-not $SkipUiResponsivenessProbe) { Invoke-UiResponsivenessProbe -BaseUrl $GatewayBaseUrl } Write-Host "Header search routing smoke checks passed."