[CmdletBinding()] param( [string]$Python = ".venv-build\Scripts\python.exe", [switch]$SkipFrontendInstall ) $ErrorActionPreference = "Stop" $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $CompanionRoot = Join-Path $ProjectRoot "video_companion" $Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec" function Invoke-FrozenGate { param( [Parameter(Mandatory = $true)][string]$Executable, [Parameter(Mandatory = $true)][string]$Argument, [Parameter(Mandatory = $true)][string]$GateName ) $TempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $SmokeRoot = Join-Path $TempRoot ("doctor-workstation-smoke-" + [guid]::NewGuid().ToString("N")) $SmokeRoot = [System.IO.Path]::GetFullPath($SmokeRoot) if (-not ($SmokeRoot.StartsWith($TempRoot, [System.StringComparison]::OrdinalIgnoreCase)) -or -not ([System.IO.Path]::GetFileName($SmokeRoot)).StartsWith("doctor-workstation-smoke-")) { throw "Refusing to use an unsafe smoke-test directory: $SmokeRoot" } $Environment = @{ "APPDATA" = (Join-Path $SmokeRoot "AppData\Roaming") "LOCALAPPDATA" = (Join-Path $SmokeRoot "AppData\Local") "XDG_CONFIG_HOME" = (Join-Path $SmokeRoot "xdg\config") "XDG_STATE_HOME" = (Join-Path $SmokeRoot "xdg\state") "XDG_CACHE_HOME" = (Join-Path $SmokeRoot "xdg\cache") "DOCTOR_CONFIG_DIR" = (Join-Path $SmokeRoot "doctor\config") "DOCTOR_LOG_DIR" = (Join-Path $SmokeRoot "doctor\logs") "DOCTOR_API_BASE_URL" = "https://127.0.0.1:9" "DOCTOR_DEMO_MODE" = "true" "DOCTOR_VIDEO_MODE" = "embedded" "DOCTOR_VIDEO_WEB_URL" = "" "DOCTOR_VERIFY_SSL" = "true" "DOCTOR_LOG_LEVEL" = "INFO" "DOCTOR_SMOKE_TEST" = "1" "HTTP_PROXY" = "http://127.0.0.1:9" "HTTPS_PROXY" = "http://127.0.0.1:9" "ALL_PROXY" = "http://127.0.0.1:9" "NO_PROXY" = "" "QT_QPA_PLATFORM" = "offscreen" } $PreviousEnvironment = @{} $StandardOutput = Join-Path $SmokeRoot "stdout.txt" $StandardError = Join-Path $SmokeRoot "stderr.txt" New-Item -ItemType Directory -Path $SmokeRoot -Force | Out-Null New-Item -ItemType Directory -Path $Environment["APPDATA"] -Force | Out-Null New-Item -ItemType Directory -Path $Environment["LOCALAPPDATA"] -Force | Out-Null try { foreach ($Name in $Environment.Keys) { $PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process") [Environment]::SetEnvironmentVariable($Name, $Environment[$Name], "Process") } # Windows PowerShell 5 can leave ExitCode unset on the object returned # by Start-Process. System.Diagnostics.Process retains the real code # and lets us drain redirected streams without risking a pipe deadlock. $ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo $ProcessInfo.FileName = $Executable $ProcessInfo.Arguments = $Argument $ProcessInfo.UseShellExecute = $false $ProcessInfo.CreateNoWindow = $true $ProcessInfo.RedirectStandardOutput = $true $ProcessInfo.RedirectStandardError = $true $Process = New-Object System.Diagnostics.Process $Process.StartInfo = $ProcessInfo if (-not $Process.Start()) { throw "Unable to start the frozen application smoke test" } $OutputTask = $Process.StandardOutput.ReadToEndAsync() $ErrorTask = $Process.StandardError.ReadToEndAsync() $TimedOut = -not $Process.WaitForExit(30000) if ($TimedOut -and -not $Process.HasExited) { $Process.Kill() } $Process.WaitForExit() $OutputTask.Wait() $ErrorTask.Wait() Set-Content -LiteralPath $StandardOutput -Value $OutputTask.Result -Encoding UTF8 Set-Content -LiteralPath $StandardError -Value $ErrorTask.Result -Encoding UTF8 $DiagnosticFiles = @($StandardOutput, $StandardError) $DiagnosticFiles += Get-ChildItem -LiteralPath $SmokeRoot -Recurse -Filter "*.log" -File | Select-Object -ExpandProperty FullName $Diagnostics = ($DiagnosticFiles | Where-Object { Test-Path -LiteralPath $_ } | ForEach-Object { Get-Content -LiteralPath $_ -Raw -ErrorAction SilentlyContinue }) -join "`n" if ($TimedOut) { throw "$GateName timed out after 30 seconds`n$Diagnostics" } if ($Process.ExitCode -ne 0) { throw "$GateName failed with exit code $($Process.ExitCode)`n$Diagnostics" } if ($Diagnostics -match "(?im)traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error") { throw "$GateName logs contain an unhandled exception" } Write-Host "$GateName passed ($Argument, isolated offscreen mode)." } finally { foreach ($Name in $Environment.Keys) { [Environment]::SetEnvironmentVariable($Name, $PreviousEnvironment[$Name], "Process") } if (Test-Path -LiteralPath $SmokeRoot) { Remove-Item -LiteralPath $SmokeRoot -Recurse -Force } } } function Assert-FrozenMultimedia { param([Parameter(Mandatory = $true)][string]$Artifact) foreach ($RequiredName in @( "QtMultimedia.pyd", "QtMultimediaWidgets.pyd", "Qt6Multimedia.dll", "Qt6MultimediaWidgets.dll" )) { $RequiredFile = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter $RequiredName -File | Where-Object { $_.FullName -like "*\PySide6\*" } | Select-Object -First 1 if (-not $RequiredFile) { throw "Frozen Qt multimedia component is missing: $RequiredName" } } $PluginDirectories = @(Get-ChildItem -LiteralPath $Artifact -Recurse -Directory | Where-Object { $_.Name -eq "multimedia" -and $_.Parent.Name -eq "plugins" }) if ($PluginDirectories.Count -eq 0) { throw "Frozen PySide6 plugins/multimedia directory is missing" } foreach ($BackendName in @("ffmpegmediaplugin.dll", "windowsmediaplugin.dll")) { $Backend = $PluginDirectories | ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Filter $BackendName -File -ErrorAction SilentlyContinue } | Select-Object -First 1 if (-not $Backend) { throw "Frozen Qt multimedia backend is missing: $BackendName" } } Write-Host "Frozen Qt multimedia file gate passed." } if (-not [System.IO.Path]::IsPathRooted($Python)) { $Python = Join-Path $ProjectRoot $Python } if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) { throw "Build Python was not found: $Python" } $Npm = (Get-Command npm.cmd -ErrorAction Stop).Source Push-Location $ProjectRoot try { if (-not $SkipFrontendInstall) { & $Npm ci --prefix $CompanionRoot --no-audit --no-fund if ($LASTEXITCODE -ne 0) { throw "npm ci failed" } } & $Npm run build --prefix $CompanionRoot if ($LASTEXITCODE -ne 0) { throw "video companion build failed" } & $Python -m PyInstaller --noconfirm --clean $Spec if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" } $Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation" $Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1 $Resources = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "qtwebengine_resources*.pak" -File | Select-Object -First 1 $Companion = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "index.html" -File | Where-Object { $_.FullName -like "*video_companion_dist*" } | Select-Object -First 1 if (-not $Helper) { throw "QtWebEngineProcess.exe is missing from the artifact" } if (-not $Resources) { throw "QtWebEngine Chromium resources are missing from the artifact" } if (-not $Companion) { throw "video_companion_dist is missing from the artifact" } $PythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim() if ($LASTEXITCODE -ne 0 -or -not $PythonBase) { throw "Unable to resolve the build Python runtime directory" } foreach ($RuntimeDllName in @("libssl-3-x64.dll", "libcrypto-3-x64.dll")) { $SourceRuntimeDll = Join-Path $PythonBase "DLLs\$RuntimeDllName" $FrozenRuntimeDll = Join-Path $Artifact "_internal\$RuntimeDllName" if (-not (Test-Path -LiteralPath $SourceRuntimeDll -PathType Leaf)) { throw "Build Python runtime dependency is missing: $SourceRuntimeDll" } if (-not (Test-Path -LiteralPath $FrozenRuntimeDll -PathType Leaf)) { throw "Frozen Python runtime dependency is missing: $FrozenRuntimeDll" } $SourceHash = (Get-FileHash -LiteralPath $SourceRuntimeDll -Algorithm SHA256).Hash $FrozenHash = (Get-FileHash -LiteralPath $FrozenRuntimeDll -Algorithm SHA256).Hash if ($SourceHash -ne $FrozenHash) { throw "Frozen $RuntimeDllName does not match the build Python runtime" } } $Executable = Join-Path $Artifact "DoctorWorkstation.exe" if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) { throw "Frozen application entry point is missing: $Executable" } Assert-FrozenMultimedia -Artifact $Artifact Invoke-FrozenGate ` -Executable $Executable ` -Argument "--media-smoke-test" ` -GateName "Frozen Qt multimedia smoke gate" Invoke-FrozenGate ` -Executable $Executable ` -Argument "--smoke-test" ` -GateName "Frozen application entry smoke gate" Write-Host "Build complete: $Artifact" } finally { Pop-Location }