diff --git a/.github/actions/setup-windows-desktop/action.yml b/.github/actions/setup-windows-desktop/action.yml new file mode 100644 index 0000000..cd2ca5d --- /dev/null +++ b/.github/actions/setup-windows-desktop/action.yml @@ -0,0 +1,33 @@ +name: Setup Windows desktop +description: Normalize and prove the interactive Windows desktop used by Holo live E2E jobs. + +inputs: + expected-architecture: + description: Native runner architecture required by the job. + required: true + probe-apps: + description: Open and close the native app canaries before reporting readiness. + required: false + default: "false" + settle-seconds: + description: Seconds the normalized desktop must remain ready before the final probe. + required: false + default: "2" + +outputs: + artifact-path: + description: Directory containing readiness JSON and diagnostics. + value: ${{ steps.prepare.outputs.artifact-path }} + +runs: + using: composite + steps: + - name: Normalize and probe Windows desktop + id: prepare + shell: pwsh + env: + HOLO_WINDOWS_EXPECTED_ARCHITECTURE: ${{ inputs.expected-architecture }} + HOLO_WINDOWS_PROBE_APPS: ${{ inputs.probe-apps }} + HOLO_WINDOWS_SETTLE_SECONDS: ${{ inputs.settle-seconds }} + run: | + & "$env:GITHUB_ACTION_PATH/prepare.ps1" diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 new file mode 100644 index 0000000..3e8c5d5 --- /dev/null +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -0,0 +1,588 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$artifactDirectory = Join-Path $env:RUNNER_TEMP "holo-windows-desktop-readiness" +$readinessPath = Join-Path $artifactDirectory "windows-desktop-readiness.json" +$processesPath = Join-Path $artifactDirectory "processes.json" +$windowsPath = Join-Path $artifactDirectory "windows.json" +$appProbesPath = Join-Path $artifactDirectory "app-probes.json" +$screenshotPath = Join-Path $artifactDirectory "desktop-ready.png" +$initialWindowsPath = Join-Path $artifactDirectory "windows-before.json" +$initialProcessesPath = Join-Path $artifactDirectory "processes-before.json" +$normalizationLogPath = Join-Path $artifactDirectory "normalization.log" + +New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null +"artifact-path=$artifactDirectory" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + +Start-Transcript -Path $normalizationLogPath -Force | Out-Null + +Add-Type -TypeDefinition @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace HoloE2E { + public sealed class WindowSnapshot { + public long Handle { get; set; } + public int ProcessId { get; set; } + public string Title { get; set; } = ""; + public string ClassName { get; set; } = ""; + public bool Visible { get; set; } + public bool Hung { get; set; } + } + + public static class NativeWindows { + private delegate bool EnumWindowsProc(IntPtr handle, IntPtr parameter); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr handle, StringBuilder text, int maximumCount); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr handle, StringBuilder text, int maximumCount); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr handle, out uint processId); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr handle); + + [DllImport("user32.dll")] + private static extern bool IsHungAppWindow(IntPtr handle); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr handle); + + [DllImport("user32.dll")] + private static extern bool ShowWindowAsync(IntPtr handle, int command); + + [DllImport("user32.dll")] + private static extern bool PostMessage(IntPtr handle, uint message, IntPtr wordParameter, IntPtr longParameter); + + [DllImport("user32.dll")] + private static extern bool SetCursorPos(int x, int y); + + [DllImport("user32.dll")] + private static extern void mouse_event(uint flags, uint x, uint y, uint data, UIntPtr extraInfo); + + public static WindowSnapshot[] EnumerateWindows() { + var windows = new List(); + EnumWindows(delegate(IntPtr handle, IntPtr parameter) { + uint processId; + GetWindowThreadProcessId(handle, out processId); + var title = new StringBuilder(1024); + var className = new StringBuilder(256); + GetWindowText(handle, title, title.Capacity); + GetClassName(handle, className, className.Capacity); + windows.Add(new WindowSnapshot { + Handle = handle.ToInt64(), + ProcessId = checked((int)processId), + Title = title.ToString(), + ClassName = className.ToString(), + Visible = IsWindowVisible(handle), + Hung = IsHungAppWindow(handle) + }); + return true; + }, IntPtr.Zero); + return windows.ToArray(); + } + + public static long ForegroundHandle() { + return GetForegroundWindow().ToInt64(); + } + + public static bool Activate(long handle) { + var window = new IntPtr(handle); + ShowWindowAsync(window, 5); + return SetForegroundWindow(window); + } + + public static bool Hide(long handle) { + return ShowWindowAsync(new IntPtr(handle), 0); + } + + public static bool Close(long handle) { + return PostMessage(new IntPtr(handle), 0x0010, IntPtr.Zero, IntPtr.Zero); + } + + public static bool Click(int x, int y) { + if (!SetCursorPos(x, y)) { + return false; + } + mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero); + mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero); + return true; + } + } +} +"@ +Add-Type -AssemblyName UIAutomationClient + +function Get-HoloWindowSnapshot { + $foregroundHandle = [HoloE2E.NativeWindows]::ForegroundHandle() + $windows = foreach ($window in [HoloE2E.NativeWindows]::EnumerateWindows()) { + $process = Get-Process -Id $window.ProcessId -ErrorAction SilentlyContinue + [pscustomobject]@{ + handle = "0x{0:X}" -f $window.Handle + handle_value = $window.Handle + process_id = $window.ProcessId + process_name = if ($null -ne $process) { $process.ProcessName } else { $null } + title = $window.Title + class_name = $window.ClassName + visible = $window.Visible + hung = $window.Hung + foreground = $window.Handle -eq $foregroundHandle + } + } + return @($windows) +} + +function Write-HoloJson { + param( + [Parameter(Mandatory = $true)] [object] $Value, + [Parameter(Mandatory = $true)] [string] $Path + ) + $Value | ConvertTo-Json -Depth 6 | Set-Content -Path $Path -Encoding utf8 +} + +function Write-HoloProcessSnapshot { + param([Parameter(Mandatory = $true)] [string] $Path) + $processes = Get-Process | + Sort-Object ProcessName, Id | + Select-Object ProcessName, Id, MainWindowTitle, Responding + Write-HoloJson -Value @($processes) -Path $Path +} + +function Set-HoloDwordPolicy { + param( + [Parameter(Mandatory = $true)] [string] $Path, + [Parameter(Mandatory = $true)] [string] $Name, + [Parameter(Mandatory = $true)] [int] $Value + ) + New-Item -Path $Path -Force | Out-Null + New-ItemProperty -Path $Path -Name $Name -PropertyType DWord -Value $Value -Force | Out-Null + Write-Host "Set policy $Path\$Name=$Value" +} + +function Stop-HoloFirstRunProcesses { + foreach ($processName in @( + "msedge", + "SystemSettings", + "wsl", + "notepad", + "CalculatorApp", + "calc" + )) { + Get-Process -Name $processName -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } +} + +function Close-HoloBlockingWindows { + foreach ($window in Get-HoloWindowSnapshot | Where-Object { $_.visible }) { + if ($window.process_name -eq "wsl") { + Write-Host "Closing WSL provisioning console $($window.handle)" + [void][HoloE2E.NativeWindows]::Close([long]$window.handle_value) + Get-Process -Id $window.process_id -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } + } +} + +function Restart-HoloExplorer { + Get-Process -Name explorer -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + Start-Process -FilePath "$env:WINDIR\explorer.exe" -ArgumentList "shell:desktop" +} + +function Test-HoloPrivacyExperience { + try { + $root = [System.Windows.Automation.AutomationElement]::RootElement + $elements = $root.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + ) + foreach ($element in $elements) { + $name = $element.Current.Name + if ($name -match "(?i)choose privacy settings for your device|privacy settings for your device") { + return $true + } + } + return $false + } catch { + Write-Warning "UI Automation desktop probe failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + return $null + } +} + +function Complete-HoloPrivacyExperience { + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } + ) + if ($oobeWindows.Count -eq 0 -and (Test-HoloPrivacyExperience) -ne $true) { + Write-Host "Privacy OOBE proxy is absent; no semantic completion is required." + return $false + } + + Add-Type -AssemblyName System.Windows.Forms + $screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $navigationX = $screenBounds.Left + [Math]::Round($screenBounds.Width * 0.847) + $navigationY = $screenBounds.Top + [Math]::Round($screenBounds.Height * 0.854) + + $navigationMessage = ( + "Privacy OOBE proxy is visible; navigating its fixed bottom-right action at " + + "({0}, {1}) within screen bounds {2}x{3}." + ) -f + $navigationX, + $navigationY, + $screenBounds.Width, + $screenBounds.Height + Write-Host $navigationMessage + [void](Save-HoloScreenshot -Path (Join-Path $artifactDirectory "privacy-oobe-before.png")) + + $deadline = [DateTimeOffset]::UtcNow.AddSeconds(120) + $invocationCount = 0 + do { + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } + ) + $privacyExperienceVisible = Test-HoloPrivacyExperience + if ($oobeWindows.Count -eq 0 -and $privacyExperienceVisible -eq $false) { + Write-Host "Privacy OOBE proxy disappeared after semantic completion." + return $true + } + + $oobeWindow = $oobeWindows | Select-Object -First 1 + [void][HoloE2E.NativeWindows]::Activate([long]$oobeWindow.handle_value) + Start-Sleep -Milliseconds 250 + if (-not [HoloE2E.NativeWindows]::Click($navigationX, $navigationY)) { + throw "Failed to inject the privacy OOBE navigation click at ($navigationX, $navigationY)." + } + + $invocationCount += 1 + Write-Host "Injected privacy OOBE navigation click $invocationCount." + Start-Sleep -Seconds 3 + [void](Save-HoloScreenshot -Path ( + Join-Path $artifactDirectory "privacy-oobe-after-click-$invocationCount.png" + )) + } while ( + [DateTimeOffset]::UtcNow -lt $deadline -and + $invocationCount -lt 6 + ) + + Write-Warning ( + "Privacy OOBE did not complete naturally after {0} guarded navigation click(s)." -f + $invocationCount + ) + return $false +} + +function Assert-HoloPrivacyExperienceCompleted { + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } + ) + $privacyExperienceVisible = Test-HoloPrivacyExperience + if ($oobeWindows.Count -gt 0 -or $privacyExperienceVisible -ne $false) { + throw ( + "Privacy OOBE completion could not be proven: proxy_windows={0}, semantic_visible={1}" -f + $oobeWindows.Count, + $privacyExperienceVisible + ) + } + Write-Host "Privacy OOBE natural completion is proven." +} + +function Set-HoloExplorerForeground { + $windows = Get-HoloWindowSnapshot + $candidate = $windows | + Where-Object { + $_.visible -and + $_.process_name -eq "explorer" -and + $_.class_name -in @("CabinetWClass", "Shell_TrayWnd", "Progman", "WorkerW") + } | + Sort-Object @{ Expression = { if ($_.class_name -eq "CabinetWClass") { 0 } else { 1 } } } | + Select-Object -First 1 + if ($null -ne $candidate) { + [void][HoloE2E.NativeWindows]::Activate([long]$candidate.handle_value) + } +} + +function Get-HoloDesktopState { + $windows = Get-HoloWindowSnapshot + $visibleWindows = @($windows | Where-Object { $_.visible }) + $foreground = $windows | Where-Object { $_.foreground } | Select-Object -First 1 + $explorerWindows = @( + $visibleWindows | + Where-Object { + $_.process_name -eq "explorer" -and + $_.class_name -in @("CabinetWClass", "Shell_TrayWnd", "Progman", "WorkerW") + } + ) + $oobeWindows = @( + $visibleWindows | + Where-Object { + $_.class_name -eq "Shell_OOBEProxy" -or + $_.title -match "(?i)choose privacy settings|privacy settings for your device" + } + ) + $privacyExperienceVisible = Test-HoloPrivacyExperience + + $blockers = [System.Collections.Generic.List[string]]::new() + if ($oobeWindows.Count -gt 0 -or $privacyExperienceVisible -eq $true) { + [void]$blockers.Add("privacy_oobe") + } + if ($null -eq $privacyExperienceVisible) { + [void]$blockers.Add("desktop_oracle_unavailable") + } + if ($visibleWindows | Where-Object { $_.process_name -eq "msedge" }) { + [void]$blockers.Add("edge_first_run") + } + if ($visibleWindows | Where-Object { + $_.process_name -eq "wsl" -or + $_.title -match "(?i)Windows Subsystem for Linux|WSL update|install WSL" + }) { + [void]$blockers.Add("wsl_prompt") + } + if ($explorerWindows.Count -eq 0) { + [void]$blockers.Add("explorer_missing") + } elseif ($explorerWindows | Where-Object { $_.hung }) { + [void]$blockers.Add("explorer_unresponsive") + } + if ($null -ne $foreground -and $foreground.process_name -ne "explorer") { + [void]$blockers.Add("unexpected_foreground_app") + } + + [pscustomobject]@{ + blockers = @($blockers | Select-Object -Unique) + windows = $windows + foreground_process = if ($null -ne $foreground) { $foreground.process_name } else { $null } + foreground_title = if ($null -ne $foreground) { $foreground.title } else { $null } + explorer_responsive = $explorerWindows.Count -gt 0 -and -not ($explorerWindows | Where-Object { $_.hung }) + } +} + +function Wait-HoloDesktopReady { + param([int] $TimeoutSeconds = 120) + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) + do { + Close-HoloBlockingWindows + Set-HoloExplorerForeground + Start-Sleep -Milliseconds 500 + $state = Get-HoloDesktopState + if ($state.blockers.Count -eq 0) { + return $state + } + Write-Host "Desktop blockers: $($state.blockers -join ', ')" + Start-Sleep -Seconds 2 + } while ([DateTimeOffset]::UtcNow -lt $deadline) + return $state +} + +function Save-HoloScreenshot { + param([Parameter(Mandatory = $true)] [string] $Path) + try { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + } finally { + $graphics.Dispose() + } + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $bitmap.Dispose() + } + return (Test-Path $Path) -and (Get-Item $Path).Length -gt 0 + } catch { + Write-Warning "Screenshot capture failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + return $false + } +} + +function Invoke-HoloAppProbe { + param( + [Parameter(Mandatory = $true)] [string] $Name, + [Parameter(Mandatory = $true)] [scriptblock] $Start, + [Parameter(Mandatory = $true)] [scriptblock] $WindowMatches + ) + $startedAt = [DateTimeOffset]::UtcNow + & $Start + $deadline = [DateTimeOffset]::UtcNow.AddSeconds(20) + do { + Start-Sleep -Milliseconds 500 + $matchingWindows = @(Get-HoloWindowSnapshot | Where-Object $WindowMatches) + if ($matchingWindows.Count -gt 0) { + return [pscustomobject]@{ + name = $Name + passed = $true + duration_s = [Math]::Round(([DateTimeOffset]::UtcNow - $startedAt).TotalSeconds, 3) + windows = $matchingWindows + } + } + } while ([DateTimeOffset]::UtcNow -lt $deadline) + return [pscustomobject]@{ + name = $Name + passed = $false + duration_s = [Math]::Round(([DateTimeOffset]::UtcNow - $startedAt).TotalSeconds, 3) + windows = @() + } +} + +function Invoke-HoloAppProbes { + $results = @() + $results += Invoke-HoloAppProbe -Name "Notepad" ` + -Start { Start-Process -FilePath "notepad.exe" } ` + -WindowMatches { $_.visible -and ($_.process_name -like "notepad*" -or $_.title -match "(?i)Notepad") } + Get-Process -Name notepad -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + + $results += Invoke-HoloAppProbe -Name "Calculator" ` + -Start { Start-Process -FilePath "calc.exe" } ` + -WindowMatches { + $_.visible -and + ($_.process_name -in @("CalculatorApp", "ApplicationFrameHost") -or $_.title -match "(?i)Calculator") + } + foreach ($processName in @("CalculatorApp", "calc")) { + Get-Process -Name $processName -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } + + $results += Invoke-HoloAppProbe -Name "File Explorer" ` + -Start { Start-Process -FilePath "$env:WINDIR\explorer.exe" -ArgumentList "shell:desktop" } ` + -WindowMatches { + $_.visible -and + $_.process_name -eq "explorer" -and + $_.class_name -eq "CabinetWClass" -and + -not $_.hung + } + return @($results) +} + +function Write-HoloReadiness { + param( + [Parameter(Mandatory = $true)] [object] $State, + [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [string[]] $Blockers, + [Parameter(Mandatory = $true)] [bool] $ScreenshotAvailable + ) + Write-HoloJson -Value $State.windows -Path $windowsPath + Write-HoloProcessSnapshot -Path $processesPath + $payload = [ordered]@{ + schema_version = 1 + ready = $Blockers.Count -eq 0 + blockers = @($Blockers) + os_version = [System.Environment]::OSVersion.Version.ToString() + architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToUpperInvariant() + foreground_process = $State.foreground_process + foreground_title = $State.foreground_title + explorer_responsive = [bool]$State.explorer_responsive + screenshot_path = if ($ScreenshotAvailable) { Split-Path -Leaf $screenshotPath } else { $null } + processes_path = Split-Path -Leaf $processesPath + windows_path = Split-Path -Leaf $windowsPath + app_probes_path = Split-Path -Leaf $appProbesPath + } + Write-HoloJson -Value $payload -Path $readinessPath + Write-Host ($payload | ConvertTo-Json -Depth 6) +} + +$finalState = $null +$finalBlockers = @() +$screenshotAvailable = $false + +try { + Write-HoloJson -Value (Get-HoloWindowSnapshot) -Path $initialWindowsPath + Write-HoloProcessSnapshot -Path $initialProcessesPath + + Set-HoloDwordPolicy ` + -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE" ` + -Name "DisablePrivacyExperience" ` + -Value 1 + Set-HoloDwordPolicy ` + -Path "HKCU:\SOFTWARE\Policies\Microsoft\Windows\OOBE" ` + -Name "DisablePrivacyExperience" ` + -Value 1 + Set-HoloDwordPolicy ` + -Path "HKLM:\SOFTWARE\Policies\Microsoft\Edge" ` + -Name "HideFirstRunExperience" ` + -Value 1 + + [void](Complete-HoloPrivacyExperience) + Assert-HoloPrivacyExperienceCompleted + Stop-HoloFirstRunProcesses + Restart-HoloExplorer + $initialReadyState = Wait-HoloDesktopReady + + if ($initialReadyState.blockers.Count -gt 0) { + $finalState = $initialReadyState + $finalBlockers += @($initialReadyState.blockers) + Write-HoloJson -Value @() -Path $appProbesPath + } else { + $probeApps = $env:HOLO_WINDOWS_PROBE_APPS -eq "true" + if ($probeApps) { + $appProbeResults = Invoke-HoloAppProbes + } else { + $appProbeResults = @() + } + Write-HoloJson -Value @($appProbeResults) -Path $appProbesPath + + Stop-HoloFirstRunProcesses + Restart-HoloExplorer + $settleSeconds = [int]$env:HOLO_WINDOWS_SETTLE_SECONDS + if ($settleSeconds -gt 0) { + Start-Sleep -Seconds $settleSeconds + } + $finalState = Wait-HoloDesktopReady + $finalBlockers += @($finalState.blockers) + if ($probeApps -and ($appProbeResults | Where-Object { -not $_.passed })) { + $finalBlockers += "app_probe_failed" + } + } + + $observedArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + if ($observedArchitecture -ne $env:HOLO_WINDOWS_EXPECTED_ARCHITECTURE) { + $finalBlockers += "architecture_mismatch" + } + + $screenshotAvailable = Save-HoloScreenshot -Path $screenshotPath + if (-not $screenshotAvailable) { + $finalBlockers += "screenshot_unavailable" + } +} catch { + Write-Error -ErrorRecord $_ + $finalBlockers += "normalization_failed" + if ($null -eq $finalState) { + $finalState = Get-HoloDesktopState + } + if (-not (Test-Path $appProbesPath)) { + Write-HoloJson -Value @() -Path $appProbesPath + } + $screenshotAvailable = Save-HoloScreenshot -Path $screenshotPath + if (-not $screenshotAvailable) { + $finalBlockers += "screenshot_unavailable" + } +} finally { + if ($null -eq $finalState) { + $finalState = Get-HoloDesktopState + } + Write-HoloReadiness ` + -State $finalState ` + -Blockers @($finalBlockers | Select-Object -Unique) ` + -ScreenshotAvailable $screenshotAvailable + Stop-Transcript | Out-Null +} + +if ($finalBlockers.Count -gt 0) { + throw "Windows desktop readiness failed: $(@($finalBlockers | Select-Object -Unique) -join ', ')" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09a090..efa7089 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: run: | $ErrorActionPreference = "Stop" $runtimeSha = ((Get-Content shas/windows-arm64.txt).Trim() -split "=", 2)[1] + $installerPath = (Resolve-Path .\install\install.ps1).Path $hasCandidate = [bool]$env:HAI_RUNTIME_CANDIDATE_ARCHIVE if ($runtimeSha -match "^0{64}$" -and -not $hasCandidate) { $env:HOLO_INSTALL_SKIP_RUN_SETUP = "1" @@ -94,8 +95,13 @@ jobs: $env:HAI_AGENT_RUNTIME_DOWNLOAD_SHA256 = $env:HAI_RUNTIME_CANDIDATE_SHA256 } - & .\install\install.ps1 - & "$env:HOLO_HOME\bin\holo.exe" --help + Push-Location $env:RUNNER_TEMP + try { + & $installerPath + & "$env:HOLO_HOME\bin\holo.exe" --help + } finally { + Pop-Location + } } finally { if ($null -ne $server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force diff --git a/.github/workflows/holo-full-e2e.yml b/.github/workflows/holo-full-e2e.yml index a0b1933..39aaead 100644 --- a/.github/workflows/holo-full-e2e.yml +++ b/.github/workflows/holo-full-e2e.yml @@ -342,6 +342,25 @@ jobs: curl -fsSL "https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_SHA}/install/install.sh" | bash "$HOLO_HOME/bin/holo" --help + # Run this after all dependency/bootstrap work so late startup surfaces, + # including the Windows 11 ARM WSL prompt, cannot contaminate the first + # model observation. + - name: Prepare Windows ARM64 desktop + if: matrix.artifact_platform == 'windows-arm64' + uses: ./.github/actions/setup-windows-desktop + with: + expected-architecture: ARM64 + probe-apps: "true" + + - name: Upload Windows ARM64 desktop readiness + if: always() && matrix.artifact_platform == 'windows-arm64' + uses: actions/upload-artifact@v4 + with: + name: holo-windows-desktop-readiness-${{ github.run_id }}-shard-${{ matrix.shard_index }} + path: ${{ runner.temp }}/holo-windows-desktop-readiness/ + if-no-files-found: error + retention-days: 14 + - name: List full e2e shard task ids shell: bash run: | diff --git a/tests/e2e/test_windows_desktop.py b/tests/e2e/test_windows_desktop.py new file mode 100644 index 0000000..131c612 --- /dev/null +++ b/tests/e2e/test_windows_desktop.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from .windows_desktop import WindowsDesktopBlocker, WindowsDesktopReadiness, load_windows_desktop_readiness + + +def _payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": 1, + "ready": True, + "blockers": [], + "os_version": "10.0.26200", + "architecture": "ARM64", + "foreground_process": "explorer", + "foreground_title": "Desktop", + "explorer_responsive": True, + "screenshot_path": "desktop-ready.png", + "processes_path": "processes.json", + "windows_path": "windows.json", + "app_probes_path": "app-probes.json", + } + payload.update(overrides) + return payload + + +def test_ready_desktop_contract_has_no_blockers() -> None: + readiness = WindowsDesktopReadiness.model_validate(_payload()) + + assert readiness.ready is True + assert readiness.blockers == () + assert readiness.explorer_responsive is True + + +def test_failed_screenshot_is_an_explicit_blocker() -> None: + readiness = WindowsDesktopReadiness.model_validate( + _payload( + ready=False, + blockers=[WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE.value], + screenshot_path=None, + ) + ) + + assert readiness.ready is False + assert readiness.blockers == (WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE,) + + +@pytest.mark.parametrize( + "overrides", + [ + {"ready": True, "blockers": [WindowsDesktopBlocker.WSL_PROMPT.value]}, + {"ready": False, "blockers": []}, + {"ready": False, "blockers": [WindowsDesktopBlocker.WSL_PROMPT.value], "screenshot_path": None}, + { + "ready": False, + "blockers": [WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE.value], + "screenshot_path": "desktop-ready.png", + }, + ], +) +def test_inconsistent_readiness_evidence_is_rejected(overrides: dict[str, object]) -> None: + with pytest.raises(ValidationError): + WindowsDesktopReadiness.model_validate(_payload(**overrides)) + + +def test_readiness_loader_rejects_wrong_runner_architecture(tmp_path: Path) -> None: + path = tmp_path / "windows-desktop-readiness.json" + path.write_text(json.dumps(_payload(architecture="AMD64")), encoding="utf-8") + + with pytest.raises(ValueError, match="expected 'ARM64', observed 'AMD64'"): + load_windows_desktop_readiness(path, expected_architecture="ARM64") diff --git a/tests/e2e/windows_desktop.py b/tests/e2e/windows_desktop.py new file mode 100644 index 0000000..93ac7df --- /dev/null +++ b/tests/e2e/windows_desktop.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +from enum import StrEnum +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class WindowsDesktopBlocker(StrEnum): + """A condition that makes a hosted Windows desktop unsafe for a live task.""" + + PRIVACY_OOBE = "privacy_oobe" + EDGE_FIRST_RUN = "edge_first_run" + WSL_PROMPT = "wsl_prompt" + EXPLORER_MISSING = "explorer_missing" + EXPLORER_UNRESPONSIVE = "explorer_unresponsive" + SCREENSHOT_UNAVAILABLE = "screenshot_unavailable" + UNEXPECTED_FOREGROUND_APP = "unexpected_foreground_app" + APP_PROBE_FAILED = "app_probe_failed" + ARCHITECTURE_MISMATCH = "architecture_mismatch" + NORMALIZATION_FAILED = "normalization_failed" + DESKTOP_ORACLE_UNAVAILABLE = "desktop_oracle_unavailable" + + +class WindowsDesktopReadiness(BaseModel): + """Versioned evidence emitted before a Windows live E2E task starts.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] = Field(description="Readiness artifact schema version.", examples=[1]) + ready: bool = Field(description="Whether the desktop satisfies every readiness invariant.", examples=[True]) + blockers: tuple[WindowsDesktopBlocker, ...] = Field( + description="Exact conditions preventing model execution.", + examples=[[]], + ) + os_version: str = Field(description="Windows version reported by the runner.", examples=["10.0.26200"]) + architecture: str = Field(description="Native process architecture.", examples=["ARM64"]) + foreground_process: str | None = Field( + description="Foreground process name after normalization.", + examples=["explorer"], + ) + foreground_title: str | None = Field( + description="Foreground top-level window title after normalization.", + examples=["Desktop"], + ) + explorer_responsive: bool = Field( + description="Whether a visible Explorer shell window exists and is responsive.", + examples=[True], + ) + screenshot_path: str | None = Field( + description="Readiness screenshot path, or null when screenshot capture itself failed.", + examples=["desktop-ready.png"], + ) + processes_path: str = Field( + description="Path to the bounded process snapshot.", + examples=["processes.json"], + ) + windows_path: str = Field( + description="Path to the top-level window snapshot.", + examples=["windows.json"], + ) + app_probes_path: str = Field( + description="Path to the native application probe results.", + examples=["app-probes.json"], + ) + + @model_validator(mode="after") + def validate_consistency(self) -> WindowsDesktopReadiness: + if self.ready == bool(self.blockers): + raise ValueError("ready must be true exactly when blockers is empty") + if self.screenshot_path is None and WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE not in self.blockers: + raise ValueError("screenshot_path may be null only for screenshot_unavailable") + if self.screenshot_path is not None and WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE in self.blockers: + raise ValueError("screenshot_unavailable requires a null screenshot_path") + return self + + def require_architecture(self, expected_architecture: str) -> WindowsDesktopReadiness: + if self.architecture.casefold() != expected_architecture.casefold(): + raise ValueError( + f"Windows desktop architecture mismatch: expected {expected_architecture!r}, " + f"observed {self.architecture!r}" + ) + return self + + +def load_windows_desktop_readiness( + path: Path, + *, + expected_architecture: str, +) -> WindowsDesktopReadiness: + payload = json.loads(path.read_text(encoding="utf-8")) + readiness = WindowsDesktopReadiness.model_validate(payload) + return readiness.require_architecture(expected_architecture) diff --git a/tests/test_publish_workflow_static.py b/tests/test_publish_workflow_static.py index f975aa5..ff691e9 100644 --- a/tests/test_publish_workflow_static.py +++ b/tests/test_publish_workflow_static.py @@ -103,6 +103,11 @@ def test_windows_arm64_installer_and_full_e2e_scaffolding() -> None: assert "UV_NO_BUILD" in rendered_installer assert "steps.dependency.outputs.manifest_path" in rendered_installer assert "steps.client.outputs.wheel_path" in rendered_installer + install_step = next( + step for step in installer["steps"] if step.get("name") == "Install from the pull request checkout" + ) + assert "Resolve-Path .\\install\\install.ps1" in install_step["run"] + assert "Push-Location $env:RUNNER_TEMP" in install_step["run"] assert "windows-11-arm" in ci["jobs"]["python"]["strategy"]["matrix"]["os"] full_e2e_path = ROOT / ".github/workflows/holo-full-e2e.yml" @@ -126,6 +131,11 @@ def test_windows_arm64_installer_and_full_e2e_scaffolding() -> None: assert "OPENSSL_DIR=" in openssl["run"] assert "OPENSSL_STATIC=1" in openssl["run"] + step_names = [step.get("name") for step in full_e2e["jobs"]["full-e2e"]["steps"]] + assert step_names.index("Install dependencies") < step_names.index("Prepare Windows ARM64 desktop") + assert step_names.index("Prepare Windows ARM64 desktop") < step_names.index("List full e2e shard task ids") + assert step_names.index("Upload Windows ARM64 desktop readiness") < step_names.index("Run full live e2e suite") + def test_windows_arm64_candidate_is_verified_and_consumed_before_merge() -> None: action = yaml.safe_load(