[CmdletBinding()] param( [Alias("h")] [switch]$Help, [string]$ApiHost = "127.0.0.1", [int]$Port = 3101, [string]$Log = "warn,tower_http=warn", [int]$StartupTimeoutSeconds = 30 ) $ErrorActionPreference = "Stop" function Write-Usage { @( 'Usage:' ' ./server-rs/scripts/smoke.ps1' ' ./server-rs/scripts/smoke.ps1 -ApiHost 127.0.0.1 -Port 3201 -Log "info,tower_http=info"' '' 'Notes:' ' 1. Build api-server and start an ephemeral local process' ' 2. Verify /healthz raw payload, response headers, and envelope contract' ' 3. Stop the temporary process automatically after the smoke checks finish' ) -join [Environment]::NewLine } function Assert-Condition { param( [bool]$Condition, [string]$Message ) if (-not $Condition) { throw $Message } } function Set-InheritedEnvVar { param( [string]$Name, [string]$Value, [hashtable]$Snapshot ) if (-not $Snapshot.ContainsKey($Name)) { $Snapshot[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process") } [Environment]::SetEnvironmentVariable($Name, $Value, "Process") } function Restore-InheritedEnvVars { param([hashtable]$Snapshot) foreach ($entry in $Snapshot.GetEnumerator()) { [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") } } function Get-HeaderValue { param( $Headers, [string]$Name ) if ($null -eq $Headers) { return $null } return $Headers[$Name] } function Wait-ForHealthz { param( [string]$Uri, [int]$TimeoutSeconds, $Process ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $lastError = $null while ((Get-Date) -lt $deadline) { if ($Process.HasExited) { throw "api-server exited before /healthz became ready." } try { $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 2 if ($response.StatusCode -eq 200) { return } $lastError = "Unexpected status code $($response.StatusCode)" } catch { $lastError = $_.Exception.Message } Start-Sleep -Milliseconds 300 } throw "Timed out waiting for /healthz readiness. Last error: $lastError" } function Write-ProcessLogs { param( [string]$StdoutLog, [string]$StderrLog ) if (Test-Path $StdoutLog) { Write-Host "[server-rs:smoke] stdout:" Get-Content -Path $StdoutLog } if (Test-Path $StderrLog) { Write-Host "[server-rs:smoke] stderr:" Get-Content -Path $StderrLog } } if ($Help) { Write-Usage exit 0 } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $serverRsDir = Split-Path -Parent $scriptDir $manifestPath = Join-Path $serverRsDir "Cargo.toml" $binaryPath = Join-Path $serverRsDir "target\debug\api-server.exe" $baseUrl = "http://$ApiHost`:$Port" $healthzUrl = "$baseUrl/healthz" $runId = [Guid]::NewGuid().ToString("N") $stdoutLog = Join-Path $env:TEMP "genarrative-server-rs-smoke-$runId.stdout.log" $stderrLog = Join-Path $env:TEMP "genarrative-server-rs-smoke-$runId.stderr.log" $envSnapshot = @{} $serverProcess = $null $shouldKeepLogs = $false if (-not (Test-Path $manifestPath)) { throw "Missing server-rs/Cargo.toml, cannot start smoke script." } Push-Location $serverRsDir try { Write-Host "[server-rs:smoke] step: cargo build -p api-server" cargo build -p api-server --manifest-path $manifestPath Assert-Condition (Test-Path $binaryPath) "Missing api-server binary at $binaryPath after cargo build." Set-InheritedEnvVar -Name "GENARRATIVE_API_HOST" -Value $ApiHost -Snapshot $envSnapshot Set-InheritedEnvVar -Name "GENARRATIVE_API_PORT" -Value "$Port" -Snapshot $envSnapshot Set-InheritedEnvVar -Name "GENARRATIVE_API_LOG" -Value $Log -Snapshot $envSnapshot Write-Host "[server-rs:smoke] step: start api-server binary" $serverProcess = Start-Process ` -FilePath $binaryPath ` -WorkingDirectory $serverRsDir ` -PassThru ` -RedirectStandardOutput $stdoutLog ` -RedirectStandardError $stderrLog Restore-InheritedEnvVars -Snapshot $envSnapshot Write-Host "[server-rs:smoke] step: wait for /healthz readiness" Wait-ForHealthz -Uri $healthzUrl -TimeoutSeconds $StartupTimeoutSeconds -Process $serverProcess $rawRequestId = "smoke-healthz-raw" Write-Host "[server-rs:smoke] step: verify raw /healthz contract" $rawResponse = Invoke-WebRequest ` -Uri $healthzUrl ` -Headers @{ "x-request-id" = $rawRequestId } ` -UseBasicParsing ` -TimeoutSec 5 $rawBody = $rawResponse.Content | ConvertFrom-Json Assert-Condition ($rawResponse.StatusCode -eq 200) "Raw /healthz did not return HTTP 200." Assert-Condition ($rawBody.ok -eq $true) "Raw /healthz body is missing ok=true." Assert-Condition ($rawBody.service -eq "genarrative-node-server") "Raw /healthz body returned an unexpected service name." $apiVersionHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-api-version" $routeVersionHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-route-version" $requestIdHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-request-id" $responseTimeHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-response-time-ms" $responseTimeValue = 0 Assert-Condition ($requestIdHeader -eq $rawRequestId) "Raw /healthz did not echo x-request-id." Assert-Condition (-not [string]::IsNullOrWhiteSpace($apiVersionHeader)) "Raw /healthz is missing x-api-version." Assert-Condition ($routeVersionHeader -eq $apiVersionHeader) "Raw /healthz x-route-version is not aligned with x-api-version." Assert-Condition ( [int]::TryParse(($responseTimeHeader | ForEach-Object { $_ }), [ref]$responseTimeValue) ) "Raw /healthz x-response-time-ms is not a valid integer." Assert-Condition ($responseTimeValue -ge 0) "Raw /healthz x-response-time-ms must be >= 0." $envelopeRequestId = "smoke-healthz-envelope" Write-Host "[server-rs:smoke] step: verify envelope /healthz contract" $envelopeResponse = Invoke-WebRequest ` -Uri $healthzUrl ` -Headers @{ "x-request-id" = $envelopeRequestId "x-genarrative-response-envelope" = "1" } ` -UseBasicParsing ` -TimeoutSec 5 $envelopeBody = $envelopeResponse.Content | ConvertFrom-Json Assert-Condition ($envelopeResponse.StatusCode -eq 200) "Envelope /healthz did not return HTTP 200." Assert-Condition ($envelopeBody.ok -eq $true) "Envelope /healthz body is missing ok=true." Assert-Condition ($null -eq $envelopeBody.error) "Envelope /healthz should return error=null." Assert-Condition ($envelopeBody.data.ok -eq $true) "Envelope /healthz data.ok should be true." Assert-Condition ($envelopeBody.data.service -eq "genarrative-node-server") "Envelope /healthz returned an unexpected service name." Assert-Condition ($envelopeBody.meta.apiVersion -eq $apiVersionHeader) "Envelope /healthz meta.apiVersion does not match x-api-version." Assert-Condition ($envelopeBody.meta.requestId -eq $envelopeRequestId) "Envelope /healthz meta.requestId did not echo x-request-id." Assert-Condition ($envelopeBody.meta.routeVersion -eq $routeVersionHeader) "Envelope /healthz meta.routeVersion does not match x-route-version." Assert-Condition ($envelopeBody.meta.operation -eq "GET /healthz") "Envelope /healthz meta.operation is unexpected." Assert-Condition ($envelopeBody.meta.latencyMs -ge 0) "Envelope /healthz meta.latencyMs must be >= 0." Write-Host "[server-rs:smoke] all checks passed" } catch { $shouldKeepLogs = $true Write-ProcessLogs -StdoutLog $stdoutLog -StderrLog $stderrLog throw } finally { Restore-InheritedEnvVars -Snapshot $envSnapshot if ($null -ne $serverProcess -and -not $serverProcess.HasExited) { Stop-Process -Id $serverProcess.Id -Force $serverProcess.WaitForExit() } Pop-Location if (-not $shouldKeepLogs) { Remove-Item -LiteralPath $stdoutLog -ErrorAction SilentlyContinue Remove-Item -LiteralPath $stderrLog -ErrorAction SilentlyContinue } }