param( [string]$PostgresBin = 'D:\pgsql17\bin', [string]$MediaMTX = 'C:\Users\ila20\Desktop\mediamtx\mediamtx.exe', [string]$Browser = 'C:\Program Files\Google\Chrome\Application\chrome.exe', [string]$PreparedPackageRoot = '', [switch]$HarnessSelfTest, [switch]$KeepTemporary ) if ($PSVersionTable.PSEdition -eq 'Core') { $legacyArguments = @('-NoProfile', '-File', $PSCommandPath, '-PostgresBin', $PostgresBin, '-MediaMTX', $MediaMTX, '-Browser', $Browser) if (-not [string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { $legacyArguments += @('-PreparedPackageRoot', $PreparedPackageRoot) } if ($HarnessSelfTest) { $legacyArguments += '-HarnessSelfTest' } if ($KeepTemporary) { $legacyArguments += '-KeepTemporary' } & powershell.exe @legacyArguments exit $LASTEXITCODE } Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' $repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) $sourceSense = Join-Path $repositoryRoot 'Sense' $temporary = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-' + [guid]::NewGuid().ToString('N')) $repoCopy = Join-Path $temporary 'repo' $senseCopy = Join-Path $repoCopy 'Sense' $pgData = Join-Path $temporary 'postgres' $pgLog = Join-Path $temporary 'postgres.log' $pgCtlLog = Join-Path $temporary 'pg-ctl.log' $runtimeLog = Join-Path $temporary 'sense.out.log' $runtimeError = Join-Path $temporary 'sense.err.log' $fixtureLog = Join-Path $temporary 'fixture.out.log' $fixtureError = Join-Path $temporary 'fixture.err.log' $ffmpegLog = Join-Path $temporary 'ffmpeg.out.log' $ffmpegError = Join-Path $temporary 'ffmpeg.err.log' $stateFile = Join-Path $temporary 'profile-state.txt' $fixtureStatus = Join-Path $temporary 'fixture-status.json' $server = $null $fixture = $null $publisher = $null $preflightMedia = $null $pgStarted = $false $savedEnvironment = @{} $sensitiveValues = @() function Get-FreePort { $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() } } function Get-UniqueFreePorts([int]$Count) { $ports = New-Object System.Collections.Generic.List[int] while ($ports.Count -lt $Count) { $candidate = Get-FreePort if (-not $ports.Contains($candidate)) { $ports.Add($candidate) } } return $ports.ToArray() } function New-RandomText([int]$Bytes = 32) { $buffer = New-Object byte[] $Bytes $generator = [Security.Cryptography.RandomNumberGenerator]::Create() try { $generator.GetBytes($buffer) } finally { $generator.Dispose() } return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+', 'A').Replace('/', 'B') } function Set-TestEnvironment([string]$Name, [string]$Value) { if (-not $script:savedEnvironment.ContainsKey($Name)) { $script:savedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process') } [Environment]::SetEnvironmentVariable($Name, $Value, 'Process') } function Get-FreeUdpPort { $client = [Net.Sockets.UdpClient]::new([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, 0)) try { return ([Net.IPEndPoint]$client.Client.LocalEndPoint).Port } finally { $client.Dispose() } } function Copy-TrackedSenseSource([string]$RepositoryRoot, [string]$Destination) { $tracked = @(& git -C $RepositoryRoot ls-files -- 'Sense') if ($LASTEXITCODE -ne 0 -or $tracked.Count -eq 0) { throw 'Could not enumerate tracked Sense source files' } $sensePrefix = 'Sense\' foreach ($relative in $tracked) { $normalized = ([string]$relative).Replace('/', '\') if (-not $normalized.StartsWith($sensePrefix, [StringComparison]::Ordinal)) { throw "Unexpected tracked path outside Sense: $relative" } $source = Join-Path $RepositoryRoot $normalized if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Tracked Sense source is missing: $relative" } $target = Join-Path $Destination $normalized.Substring($sensePrefix.Length) $parent = Split-Path -Parent $target if (-not (Test-Path -LiteralPath $parent)) { [void](New-Item -ItemType Directory -Path $parent -Force) } Copy-Item -LiteralPath $source -Destination $target } } function Get-SafeLogSummary([string[]]$Paths, [int]$MaximumCharacters = 2000) { $parts = New-Object System.Collections.Generic.List[string] foreach ($path in @($Paths)) { if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } $text = [string](@(Get-Content -LiteralPath $path -Tail 20 -ErrorAction SilentlyContinue) -join ' | ') foreach ($secret in @($script:sensitiveValues)) { if (-not [string]::IsNullOrWhiteSpace($secret) -and $secret.Length -ge 4) { $text = $text.Replace($secret, '') } } $text = $text -replace '(?i)((?:password|token|secret|credential(?:_key)?|database(?:_url)?|cookie|authorization)["'']?\s*[:=]\s*["'']?)[^\s,;"'']+', '$1' $text = $text -replace '(?i)(postgres(?:ql)?://)[^\s]+', '$1' if ($text.Length -gt $MaximumCharacters) { $text = $text.Substring($text.Length - $MaximumCharacters) } if (-not [string]::IsNullOrWhiteSpace($text)) { $parts.Add("$([IO.Path]::GetFileName($path)): $text") } } if ($parts.Count -eq 0) { return '' } return ($parts -join ' || ') } function Wait-Http { param( [string]$Uri, [int]$Attempts = 120, $Process = $null, [string]$Stage = 'HTTP endpoint', [string[]]$LogPaths = @() ) for ($attempt = 0; $attempt -lt $Attempts; $attempt++) { if ($null -ne $Process -and $Process.HasExited) { try { $Process.WaitForExit(); $Process.Refresh() } catch {} $exitCode = try { [string]$Process.ExitCode } catch { 'unknown' } if ([string]::IsNullOrWhiteSpace($exitCode)) { $exitCode = 'unknown' } $summary = Get-SafeLogSummary $LogPaths throw "$Stage process exited before readiness: exit_code=$exitCode; log_files=$($LogPaths -join ','); summary=$summary" } try { $response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1 if ($response.StatusCode -eq 200) { return } } catch {} Start-Sleep -Milliseconds 500 } $processState = if ($null -eq $Process) { 'not-observed' } elseif ($Process.HasExited) { "exited:$($Process.ExitCode)" } else { 'running' } $summary = Get-SafeLogSummary $LogPaths throw "$Stage did not become ready: uri=$Uri; process_state=$processState; log_files=$($LogPaths -join ','); summary=$summary" } function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) { for ($attempt = 0; $attempt -lt $Attempts; $attempt++) { $client = [Net.Sockets.TcpClient]::new() try { $task = $client.ConnectAsync('127.0.0.1', $Port) $connected = $task.Wait(250) -and $client.Connected } catch { $connected = $false } finally { $client.Dispose() } if ($connected -eq $Open) { return } Start-Sleep -Milliseconds 250 } throw "TCP port $Port did not reach expected open=$Open state" } function Invoke-SenseJson { param([string]$Method, [string]$Path, $Body = $null, [string]$Token = '', [int]$ExpectedCode = 200) $headers = @{} if ($Token) { $headers.Authorization = "Bearer $Token" } $arguments = @{ Method = $Method; Uri = "$script:baseUrl$Path"; Headers = $headers; TimeoutSec = 15 } if ($null -ne $Body) { $arguments.ContentType = 'application/json; charset=utf-8' $arguments.Body = $Body | ConvertTo-Json -Depth 12 -Compress } try { $response = Invoke-RestMethod @arguments } catch { $safe = $_.Exception.Message -replace '(?i)(password|token)=[^\s;]+', '$1=' throw "Sense request failed for $Method $Path`: $safe" } if ([int]$response.code -ne $ExpectedCode) { throw "Unexpected Sense code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)" } return $response } function Start-SensePackage([string]$PackageRoot) { $launcher = Join-Path $PackageRoot 'start-sense.bat' $process = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d', '/c', "`"$launcher`"" -WorkingDirectory $PackageRoot -RedirectStandardOutput $runtimeLog -RedirectStandardError $runtimeError -WindowStyle Hidden -PassThru Wait-Http -Uri "$script:baseUrl/" -Process $process -Stage 'Sense HTTP' -LogPaths @($runtimeLog, $runtimeError) return $process } function Stop-ProcessTree($Process) { if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null } } function Invoke-HarnessSelfTest { $root = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-selftest-' + [guid]::NewGuid().ToString('N')) $originalSensitiveValues = @($script:sensitiveValues) try { $fixtureRepository = Join-Path $root 'repository' $trackedSource = Join-Path $fixtureRepository 'Sense\tracked.txt' $ignoredSource = Join-Path $fixtureRepository 'Sense\ui\node_modules\ignored.txt' [void](New-Item -ItemType Directory -Path (Split-Path -Parent $trackedSource) -Force) [void](New-Item -ItemType Directory -Path (Split-Path -Parent $ignoredSource) -Force) [IO.File]::WriteAllText($trackedSource, 'tracked', (New-Object Text.UTF8Encoding($false))) [IO.File]::WriteAllText($ignoredSource, 'ignored', (New-Object Text.UTF8Encoding($false))) & git -C $fixtureRepository init --quiet & git -C $fixtureRepository add -- 'Sense/tracked.txt' if ($LASTEXITCODE -ne 0) { throw 'Harness self-test could not prepare tracked source' } $copy = Join-Path $root 'copy' Copy-TrackedSenseSource $fixtureRepository $copy if (-not (Test-Path -LiteralPath (Join-Path $copy 'tracked.txt'))) { throw 'Harness self-test did not copy tracked source' } if (Test-Path -LiteralPath (Join-Path $copy 'ui\node_modules\ignored.txt')) { throw 'Harness self-test copied ignored node_modules content' } $udpPort = Get-FreeUdpPort $udpProbe = [Net.Sockets.UdpClient]::new() try { $udpProbe.Client.Bind([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, $udpPort)) } finally { $udpProbe.Dispose() } $diagnosticLog = Join-Path $root 'sense.err.log' [IO.File]::WriteAllText($diagnosticLog, 'SENSE_JWT_SECRET=unit-secret-value', (New-Object Text.UTF8Encoding($false))) $script:sensitiveValues = @('unit-secret-value') $exited = [pscustomobject]@{ HasExited = $true; ExitCode = 23 } $earlyFailure = '' try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 3 -Process $exited -Stage 'Self-test early exit' -LogPaths @($diagnosticLog) } catch { $earlyFailure = $_.Exception.Message } if ($earlyFailure -notmatch 'exit_code=23' -or $earlyFailure.Contains('unit-secret-value') -or $earlyFailure -notmatch '') { throw "Harness self-test early-exit diagnostic was unsafe or incomplete: $earlyFailure" } $timeoutFailure = '' try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 1 -Stage 'Self-test timeout' -LogPaths @($diagnosticLog) } catch { $timeoutFailure = $_.Exception.Message } if ($timeoutFailure -notmatch 'process_state=not-observed' -or $timeoutFailure.Contains('unit-secret-value') -or $timeoutFailure -notmatch '') { throw "Harness self-test timeout diagnostic was unsafe or incomplete: $timeoutFailure" } Write-Host 'Sense E2E harness self-test passed: tracked copy, UDP bind, early exit, timeout and redaction.' } finally { $script:sensitiveValues = $originalSensitiveValues if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } } if ($HarnessSelfTest) { Invoke-HarnessSelfTest exit 0 } try { foreach ($required in @( (Join-Path $PostgresBin 'initdb.exe'), (Join-Path $PostgresBin 'pg_ctl.exe'), (Join-Path $PostgresBin 'createdb.exe'), (Join-Path $PostgresBin 'psql.exe'), $MediaMTX, $Browser )) { if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required test dependency not found: $required" } } $ffmpeg = (Get-Command ffmpeg.exe -ErrorAction Stop).Source if ([string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { New-Item -ItemType Directory -Path $senseCopy -Force | Out-Null Copy-TrackedSenseSource $repositoryRoot $senseCopy & git -C $repoCopy init --quiet & git -C $repoCopy config user.name 'Sense E2E' & git -C $repoCopy config user.email 'sense-e2e@invalid.local' & git -C $repoCopy config core.autocrlf false & git -C $repoCopy add -- Sense if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source staging failed' } & git -C $repoCopy commit --quiet -m 'temporary acceptance source' if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source commit failed' } Write-Host 'Building Sense Windows package in an isolated temporary copy...' & pwsh.exe -NoProfile -File (Join-Path $senseCopy 'scripts\build\build-windows.ps1') -MediaMTXPath $MediaMTX if ($LASTEXITCODE -ne 0) { throw 'isolated Windows package build failed' } $packageRoot = Join-Path $senseCopy 'dist\sense-windows-amd64' } else { $preparedInput = [IO.Path]::GetFullPath($PreparedPackageRoot) if (-not (Test-Path -LiteralPath (Join-Path $preparedInput 'sense.exe'))) { throw 'prepared Sense package is invalid' } $packageRoot = Join-Path $temporary 'prepared-package' Copy-Item -LiteralPath $preparedInput -Destination $packageRoot -Recurse Write-Host "Using temporary copy of prepared package: $packageRoot" } $tcpPorts = @(Get-UniqueFreePorts 7) $pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $mediaAPIPort, $onvifPort = $tcpPorts do { $webrtcUDPort = Get-FreeUdpPort } while ($tcpPorts -contains $webrtcUDPort) $script:baseUrl = "http://127.0.0.1:$sensePort" Write-Host "Initializing isolated PostgreSQL on port $pgPort..." & (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U sense_e2e -A trust --encoding=UTF8 --no-locale | Out-Null if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' } # Do not synchronously invoke pg_ctl on Windows. Its persistent # cmd/postgres child inherits console handles and can keep PowerShell # waiting even after pg_ctl exits. Start it hidden, then poll the port. $pgStartArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start" [void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStartArguments -RedirectStandardOutput $pgCtlLog -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden -PassThru) Wait-Tcp -Port $pgPort -Open $true $pgStarted = $true & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U sense_e2e sense_e2e if ($LASTEXITCODE -ne 0) { throw 'isolated Sense database creation failed' } Write-Host 'Isolated PostgreSQL database is ready.' $mediaConfig = @( 'logLevel: warn', 'api: true', "apiAddress: 127.0.0.1:$mediaAPIPort", 'rtspTransports: [tcp]', "rtspAddress: 127.0.0.1:$rtspPort", "hlsAddress: 127.0.0.1:$hlsPort", "webrtcAddress: 127.0.0.1:$webrtcPort", "webrtcLocalUDPAddress: 127.0.0.1:$webrtcUDPort", 'rtmp: false', 'srt: false', 'moq: false', 'metrics: false', 'paths:', ' fixture:' ) -join "`n" [IO.File]::WriteAllText((Join-Path $packageRoot 'config\mediamtx.yml'), $mediaConfig, (New-Object Text.UTF8Encoding($false))) $preflightOut = Join-Path $temporary 'mediamtx-preflight.out.log' $preflightError = Join-Path $temporary 'mediamtx-preflight.err.log' $preflightBinary = Join-Path $packageRoot 'bin\mediamtx.exe' $preflightConfig = Join-Path $packageRoot 'config\mediamtx.yml' $preflightMedia = Start-Process -FilePath $preflightBinary -ArgumentList $preflightConfig -WorkingDirectory (Split-Path -Parent $preflightConfig) -RedirectStandardOutput $preflightOut -RedirectStandardError $preflightError -WindowStyle Hidden -PassThru Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $preflightMedia -Stage 'MediaMTX preflight' -LogPaths @($preflightOut, $preflightError) -Attempts 60 Stop-ProcessTree $preflightMedia Wait-Tcp -Port $mediaAPIPort -Open $false -Attempts 40 $preflightMedia = $null Write-Host 'MediaMTX package/config preflight passed before managed Sense startup.' $jwt = New-RandomText 48 $bootstrap = New-RandomText 48 $adminPassword = New-RandomText 18 $credentialBytes = New-Object byte[] 32 $credentialGenerator = [Security.Cryptography.RandomNumberGenerator]::Create() try { $credentialGenerator.GetBytes($credentialBytes) } finally { $credentialGenerator.Dispose() } $credentialKey = [Convert]::ToBase64String($credentialBytes) $cameraUser = 'fixture_' + (New-RandomText 8) $cameraPassword = New-RandomText 24 $database = "host=127.0.0.1 port=$pgPort user=sense_e2e dbname=sense_e2e sslmode=disable" $script:sensitiveValues = @($jwt, $bootstrap, $adminPassword, $credentialKey, $cameraUser, $cameraPassword, $database) $environment = @{ SENSE_HOST = '127.0.0.1'; SENSE_PORT = "$sensePort"; SENSE_DATABASE_URL = $database; SENSE_JWT_SECRET = $jwt; SENSE_BOOTSTRAP_TOKEN = $bootstrap; SENSE_CREDENTIAL_KEY = $credentialKey; SENSE_ONVIF_DISCOVERY_IP = '127.0.0.1'; SENSE_ONVIF_ALLOWED_CIDRS = '127.0.0.0/8'; SENSE_MEDIAMTX_MODE = 'managed'; SENSE_MEDIAMTX_BINARY = 'bin\mediamtx.exe'; SENSE_MEDIAMTX_CONFIG = 'config\mediamtx.yml'; SENSE_MEDIAMTX_API = "http://127.0.0.1:$mediaAPIPort"; SENSE_WEB_ROOT = 'web'; SENSE_AUTO_MIGRATE = 'true'; SENSE_POSTGRES_BIN = $PostgresBin; SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE = "http://127.0.0.1:$webrtcPort" } foreach ($item in $environment.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value } Write-Host 'Generated ephemeral runtime values without persisting credentials.' [IO.File]::WriteAllText($stateFile, 'initial', (New-Object Text.UTF8Encoding($false))) foreach ($item in @{ SENSE_E2E_ONVIF_PORT = "$onvifPort"; SENSE_E2E_RTSP_PORT = "$rtspPort"; SENSE_E2E_CAMERA_USERNAME = $cameraUser; SENSE_E2E_CAMERA_PASSWORD = $cameraPassword; SENSE_E2E_PROFILE_STATE_FILE = $stateFile; SENSE_E2E_FIXTURE_STATUS_FILE = $fixtureStatus }.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value } Write-Host "Starting Digest ONVIF fixture on port $onvifPort..." $fixture = Start-Process -FilePath 'python.exe' -ArgumentList (Join-Path $PSScriptRoot '..\fixtures\onvif_digest_fixture.py') -RedirectStandardOutput $fixtureLog -RedirectStandardError $fixtureError -WindowStyle Hidden -PassThru for ($attempt = 0; $attempt -lt 40 -and -not (Test-Path $fixtureStatus); $attempt++) { Start-Sleep -Milliseconds 250 } if (-not (Test-Path $fixtureStatus)) { throw 'ONVIF fixture did not become ready' } $server = Start-SensePackage $packageRoot Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $server -Stage 'MediaMTX API' -LogPaths @($runtimeLog, $runtimeError) $publisherArguments = @( '-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=10', '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'rtsp', '-rtsp_transport', 'tcp', "rtsp://127.0.0.1:$rtspPort/fixture" ) $publisher = Start-Process -FilePath $ffmpeg -ArgumentList $publisherArguments -RedirectStandardOutput $ffmpegLog -RedirectStandardError $ffmpegError -WindowStyle Hidden -PassThru Start-Sleep -Seconds 2 if ($publisher.HasExited) { throw 'synthetic RTSP publisher exited before the acceptance flow' } $bootstrapResponse = Invoke-SenseJson POST '/api/v1/bootstrap' @{ username = 'acceptance-admin'; password = $adminPassword; nickName = 'Acceptance Admin' } '' 403 # Bootstrap token is a header, so use the dedicated request without placing it in a body or URI. $bootstrapResponse = Invoke-RestMethod -Method POST -Uri "$baseUrl/api/v1/bootstrap" -Headers @{ 'X-Sense-Bootstrap-Token' = $bootstrap } -ContentType 'application/json; charset=utf-8' -Body (@{ username = 'acceptance-admin'; password = $adminPassword; nickName = 'Acceptance Admin' } | ConvertTo-Json -Compress) if ([int]$bootstrapResponse.code -ne 200) { throw 'administrator bootstrap failed' } $login = Invoke-SenseJson POST '/api/v1/login' @{ username = 'acceptance-admin'; password = $adminPassword } $token = [string]$login.token $script:sensitiveValues += $token if ($token.Length -lt 20) { throw 'login did not return a usable token' } $unauthorized = Invoke-SenseJson GET '/api/v1/devices' $null '' 401 $deviceBody = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\fixtures\device-create.zh-CN.json') -Raw -Encoding utf8 | ConvertFrom-Json $created = Invoke-SenseJson POST '/api/v1/devices' $deviceBody $token $device = $created.data if ($device.name -ne $deviceBody.name -or $device.location -ne $deviceBody.location) { throw 'Chinese device fields did not round-trip' } $badBody = @{ name = 'reject-unknown-field'; location = 'fixture'; modality = 'video'; capabilities = @('video'); password = $cameraPassword } $bad = Invoke-SenseJson POST '/api/v1/devices' $badBody $token 400 $credentials = Invoke-SenseJson PUT "/api/v1/devices/$($device.id)/credentials" @{ onvifUsername = $cameraUser; onvifPassword = $cameraPassword; rtspSameAsOnvif = $false; rtspUsername = $cameraUser; rtspPassword = $cameraPassword; version = [int64]$device.version } $token $device = $credentials.data if (-not $device.onvifCredentialConfigured -or -not $device.rtspCredentialConfigured) { throw 'purpose-separated credentials were not recorded' } $probe = Invoke-SenseJson POST "/api/v1/admission/devices/$($device.id)/probe" @{ address = "http://127.0.0.1:$onvifPort/onvif/device"; version = [int64]$device.version } $token if ($probe.data.status -ne 'ready' -or $probe.data.profiles[0].verificationStatus -ne 'ready') { throw 'Digest ONVIF/RTSP probe did not become ready' } $fixtureEvidence = Get-Content -LiteralPath $fixtureStatus -Raw -Encoding utf8 | ConvertFrom-Json if ([int]$fixtureEvidence.digestRequests -lt 3) { throw 'ONVIF fixture did not observe Digest requests for the full SOAP flow' } $routeList = Invoke-SenseJson GET '/api/v1/media/routes' $null $token $route = @($routeList.data.list)[0] if (-not $route.id -or $route.path -match '(?i)@|password|credential') { throw 'media route is missing or exposes credentials' } [void](Invoke-SenseJson POST "/api/v1/media/routes/$([Uri]::EscapeDataString($route.id))/reconcile" @{} $token) $consumerOut = Join-Path $temporary 'consumer.out.log' $consumerErr = Join-Path $temporary 'consumer.err.log' $consumer = Start-Process -FilePath $ffmpeg -ArgumentList @( '-hide_banner', '-loglevel', 'error', '-rtsp_transport', 'tcp', '-i', "rtsp://127.0.0.1:$rtspPort/$($route.path)", '-t', '2', '-f', 'null', 'NUL' ) -RedirectStandardOutput $consumerOut -RedirectStandardError $consumerErr -WindowStyle Hidden -PassThru -Wait if ($consumer.ExitCode -ne 0) { throw 'on-demand MediaMTX route did not deliver the synthetic stream' } $liveRoutes = Invoke-SenseJson GET '/api/v1/liveview/routes?pageIndex=1&pageSize=10' $null $token $liveRoute = @($liveRoutes.data.list)[0] $session = Invoke-SenseJson POST '/api/v1/liveview/sessions' @{ routeId = $liveRoute.id } $token $player = Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl$($session.data.playerUrl)" -TimeoutSec 10 if ($player.StatusCode -ne 200 -or -not $player.Content.Contains(":$webrtcPort/")) { throw 'live-view wrapper did not use the safe browser-visible MediaMTX address' } $areaBody = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\fixtures\area-polygon.zh-CN.json') -Raw -Encoding utf8 | ConvertFrom-Json $areaBody | Add-Member -NotePropertyName routeId -NotePropertyValue $route.id $areaCreated = Invoke-SenseJson POST '/api/v1/area/configurations' $areaBody $token if ($areaCreated.data.name -ne $areaBody.name) { throw 'Chinese area fields did not round-trip' } [IO.File]::WriteAllText($stateFile, 'changed', (New-Object Text.UTF8Encoding($false))) $currentDevice = (Invoke-SenseJson GET "/api/v1/devices/$($device.id)" $null $token).data $reprobe = Invoke-SenseJson POST "/api/v1/admission/devices/$($device.id)/probe" @{ address = "http://127.0.0.1:$onvifPort/onvif/device"; version = [int64]$currentDevice.version } $token if ($reprobe.data.profiles[0].width -ne 1280) { throw 'changed profile resolution was not persisted' } $areas = Invoke-SenseJson GET '/api/v1/area/configurations?pageIndex=1&pageSize=10' $null $token $area = @($areas.data.list | Where-Object id -eq $areaCreated.data.id)[0] if (-not $area.needsRecalibration) { throw 'resolution change did not mark the area for recalibration' } $browserScript = Join-Path $PSScriptRoot 'browser-smoke.cjs' foreach ($item in @{ SENSE_E2E_BASE_URL = $baseUrl; SENSE_E2E_TOKEN = $token; SENSE_E2E_BROWSER = $Browser; SENSE_E2E_SCREENSHOT = (Join-Path $temporary 'sense-browser.png') }.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value } Push-Location $temporary try { & node.exe $browserScript } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { throw 'browser GoAdmin shell smoke failed' } & (Join-Path $packageRoot 'stop-sense.bat') Start-Sleep -Seconds 2 if (-not $server.HasExited) { Stop-ProcessTree $server } $server = Start-SensePackage $packageRoot $routesAfterRestart = Invoke-SenseJson GET '/api/v1/media/routes' $null $token if (@($routesAfterRestart.data.list).Count -lt 1) { throw 'cold restart lost persisted media routes' } if ($publisher.HasExited) { $publisher = Start-Process -FilePath $ffmpeg -ArgumentList $publisherArguments -RedirectStandardOutput $ffmpegLog -RedirectStandardError $ffmpegError -WindowStyle Hidden -PassThru Start-Sleep -Seconds 2 } [void](Invoke-SenseJson POST "/api/v1/media/routes/$([Uri]::EscapeDataString($route.id))/reconcile" @{} $token) $restartConsumer = Start-Process -FilePath $ffmpeg -ArgumentList @( '-hide_banner', '-loglevel', 'error', '-rtsp_transport', 'tcp', '-i', "rtsp://127.0.0.1:$rtspPort/$($route.path)", '-t', '2', '-f', 'null', 'NUL' ) -RedirectStandardOutput (Join-Path $temporary 'restart-consumer.out.log') -RedirectStandardError (Join-Path $temporary 'restart-consumer.err.log') -WindowStyle Hidden -PassThru -Wait if ($restartConsumer.ExitCode -ne 0) { throw 'cold restart did not restore on-demand playback' } $psql = Join-Path $PostgresBin 'psql.exe' $migrationCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc 'select count(*) from sys_migration;').Trim() $capabilitiesType = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select data_type from information_schema.columns where table_name='sense_devices' and column_name='capabilities';").Trim() if ([int]$migrationCount -lt 8 -or $capabilitiesType -ne 'jsonb') { throw 'clean PostgreSQL migration chain did not reach the Sense schema' } $plainCredentialCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select count(*) from sense_device_credentials where position(convert_to('$cameraPassword','UTF8') in ciphertext) > 0;").Trim() if ([int]$plainCredentialCount -ne 0) { throw 'camera credential appeared in plaintext storage' } foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError, $preflightOut, $preflightError)) { if (Test-Path $log) { $text = [string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue) if ($null -eq $text) { $text = '' } if ($text.Contains($adminPassword) -or $text.Contains($cameraPassword) -or $text.Contains($token)) { throw "runtime log exposed acceptance credentials: $log" } } } Write-Host 'Sense isolated E2E passed: clean PostgreSQL, login/RBAC, Chinese device, Digest ONVIF/RTSP, separated credentials, MediaMTX on-demand playback, live view, area recalibration, browser shell, cold restart and Windows stop.' } finally { Stop-ProcessTree $publisher Stop-ProcessTree $fixture Stop-ProcessTree $server Stop-ProcessTree $preflightMedia if ($pgStarted) { $pgStopArguments = "-D `"$pgData`" -m fast stop" [void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStopArguments -RedirectStandardOutput (Join-Path $temporary 'pg-stop.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden -PassThru) try { Wait-Tcp -Port $pgPort -Open $false -Attempts 40 } catch {} } foreach ($item in $savedEnvironment.GetEnumerator()) { [Environment]::SetEnvironmentVariable($item.Key, $item.Value, 'Process') } if (-not $KeepTemporary -and (Test-Path -LiteralPath $temporary)) { $resolved = [IO.Path]::GetFullPath($temporary) if (-not $resolved.StartsWith([IO.Path]::GetTempPath(), [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe temporary cleanup path: $resolved" } Remove-Item -LiteralPath $resolved -Recurse -Force } elseif ($KeepTemporary) { Write-Host "Kept isolated acceptance directory: $temporary" } }