328 lines
21 KiB
PowerShell
328 lines
21 KiB
PowerShell
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]$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 ($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
|
|
$pgStarted = $false
|
|
$savedEnvironment = @{}
|
|
|
|
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 Wait-Http([string]$Uri, [int]$Attempts = 120) {
|
|
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
|
try {
|
|
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1
|
|
if ($response.StatusCode -eq 200) { return }
|
|
} catch {}
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
throw "HTTP endpoint did not become ready: $Uri"
|
|
}
|
|
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=<redacted>'
|
|
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 "$script:baseUrl/"
|
|
return $process
|
|
}
|
|
function Stop-ProcessTree($Process) {
|
|
if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null }
|
|
}
|
|
|
|
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 $repoCopy | Out-Null
|
|
Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse
|
|
& 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 commit --allow-empty --quiet -m 'temporary acceptance source'
|
|
|
|
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 {
|
|
$packageRoot = [IO.Path]::GetFullPath($PreparedPackageRoot)
|
|
if (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'sense.exe'))) { throw 'prepared Sense package is invalid' }
|
|
$senseCopy = [IO.Path]::GetFullPath((Join-Path $packageRoot '..\..'))
|
|
Write-Host "Using prepared isolated package: $packageRoot"
|
|
}
|
|
|
|
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $webrtcUDPort, $mediaAPIPort, $onvifPort = Get-UniqueFreePorts 8
|
|
$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)))
|
|
|
|
$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"
|
|
$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 "http://127.0.0.1:$mediaAPIPort/v3/config/global/get"
|
|
$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
|
|
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 $senseCopy 'ui\sense-browser-smoke.cjs'
|
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-smoke.cjs') -Destination $browserScript
|
|
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 (Join-Path $senseCopy 'ui')
|
|
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)) {
|
|
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
|
|
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"
|
|
}
|
|
}
|