diff options
| author | akbash <akbash@herdr.dev> | 2026-09-18 02:13:13 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-18 01:13:13 +0200 |
| commit | b88e8116e9e1f7e2f6e8b211536311cd17feca9a (patch) | |
| tree | 699535f31233ae1aee63a110bdbf2e989ba0878b /scripts | |
| parent | 14351f121f6fc8c84b7c6c6eda78ad210e93cfcb (diff) | |
test: add local windows input gauntlet (#4298)
* test: add local windows input gauntlet
refs #4251
* test: add local windows input gauntlet
Harden elevation and channel identity checks, distinguish known host limitations, and surface incomplete cleanup.
refs #4251
* test(windows): harden input gauntlet execution
refs #4251
* test(windows): make input gauntlet self-contained
refs #4251
* test(windows): bound input gauntlet failure paths
refs #4251
* test(windows): extend input qualification gauntlet
refs #4251
* test(windows): complete input qualification matrix
refs #4251
---------
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/test_windows_input.ps1 | 415 | ||||
| -rw-r--r-- | scripts/test_windows_input.py | 243 | ||||
| -rw-r--r-- | scripts/windows_input/Bootstrap.ps1 | 51 | ||||
| -rw-r--r-- | scripts/windows_input/Common.ps1 | 88 | ||||
| -rw-r--r-- | scripts/windows_input/Native.cs | 439 | ||||
| -rw-r--r-- | scripts/windows_input/Probe.ps1 | 70 | ||||
| -rw-r--r-- | scripts/windows_input/README.md | 213 | ||||
| -rw-r--r-- | scripts/windows_input/report.py | 446 |
8 files changed, 1965 insertions, 0 deletions
diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 new file mode 100644 index 00000000..dfbd41b4 --- /dev/null +++ b/scripts/test_windows_input.ps1 @@ -0,0 +1,415 @@ +#Requires -Version 7.0 +<# Local interactive qualification, deliberately excluded from normal CI. + F12 aborts before the next injected gesture. Use an isolated unlocked desktop. + SendInput has an unavoidable focus race: do not use this while doing other work. +#> +[CmdletBinding()] +param( + [string] $ExePath, + [string] $StablePath, + [string] $PreviewPath, + [ValidateSet('default', 'win32', 'vt')][string] $Profile = 'default', + [ValidateSet('native', 'legacy', 'mok2', 'kitty')][string[]] $Modes = @('native', 'legacy', 'mok2', 'kitty'), + [ValidateSet('stable', 'preview')][string[]] $Channels = @('stable', 'preview'), + [ValidateSet('direct', 'herdr')][string[]] $Paths = @('direct', 'herdr'), + [string[]] $Cases, + [int[]] $Widths = @(80, 119, 120, 121, 132, 160, 240), + [int[]] $Heights = @(24, 50), + [string] $OutputDirectory, + [switch] $AllowInputInjection, + [switch] $Manual, + [switch] $MatrixOnly +) +. "$PSScriptRoot/windows_input/Common.ps1" +$reportScript = Join-Path $PSScriptRoot 'windows_input/report.py' +$python = (Get-Command python -ErrorAction Stop).Source +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PWD ".local/windows-input/$([guid]::NewGuid().ToString('N'))" } +if (Test-Path -LiteralPath $OutputDirectory) { throw 'Output directory must be new, to prevent stale evidence or overwrites' } +$root = [IO.Directory]::CreateDirectory($OutputDirectory).FullName +$null = Invoke-GauntletProcess $python @($reportScript, 'matrix', '--output', (Join-Path $root 'matrix.json')) +$matrix = Read-GauntletJson (Join-Path $root 'matrix.json') +if ($MatrixOnly) { Write-Host "Matrix: $root/matrix.json"; exit 0 } +$knownCases = @($matrix.cases | ForEach-Object id) +if (@($Cases).Count -and @($Cases | Where-Object { $_ -notin $knownCases }).Count) { throw 'Unknown case selection' } +$selectedCases = if (@($Cases).Count) { @($matrix.cases | Where-Object id -in $Cases) } else { @($matrix.cases) } +if (-not $IsWindows) { throw 'Real-host qualification requires Windows and an interactive desktop; no tests passed' } +if (-not $AllowInputInjection) { throw 'Read scripts/windows_input/README.md, then explicitly pass -AllowInputInjection on an isolated desktop' } +if (-not [Environment]::UserInteractive) { throw 'Interactive desktop unavailable' } +if ($Widths.Count -eq 0 -or $Heights.Count -eq 0 -or @($Widths | Where-Object { $_ -lt 40 -or $_ -gt 500 }).Count -or @($Heights | Where-Object { $_ -lt 15 -or $_ -gt 150 }).Count) { throw 'Invalid geometry selection' } +Add-Type -Path "$PSScriptRoot/windows_input/Native.cs" +[HerdrInputGauntlet.Desktop]::AssertNotElevated($PID) +[HerdrInputGauntlet.Desktop]::Neutral() +$controllerWindow = [IntPtr]::Zero +if (@($selectedCases | Where-Object id -eq 'mouse-focus-refresh').Count) { + $controllerWindow = [HerdrInputGauntlet.Desktop]::GetForegroundWindow() + if ($controllerWindow -eq [IntPtr]::Zero) { throw 'Controller has no foreground window for focus-cycle qualification' } + [HerdrInputGauntlet.Desktop]::AssertNotElevated([HerdrInputGauntlet.Desktop]::Pid($controllerWindow)) +} +$sourceCommit = $null +$sourceDirty = $null +if (-not $ExePath) { + $repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + $sourceCommit = (& git -C $repo rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { throw 'Could not identify the source checkout commit' } + $sourceDirty = [bool](& git -C $repo status --porcelain) + Write-Host "Building current checkout: $repo" + Push-Location $repo + try { + & cargo build --release --locked + if ($LASTEXITCODE -ne 0) { throw "cargo build failed with exit code $LASTEXITCODE" } + } finally { Pop-Location } + $targetRoot = if ($env:CARGO_TARGET_DIR) { [IO.Path]::GetFullPath($env:CARGO_TARGET_DIR, $repo) } else { Join-Path $repo 'target' } + $builtExe = Join-Path $targetRoot 'release/herdr.exe' + $package = Join-Path $repo '.local/windows-input/cache/Microsoft.Windows.Console.ConPTY.nupkg' + $stage = Join-Path $root 'package' + Write-Host 'Staging the current binary with the pinned ConPTY runtime' + $null = Invoke-GauntletProcess $python @((Join-Path $PSScriptRoot 'package_windows_conpty.py'), 'stage', '--package', $package, '--herdr-exe', $builtExe, '--output-dir', $stage) -Timeout 300 + $ExePath = Join-Path $stage 'herdr.exe' +} +$exe = (Resolve-Path -LiteralPath $ExePath).Path +$conpty = Join-Path ([IO.Path]::GetDirectoryName($exe)) 'conpty/conpty.dll' +if (-not (Test-Path -LiteralPath $conpty -PathType Leaf)) { throw "Selected Herdr binary has no adjacent bundled ConPTY runtime: $exe. Pass -ExePath to a packaged herdr.exe" } +$pwsh = (Get-Process -Id $PID).Path +$exeHash = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash +if ($sourceCommit) { Write-Host "Source: $sourceCommit$(if ($sourceDirty) { ' + working tree changes' })" } +Write-Host "Herdr under test: $exe" +Write-Host "SHA-256: $exeHash" +Write-Host "Modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')" +Write-Host "Channels: $($Channels -join ', '); paths: $($Paths -join ', '); cases: $(if (@($Cases).Count) { $Cases -join ', ' } else { 'all' })" +Write-Host "Clipboard formats at start: $([HerdrInputGauntlet.Desktop]::CountClipboardFormats()) (paste runs only when zero)" +$document = @{ schema = 1; run = [IO.Path]::GetFileName($root); started = [DateTime]::UtcNow.ToString('O'); + source_commit = $sourceCommit; source_dirty = $sourceDirty; exe = $exe; exe_sha256 = $exeHash; + powershell = $PSVersionTable.PSVersion.ToString(); os = [Environment]::OSVersion.VersionString; + profile = $Profile; controller_elevated = $false; observations = @(); hosts = @(); errors = @(); cleanup_errors = @(); + widths = $Widths; heights = $Heights; modes = $Modes; channels = $Channels; paths = $Paths; + cases = @($selectedCases | ForEach-Object id); note = 'Desktop input evidence; native qualification still required' } +$rawReport = Join-Path $root 'observations.json' +$artifactReport = Join-Path $root 'report.json' + +function Save-Report { Write-GauntletJson $rawReport $document } +function Resolve-Terminal($Name, $Override) { + if ($Override) { return @{ path = (Resolve-Path -LiteralPath $Override).Path; app_user_model_id = $null } } + $packageName = if ($Name -eq 'stable') { 'Microsoft.WindowsTerminal' } else { 'Microsoft.WindowsTerminalPreview' } + $package = @(Get-AppxPackage -Name $packageName -ErrorAction SilentlyContinue | Sort-Object Version -Descending) + if ($package.Count -eq 0) { return $null } + $path = Join-Path $package[0].InstallLocation 'WindowsTerminal.exe' + if (-not (Test-Path -LiteralPath $path)) { return $null } + return @{ path = $path; app_user_model_id = "$($package[0].PackageFamilyName)!App" } +} +function Observer-Request($Plan, $Action, $Value = $null) { + $id = [guid]::NewGuid().ToString('N') + Write-GauntletJson (Join-Path $Plan.work 'request.json') @{ id = $id; nonce = $Plan.nonce; action = $Action; value = $Value } + $reply = Wait-GauntletJson (Join-Path $Plan.work 'reply.json') $root { param($v) $v.id -eq $id -and $v.nonce -eq $Plan.nonce -and $v.action -eq $Action } + if ($reply.error) { throw "Observer failed: $($reply.error)" } + return $reply +} +function Outer-State($Plan) { + $after = if ($Plan.ContainsKey('outer_sequence')) { $Plan.outer_sequence } else { 0 } + $state = Wait-GauntletJson (Join-Path $Plan.work 'outer.json') $root { param($v) $v.nonce -eq $Plan.nonce -and $v.sequence -gt $after } + $Plan.outer_sequence = $state.sequence + return $state +} +function Set-ObservedGeometry($Plan, $Window, $WindowPid, $Width, $Height) { + # Correct against actual console cell measurements, not a claimed pixel size. + for ($attempt = 0; $attempt -lt 8; $attempt++) { + [HerdrInputGauntlet.Desktop]::Guard($Window, $Plan.nonce, $WindowPid) + $outer = Outer-State $Plan + $w = [int]$outer.geometry[0]; $h = [int]$outer.geometry[1] + if ($w -eq $Width -and $h -eq $Height) { return $outer } + [HerdrInputGauntlet.Desktop]::Resize($Window, $Plan.nonce, $WindowPid, ($Width - $w) * 8, ($Height - $h) * 16) + Start-Sleep -Milliseconds 350 + Update-GauntletLease $root + } + return $null +} +function New-Observation($HostName, $Plan, $Width, $Height, $Case) { + return @{ host = $HostName; path = $Plan.path; mode = $Plan.mode; width = $Width; height = $Height; case = $Case.id; + nonce = $Plan.nonce; status = 'inconclusive'; ready = $false; focus_verified = $false; complete = $false } +} + +try { + [HerdrInputGauntlet.Desktop]::StartEmergencyStop() + $terminals = @{ stable = (Resolve-Terminal 'stable' $StablePath); preview = (Resolve-Terminal 'preview' $PreviewPath) } + foreach ($channel in $Channels) { + $terminal = $terminals[$channel] + if ($terminal) { Write-Host "Windows Terminal $channel`: $($terminal.path) ($((Get-Item $terminal.path).VersionInfo.FileVersion))" } + else { Write-Host "Windows Terminal $channel`: not installed (channel will be not_run)" } + } + $launcherIdentities = @{}; $installationIdentities = @{} + foreach ($channel in $Channels) { + if ($terminals[$channel]) { + $launcherIdentities[$channel] = [HerdrInputGauntlet.Desktop]::FileIdentity($terminals[$channel].path) + $installationIdentities[$channel] = [HerdrInputGauntlet.Desktop]::FileIdentity([IO.Path]::GetDirectoryName($terminals[$channel].path)) + } + } + if ($Channels -contains 'stable' -and $Channels -contains 'preview' -and $terminals.stable -and $terminals.preview -and ($launcherIdentities.stable -eq $launcherIdentities.preview -or $installationIdentities.stable -eq $installationIdentities.preview)) { + throw 'Stable and Preview resolve to the same executable/installation; refusing duplicate channel evidence' + } + foreach ($hostName in $Channels) { + $terminal = $terminals[$hostName] + if (-not $terminal) { + $document.hosts += @{ channel = $hostName; status = 'not_run'; reason = 'Terminal installation not found; supply explicit path' } + Save-Report + continue + } + $hostRecord = @{ channel = $hostName; launcher = $terminal.path; launcher_version = (Get-Item $terminal.path).VersionInfo.FileVersion; + launcher_identity = $launcherIdentities[$hostName]; installation_identity = $installationIdentities[$hostName]; runs = @() } + $document.hosts += $hostRecord + foreach ($path in $Paths) { foreach ($mode in $Modes) { + $nonce = 'herdr-gauntlet-' + [guid]::NewGuid().ToString('N') + $work = [IO.Directory]::CreateDirectory((Join-Path $root $nonce)).FullName + $configHome = [IO.Directory]::CreateDirectory((Join-Path $work 'config')).FullName + $config = Join-Path $configHome 'test.toml' + $shellToml = $pwsh | ConvertTo-Json -Compress + [IO.File]::WriteAllText($config, "onboarding = false`n[terminal]`ndefault_shell = $shellToml`n[ui]`nmouse_capture = true`n") + $plan = @{ root = $root; work = $work; nonce = $nonce; session = $nonce; config = $config; config_home = $configHome; + path = $path; mode = $mode; profile = $Profile; exe = $exe; pwsh = $pwsh; input_trace = (Join-Path $work 'input-transport.log') } + $planPath = Join-Path $work 'plan.json' + Write-GauntletJson $planPath $plan + $window = [IntPtr]::Zero; $windowPid = 0; $server = $null; $launcher = $null; $clipboardSequence = $null; $injectionAuthorized = $false + $cursorPosition = $null; $mouseReporting = $false + Update-GauntletLease $root + try { + if ($path -eq 'herdr') { + $server = New-GauntletProcess $exe @('--session', $nonce, 'server') $plan -Capture + # Drain pipes asynchronously; server output must never block readiness. + $serverOut = $server.StandardOutput.ReadToEndAsync(); $serverErr = $server.StandardError.ReadToEndAsync() + $deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + Update-GauntletLease $root + try { $status = Invoke-GauntletProcess $exe @('--session', $nonce, 'status', 'server') $plan -Timeout 5 } catch { $status = '' } + if ($status -match 'status: running') { break } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + if ($status -notmatch 'status: running') { throw 'Owned server did not become ready' } + $created = (Invoke-GauntletProcess $exe @('--session', $nonce, 'workspace', 'create', '--cwd', $work, '--focus') $plan) | ConvertFrom-Json + $pane = $created.result.root_pane.pane_id + if (-not $pane) { throw 'Missing owned probe pane identity' } + $command = '& ' + (Quote-GauntletPS $pwsh) + ' -NoProfile -File ' + (Quote-GauntletPS "$PSScriptRoot/windows_input/Probe.ps1") + ' -PlanPath ' + (Quote-GauntletPS $planPath) + $null = Invoke-GauntletProcess $exe @('--session', $nonce, 'pane', 'run', $pane, $command) $plan + } + # Always request a new window. Bootstrap independently clears inherited identity. + $terminalArguments = @('-w', 'new', 'new-tab', '--title', $nonce, '--suppressApplicationTitle', '--', $pwsh, '-NoProfile', '-File', "$PSScriptRoot/windows_input/Bootstrap.ps1", '-PlanPath', $planPath) + if ($terminal.app_user_model_id) { + $launcher = Get-Process -Id ([HerdrInputGauntlet.Desktop]::ActivateApplication($terminal.app_user_model_id, $terminalArguments)) -ErrorAction SilentlyContinue + } else { + $launcher = New-GauntletProcess $terminal.path $terminalArguments $plan + } + $deadline = [DateTime]::UtcNow.AddSeconds(25) + do { + Update-GauntletLease $root + $window = [HerdrInputGauntlet.Desktop]::Find($nonce) + if ($window -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ($window -eq [IntPtr]::Zero) { throw 'No unique owned Terminal window appeared' } + $windowPid = [HerdrInputGauntlet.Desktop]::Pid($window) + $windowProcess = Get-Process -Id $windowPid + if ($windowProcess.ProcessName -ne 'WindowsTerminal') { throw 'Nonce window is not Windows Terminal; refusing injection' } + [HerdrInputGauntlet.Desktop]::AssertNotElevated($windowPid) + $imageIdentity = [HerdrInputGauntlet.Desktop]::FileIdentity($windowProcess.Path) + $installationIdentity = [HerdrInputGauntlet.Desktop]::FileIdentity([IO.Path]::GetDirectoryName($windowProcess.Path)) + $processIdentity = "$windowPid/$($windowProcess.StartTime.ToUniversalTime().Ticks)" + foreach ($otherHost in $document.hosts) { + if ($otherHost.channel -ne $hostName -and $otherHost.ContainsKey('runs')) { + foreach ($other in $otherHost.runs) { + if ($other.image_identity -eq $imageIdentity -or $other.installation_identity -eq $installationIdentity -or $other.process_identity -eq $processIdentity) { + throw 'Stable and Preview activated the same Terminal installation/process; refusing duplicate evidence' + } + } + } + } + $runRecord = @{ nonce = $nonce; path = $path; mode = $mode; hwnd = $window.ToInt64(); pid = $windowPid; + terminal_path = $windowProcess.Path; terminal_version = $windowProcess.MainModule.FileVersionInfo.FileVersion; + elevated = $false; image_identity = $imageIdentity; installation_identity = $installationIdentity; process_identity = $processIdentity; + layout = [HerdrInputGauntlet.Desktop]::Layout($window) } + $hostRecord.runs += $runRecord + $ready = Wait-GauntletJson (Join-Path $work 'ready.json') $root { param($v) $v.nonce -eq $nonce -and $v.mode -eq $mode } + [HerdrInputGauntlet.Desktop]::Focus($window, $nonce, $windowPid) + $injectionAuthorized = $true + Start-Sleep -Milliseconds 500 + # Full case catalogue once; boundary geometries run discriminating sentinels. + $geometries = @(@{ width = 120; height = 30; full = $true }) + foreach ($height in $Heights) { foreach ($width in $Widths) { $geometries += @{ width = $width; height = $height; full = $false } } } + # Return narrow after wide to exercise repeated reflow/recovery. + $geometries += @{ width = 80; height = 30; full = $false } + $phase = 0 + foreach ($geometry in $geometries) { + $phase++ + $selected = if ($geometry.full) { $selectedCases } else { @($selectedCases | Where-Object { $_.id -in @('letter-a', 'shift-enter', 'paste-lf', 'mouse-focus-refresh') }) } + $outer = Set-ObservedGeometry $plan $window $windowPid $geometry.width $geometry.height + foreach ($case in $selected) { + $row = New-Observation $hostName $plan $geometry.width $geometry.height $case + $row.phase = $phase + $document.observations += $row + if ($case.kind -eq 'qualification' -or -not $case.expected.ContainsKey($mode)) { $row.status = 'not_run'; $row.reason = 'Requires separate qualification: ' + $case.id; continue } + if ($case.kind -eq 'manual' -and -not $Manual) { $row.status = 'not_run'; $row.reason = 'Operator-assisted case; rerun with -Manual and declared layout'; continue } + $layoutChords = $null + if ($case.kind -eq 'layout-key') { + try { + $layoutChords = @([HerdrInputGauntlet.Desktop]::DeadKeyChord($window, [char]$case.dead), [int[]]@([int]$case.base_vk)) + $row.input_layout = [HerdrInputGauntlet.Desktop]::Layout($window) + } catch { + $row.status = 'not_run' + $row.reason = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message } + continue + } + } + if ($null -eq $outer) { $row.status = 'not_run'; $row.reason = 'Requested cell geometry not reached; monitor/font/window constraints'; continue } + $fresh = Outer-State $plan + if ($fresh.geometry[0] -ne $geometry.width -or $fresh.geometry[1] -ne $geometry.height) { + $fresh = Set-ObservedGeometry $plan $window $windowPid $geometry.width $geometry.height + if ($null -eq $fresh) { $row.status = 'inconclusive'; $row.reason = 'Geometry changed and could not be restored'; continue } + } + $row.outer_geometry = $fresh.geometry + $row.outer_sequence = $fresh.sequence + $row.negotiation_hex = $ready.negotiation_hex + if ($mode -eq 'kitty' -and -not $ready.kitty_acknowledged) { $row.status = 'inconclusive'; $row.reason = 'Kitty disambiguation query not acknowledged; host support is not established'; continue } + if ($case.kind -in @('paste', 'mouse-interleave') -and [HerdrInputGauntlet.Desktop]::CountClipboardFormats() -ne 0) { + $row.status = 'not_run'; $row.reason = 'Clipboard is not empty; refusing to replace user data'; continue + } + if ($case.kind -in @('mouse-interleave', 'mouse-focus-refresh')) { $null = Observer-Request $plan 'mouse-on'; $mouseReporting = $true } + $traceLineCount = if ($path -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) { @(Get-Content -LiteralPath $plan.input_trace).Count } else { 0 } + $begin = Observer-Request $plan 'begin' + $row.ready = $true; $row.pane_geometry = $begin.geometry + [HerdrInputGauntlet.Desktop]::Guard($window, $nonce, $windowPid) + $row.focus_verified = $true + if ($case.kind -eq 'key') { + $row.scans = @([HerdrInputGauntlet.Desktop]::Scans($window, [int[]]$case.chords[0])) + foreach ($chord in $case.chords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) } + } elseif ($case.kind -eq 'paste') { + $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboard($window, $case.text) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86)) + } elseif ($case.kind -eq 'mouse-interleave') { + $cursorPosition = [HerdrInputGauntlet.Desktop]::Cursor() + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(65)) + [HerdrInputGauntlet.Desktop]::MouseMoveInside($window, $nonce, $windowPid, -60) + Start-Sleep -Milliseconds 150 + $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboard($window, $case.text) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86)) + Start-Sleep -Milliseconds 150 + [HerdrInputGauntlet.Desktop]::MouseMoveInside($window, $nonce, $windowPid, 60) + Start-Sleep -Milliseconds 150 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(66)) + } elseif ($case.kind -eq 'mouse-focus-refresh') { + $cursorPosition = [HerdrInputGauntlet.Desktop]::Cursor() + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(65)) + [HerdrInputGauntlet.Desktop]::MouseClickWheelInside($window, $nonce, $windowPid, -60) + Start-Sleep -Milliseconds 200 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(66)) + [HerdrInputGauntlet.Desktop]::FocusAwayAndBack($controllerWindow, $window, $nonce, $windowPid) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(67)) + [HerdrInputGauntlet.Desktop]::MouseClickWheelInside($window, $nonce, $windowPid, 60) + Start-Sleep -Milliseconds 200 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(68)) + } elseif ($case.kind -eq 'mode-transitions') { + foreach ($step in @( + @{ mode = 'legacy'; chords = @([int[]]@(65), [int[]]@(16, 13)) }, + @{ mode = 'mok2'; chords = @([int[]]@(66), [int[]]@(16, 13)) }, + @{ mode = 'kitty'; chords = @([int[]]@(67), [int[]]@(16, 13)) }, + @{ mode = 'mok2'; chords = @([int[]]@(68), [int[]]@(16, 13)) }, + @{ mode = 'legacy'; chords = @([int[]]@(69), [int[]]@(16, 13), [int[]]@(70)) } + )) { + if ($step.mode) { $null = Observer-Request $plan 'set-mode' $step.mode; Start-Sleep -Milliseconds 200 } + foreach ($chord in $step.chords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) } + } + } elseif ($case.kind -eq 'layout-key') { + foreach ($chord in $layoutChords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) } + } else { + Write-Host "$($case.id): $($case.prompt) Return to this controller and press Enter after the gesture." + # Manual waits are bounded by the independent bootstrap lease watchdog. + $null = Read-Host + $row.operator_layout = [HerdrInputGauntlet.Desktop]::Layout($window) + } + Start-Sleep -Milliseconds 500 + if ($case.kind -ne 'manual') { [HerdrInputGauntlet.Desktop]::Guard($window, $nonce, $windowPid) } + $end = Observer-Request $plan 'end' + if ($case.kind -eq 'mode-transitions') { $null = Observer-Request $plan 'set-mode' $mode } + $row.capture_id = $end.id + $row.hex = $end.hex; $row.records = $end.records; $row.error = $end.error; $row.complete = $end.quiet_reached + $row.final_outer_geometry = (Outer-State $plan).geometry + $row.status = 'observed'; $row.final_pane_geometry = $end.geometry + if ($path -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) { + $traceLines = @(Get-Content -LiteralPath $plan.input_trace) + $trace = $traceLines -join "`n" + $captureTrace = ($traceLines | Select-Object -Skip $traceLineCount) -join "`n" + $row.input_reader = if ($trace.Contains('reader=windows-console')) { 'windows-console' } elseif ($trace.Contains('reader=crossterm')) { 'crossterm' } else { 'unknown' } + if ($captureTrace.Contains('transport=win32-serialized')) { $row.input_transport = 'win32-serialized' } + } + if ($null -ne $clipboardSequence) { + if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($clipboardSequence)) { throw 'Could not clear test-owned clipboard' } + $clipboardSequence = $null + } + if ($mouseReporting) { $null = Observer-Request $plan 'mouse-off'; $mouseReporting = $false } + if ($null -ne $cursorPosition) { [HerdrInputGauntlet.Desktop]::RestoreCursor($cursorPosition); $cursorPosition = $null } + if ($case.kind -eq 'manual') { [HerdrInputGauntlet.Desktop]::Focus($window, $nonce, $windowPid) } + Save-Report + Update-GauntletLease $root + } + } + } catch { + $document.errors += "$nonce : $($_.Exception.Message)" + Save-Report + # Abort the campaign on lost focus, readiness failure or partial injection. + throw + } finally { + if ($mouseReporting) { + try { $null = Observer-Request $plan 'mouse-off'; $mouseReporting = $false } catch { $document.cleanup_errors += $_.Exception.Message } + } + if ($null -ne $cursorPosition -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) { + [HerdrInputGauntlet.Desktop]::RestoreCursor($cursorPosition); $cursorPosition = $null + } + if ($null -ne $clipboardSequence -and -not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($clipboardSequence)) { $document.cleanup_errors += 'Could not clear test-owned clipboard' } + [IO.File]::WriteAllText((Join-Path $work 'probe-stop'), '') + if (Test-Path (Join-Path $work 'ready.json')) { + try { + $probeExit = Wait-GauntletJson (Join-Path $work 'probe-exit.json') $root { param($v) $v.nonce -eq $nonce } -Timeout 5 + $document.cleanup_errors += @($probeExit.cleanup_errors) + } catch { $document.cleanup_errors += $_.Exception.Message } + } + # Normal detach only while still owning foreground focus; never type into another window. + if ($injectionAuthorized -and $path -eq 'herdr' -and [HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) { + try { + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 66)) + Start-Sleep -Milliseconds 100 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(81)) + } catch { $document.cleanup_errors += $_.Exception.Message } + } + if ($null -ne $server -and -not $server.HasExited) { + try { $null = Invoke-GauntletProcess $exe @('session', 'stop', $nonce) $plan } + catch { if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += $_.Exception.Message } } + } + [IO.File]::WriteAllText((Join-Path $work 'stop'), '') + if ($window -ne [IntPtr]::Zero) { + try { + $exitRecord = Wait-GauntletJson (Join-Path $work 'bootstrap-exit.json') $root { param($v) $v.nonce -eq $nonce } -Timeout 45 + $document.cleanup_errors += @($exitRecord.cleanup_errors) + if ($exitRecord.before[2] -ne $exitRecord.after[2]) { $document.cleanup_errors += "$nonce console input mode not restored" } + } catch { $document.cleanup_errors += $_.Exception.Message } + $windowDeadline = [DateTime]::UtcNow.AddSeconds(5) + while ([HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [DateTime]::UtcNow -lt $windowDeadline) { Start-Sleep -Milliseconds 100 } + if ([HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid)) { $document.cleanup_errors += "$nonce Terminal window remained open; close it manually" } + } + if ($null -ne $server) { + if (-not $server.WaitForExit(5000)) { + $server.Kill($true) + $document.cleanup_errors += "$nonce server required forced cleanup" + if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += "$nonce server remained active after forced cleanup" } + } + if (-not (Test-Path (Join-Path $work 'bootstrap-exit.json'))) { + try { $null = Invoke-GauntletProcess $exe @('session', 'delete', $nonce) $plan } catch { $document.cleanup_errors += $_.Exception.Message } + } + $server.Dispose() + } + if ($null -ne $launcher) { $launcher.Dispose() } + Save-Report + } + } } + } +} catch { + if ($document.errors.Count -eq 0) { $document.errors += $_.Exception.Message } +} finally { [HerdrInputGauntlet.Desktop]::StopEmergencyStop(); Save-Report } +& $python $reportScript report --input $rawReport --output $artifactReport +$code = $LASTEXITCODE +Write-Host "Evidence and report: $root" +exit $code diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py new file mode 100644 index 00000000..f8a70eee --- /dev/null +++ b/scripts/test_windows_input.py @@ -0,0 +1,243 @@ +"""Portable tests of the gauntlet's oracle, not Windows input qualification.""" +import copy +import unittest +from scripts.windows_input.report import catalogue, channel_identity_errors, herdr_protocol_label, known_host_gap, qualification_matrix, summarize, verdict + + +class WindowsInputGauntletTests(unittest.TestCase): + def setUp(self): + self.matrix = catalogue() + self.cases = {case["id"]: case for case in self.matrix["cases"]} + self.evidence = dict(ready=True, focus_verified=True, complete=True, width=121, height=24, + outer_geometry=[121, 24, 0x298], pane_geometry=[100, 20, 0x200], + final_outer_geometry=[121, 24, 0x298], final_pane_geometry=[100, 20, 0x200]) + + def test_catalogue_has_unique_cases_and_geometry_boundaries(self): + self.assertEqual(len(self.cases), len(self.matrix["cases"])) + self.assertEqual(self.matrix["widths"], [80, 119, 120, 121, 132, 160, 240]) + self.assertEqual(self.matrix["heights"], [24, 50]) + for case in self.cases.values(): + self.assertTrue(case["id"]) + self.assertLessEqual(set(case["expected"]), set(self.matrix["modes"])) + for expected in case["expected"].values(): + for value in expected.get("hex", []): + self.assertTrue(bytes.fromhex(value)) + self.assertEqual(self.cases["dead-acute"]["kind"], "layout-key") + self.assertEqual(self.cases["altgr-euro"]["kind"], "manual") + + def test_default_catalogue_does_not_inject_terminal_host_actions(self): + for case_id in ["ctrl-v", "alt-enter", "ctrl-shift-up", "ctrl-shift-down", "ctrl-shift-home", "ctrl-shift-end"]: + self.assertEqual(self.cases[case_id]["kind"], "qualification", case_id) + + def test_kitty_function_keys_accept_native_and_legacy_encodings(self): + self.assertEqual(self.cases["shift-tab"]["expected"]["kitty"]["hex"], ["1b5b5a", "1b5b393b3275"]) + self.assertEqual(self.cases["up"]["expected"]["kitty"]["hex"], ["1b5b41", "1b5b353734313975"]) + + def test_shift_enter_cannot_pass_with_plain_enter_or_trailing_duplicates(self): + case = self.cases["shift-enter"] + correct = "1b5b32373b323b31337e" + for value, status in [("0d", "fail"), (correct, "pass"), (correct + "0d", "fail")]: + self.assertEqual(verdict(case, "mok2", {**self.evidence, "hex": value})[0], status) + self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": "0d"})[0], "unsupported") + + def test_focus_readiness_and_actual_geometry_are_required(self): + case = self.cases["letter-a"] + good = {**self.evidence, "hex": "61"} + for field in ["ready", "focus_verified", "complete", "pane_geometry", "outer_geometry", "final_pane_geometry", "final_outer_geometry"]: + value = copy.deepcopy(good) + value.pop(field) + self.assertEqual(verdict(case, "legacy", value)[0], "inconclusive", field) + self.assertEqual(verdict(case, "legacy", {**good, "outer_geometry": [120, 24]})[0], "inconclusive") + for malformed in [1, "121,24", {}, [121], [121, "24"], [True, 24]]: + self.assertEqual(verdict(case, "legacy", {**good, "pane_geometry": malformed})[0], "inconclusive") + + def test_native_modifiers_release_and_repeat_are_not_discarded(self): + # type, down, repeat, vk, scan, Unicode, control-state + self.evidence["scans"] = [42, 28] + records = [[1, 1, 1, 13, 28, 13, 16], [1, 0, 1, 13, 28, 13, 16]] + case = self.cases["shift-enter"] + self.assertEqual(verdict(case, "native", {**self.evidence, "records": records})[0], "pass") + for index, value in [(6, 0), (2, 2), (3, 10), (4, 0), (5, 122)]: + bad = copy.deepcopy(records) + bad[0][index] = value + self.assertEqual(verdict(case, "native", {**self.evidence, "records": bad})[0], "fail") + self.assertEqual(verdict(case, "native", {**self.evidence, "records": records[:1]})[0], "fail") + extra = [[1, 1, 1, 16, 42, 0, 16]] + records + self.assertEqual(verdict(case, "native", {**self.evidence, "records": extra})[0], "fail") + balanced = extra + [[1, 0, 1, 16, 42, 0, 0]] + self.assertEqual(verdict(case, "native", {**self.evidence, "records": balanced})[0], "pass") + + def test_herdr_native_page_keys_must_be_consumed_before_the_pane(self): + case = self.cases["page-up"] + evidence = {**self.evidence, "path": "herdr", "scans": [73]} + records = [[1, 1, 1, 33, 73, 0, 0], [1, 0, 1, 33, 73, 0, 0]] + self.assertEqual(verdict(case, "native", {**evidence, "records": []})[0], "inconclusive") + self.assertEqual(verdict(case, "native", {**evidence, "records": [[4, 0, 0, 0, 0, 0, 0]]})[0], "inconclusive") + self.assertEqual(verdict(case, "native", {**evidence, "records": records})[0], "fail") + self.assertEqual(verdict(case, "native", {**evidence, "records": [None]})[0], "inconclusive") + self.assertEqual(verdict(case, "native", {**evidence, "path": "direct", "records": records})[0], "pass") + for mode in ("legacy", "mok2", "kitty"): + self.assertEqual(verdict(case, mode, {**evidence, "hex": ""})[0], "inconclusive") + self.assertEqual(verdict(case, mode, {**evidence, "hex": "1b5b357e"})[0], "fail") + + def test_paste_requires_one_envelope_and_complete_unicode_payload(self): + case = self.cases["paste-unicode"] + payload = case["text"].replace("\n", "\r\n").encode() + framed = b"\x1b[200~" + payload + b"\x1b[201~" + for data, expected in [(framed, "pass"), (payload, "fail"), (framed * 2, "fail"), + (framed[:-1], "fail"), (framed + b"\r", "fail"), + (b"\x1b[200~\xff\x1b[201~", "fail")]: + self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": data.hex()})[0], expected) + + def test_mouse_interleave_requires_ordered_motion_and_paste(self): + case = self.cases["mouse-interleave"] + motion = b"\x1b[<35;10;5M" + paste = b"\x1b[200~mouse\r\npaste\x1b[201~" + good = b"a" + motion + paste + motion + b"b" + self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": good.hex()})[0], "pass") + for bad in (good.replace(motion, b"", 1), good + b"b", b"a" + motion + motion + paste + b"b"): + self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": bad.hex()})[0], "fail") + + def test_runtime_mode_transition_has_one_exact_order(self): + case = self.cases["mode-transitions"] + for mode in ("legacy", "mok2", "kitty"): + expected = case["expected"][mode]["hex"][0] + self.assertEqual(verdict(case, mode, {**self.evidence, "hex": expected})[0], "pass") + self.assertEqual(verdict(case, mode, {**self.evidence, "hex": expected + "0d"})[0], "fail") + direct = {**self.evidence, "path": "direct"} + for observed in ("a\rb\rc\rd\re\rf", "a\rb\rc\x1b[13;2ud\re\rf"): + self.assertEqual(verdict(case, "legacy", {**direct, "hex": observed.encode().hex()})[0], "unsupported") + + def test_mouse_focus_refresh_requires_reports_on_both_sides(self): + case = self.cases["mouse-focus-refresh"] + mouse = b"\x1b[<35;10;5M\x1b[<0;10;5M\x1b[<0;10;5m\x1b[<64;10;5M" + good = b"a" + mouse + b"bc" + mouse + b"d" + self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": good.hex()})[0], "pass") + for bad in (b"ab" + b"c" + mouse + b"d", b"a" + mouse + b"bcd"): + self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": bad.hex()})[0], "fail") + + def test_qualification_matrix_uses_observed_results_only(self): + observations = [ + {"host": "stable", "case": "shift-enter", "path": "herdr", "mode": "mok2", "status": "pass"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "legacy", "status": "unsupported"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "pass"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "inconclusive"}, + ] + rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable"], "observations": observations})} + self.assertEqual(rows["Shift+Enter"], ("PASS", "X - becomes Enter", "PASS**")) + self.assertEqual(rows["Multiline paste"], ("NOT TESTED", "NOT TESTED", "NOT TESTED")) + observations.append({"host": "stable", "case": "dead-acute", "path": "herdr", "mode": "mok2", "status": "pass"}) + observations.extend([ + {"host": "stable", "case": "letter-a", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "legacy", "width": 80, "status": "unsupported"}, + {"host": "stable", "case": "paste-lf", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "mouse-focus-refresh", "path": "herdr", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "mouse-focus-refresh", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "mouse-focus-refresh", "path": "direct", "mode": "kitty", "width": 80, "status": "pass"}, + ]) + rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable"], "observations": observations})} + self.assertEqual(rows["Dead-key composition"][0], "PASS") + self.assertEqual(rows["Resize 120 -> 80"][1], "PASS") + self.assertEqual(rows["Mouse after resize"], ("PASS", "PASS", "PASS")) + self.assertEqual(rows["AltGr"], ("MANUAL", "MANUAL", "MANUAL")) + self.assertEqual(rows["IME composition"], ("MANUAL", "MANUAL", "MANUAL")) + + def test_qualification_matrix_requires_every_selected_channel(self): + observations = [ + {"host": "stable", "case": "shift-enter", "path": "herdr", "mode": "mok2", "status": "pass"}, + ] + for result in ({"channels": ["stable", "preview"], "observations": observations}, + {"observations": observations}): + rows = {row[0]: row[1:] for row in qualification_matrix(result)} + self.assertEqual(rows["Shift+Enter"][0], "PARTIAL") + + def test_herdr_protocol_label_requires_every_run_to_prove_transport(self): + result = {"hosts": [{"channel": "stable", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"}, + {"nonce": "two", "path": "herdr", "mode": "native"}]}], + "observations": [{"host": "stable", "nonce": "one", "path": "herdr", "mode": "native", + "input_reader": "windows-console", + "input_transport": "win32-serialized"}]} + self.assertEqual(herdr_protocol_label(result), "Herdr default (UNKNOWN)*") + result["observations"].append({"host": "stable", "nonce": "two", "path": "herdr", "mode": "native", + "input_reader": "windows-console", + "input_transport": "win32-serialized"}) + self.assertEqual(herdr_protocol_label(result), "Win32 (Herdr)*") + result["hosts"].append({"channel": "preview", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"}]}) + self.assertEqual(herdr_protocol_label(result), "Herdr default (UNKNOWN)*") + + def test_empty_partial_and_duplicate_reports_never_become_green(self): + self.assertFalse(summarize({})["observed_checks_passed"]) + row = {**self.evidence, "case": "letter-a", "host": "stable", "path": "herdr", "mode": "legacy", "hex": "61", "phase": 1, + "width": 120, "height": 30, "outer_geometry": [120, 30], "final_outer_geometry": [120, 30]} + self.assertFalse(summarize({"observations": [row]})["observed_checks_passed"]) + with self.assertRaisesRegex(ValueError, "Duplicate"): + summarize({"observations": [row, row]}) + later = {**row, "phase": 2, "width": 80, "height": 24, "outer_geometry": [80, 24], "final_outer_geometry": [80, 24]} + host = {"channel": "stable", "runs": [{"nonce": "owned", "path": "herdr", "mode": "legacy", "pid": 123, "hwnd": 456, + "elevated": False, "image_identity": "image-s", "installation_identity": "install-s"}]} + row.update(nonce="owned", capture_id="first") + later.update(nonce="owned", capture_id="second") + self.assertEqual(summarize({"observations": [row, later], "hosts": [host], "controller_elevated": False})["counts"]["pass"], 2) + stale = {**later, "capture_id": "first"} + self.assertEqual(summarize({"observations": [row, stale], "hosts": [host], "controller_elevated": False})["counts"]["inconclusive"], 1) + forged_hosts = [{"channel": name, "runs": [{}]} for name in ("stable", "preview")] + partial = summarize({"observations": [row], "hosts": forged_hosts}) + self.assertFalse(partial["observed_checks_passed"]) + self.assertGreater(partial["coverage_missing"], 0) + for status in ["unsupported", "not_run", "inconclusive"]: + self.assertEqual(verdict(self.cases["letter-a"], "legacy", {**row, "status": status})[0], status) + + def test_malformed_native_records_are_inconclusive_not_exceptions(self): + evidence = {**self.evidence, "scans": [30]} + for records in [None, 7, "records", [None], [[1, 1, 1, 65, 30, 97, None]], + [[True, 1, 1, 65, 30, 97, 0]], [[1, 1, 1, 65, 30, 97, "0"]]]: + self.assertEqual(verdict(self.cases["letter-a"], "native", {**evidence, "records": records})[0], "inconclusive") + + def test_known_host_gap_only_exempts_observed_terminal_versions_and_cases(self): + row = {**self.evidence, "case": "shift-enter", "host": "stable", "path": "direct", "mode": "mok2", "hex": "0d", "phase": 1, + "width": 120, "height": 30, "outer_geometry": [120, 30], "final_outer_geometry": [120, 30], + "nonce": "owned", "capture_id": "fresh"} + for path, version, raw, expected in [("direct", "1.24.11911.0", "0d", "unsupported"), + ("direct", "1.25.1912.0", "0d", "unsupported"), + ("direct", "1.26.1.0", "0d", "fail"), + ("herdr", "1.24.11911.0", "0d", "fail"), + ("direct", "1.24.11911.0", "", "fail")]: + observation = {**row, "path": path, "hex": raw} + run = {"nonce": "owned", "path": path, "mode": "mok2", "pid": 123, "hwnd": 456, "terminal_version": version, + "elevated": False, "image_identity": "image-s", "installation_identity": "install-s"} + document = {"observations": [observation], "hosts": [{"channel": "stable", "runs": [run]}], "controller_elevated": False} + result = summarize(document)["observations"][0] + self.assertEqual(result["status"], expected) + self.assertEqual(result["failure_scope"], "direct_host" if path == "direct" else "through_herdr_not_yet_attributed") + document["controller_elevated"] = True + self.assertEqual(summarize(document)["observations"][0]["status"], "inconclusive") + document["controller_elevated"] = False + run["elevated"] = True + self.assertEqual(summarize(document)["observations"][0]["status"], "inconclusive") + + def test_direct_mok_legacy_fallback_is_case_and_version_bounded(self): + case = self.cases["shift-tab"] + direct = {**self.evidence, "path": "direct", "mode": "mok2", "hex": "1b5b5a"} + self.assertTrue(known_host_gap(direct, case, "1.25.2607.10002")) + self.assertFalse(known_host_gap(direct, case, "1.26.0.0")) + self.assertFalse(known_host_gap({**direct, "path": "herdr"}, case, "1.25.2607.10002")) + self.assertFalse(known_host_gap({**direct, "hex": "1b5b32373b323b397e"}, case, "1.25.2607.10002")) + + def test_duplicate_channel_identity_is_rejected_at_both_stages(self): + hosts = [{"channel": name, "launcher_identity": name + "-exe", "installation_identity": name + "-dir", + "runs": [{"image_identity": name + "-image", "installation_identity": name + "-dir", "process_identity": name + "-pid/start"}]} + for name in ("stable", "preview")] + self.assertEqual(channel_identity_errors(hosts), []) + for field in ("launcher_identity", "installation_identity"): + duplicate = copy.deepcopy(hosts) + duplicate[1][field] = duplicate[0][field] + self.assertTrue(channel_identity_errors(duplicate)) + self.assertTrue(summarize({"hosts": duplicate})["errors"]) + for field in ("image_identity", "installation_identity", "process_identity"): + duplicate = copy.deepcopy(hosts) + duplicate[1]["runs"][0][field] = duplicate[0]["runs"][0][field] + self.assertTrue(channel_identity_errors(duplicate)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/windows_input/Bootstrap.ps1 b/scripts/windows_input/Bootstrap.ps1 new file mode 100644 index 00000000..4f6884fb --- /dev/null +++ b/scripts/windows_input/Bootstrap.ps1 @@ -0,0 +1,51 @@ +#Requires -Version 7.0 +param([Parameter(Mandatory)][string] $PlanPath) +. "$PSScriptRoot/Common.ps1" +$plan = Read-GauntletJson $PlanPath +# Terminal activation can inherit another process's environment. Normalize here too. +foreach ($item in @(Get-ChildItem Env:)) { + if ($item.Name -like 'HERDR_*' -or $item.Name -in @('SSH_CONNECTION', 'SSH_TTY')) { Remove-Item "Env:$($item.Name)" } +} +$env:HERDR_CONFIG_PATH = $plan.config +$env:XDG_CONFIG_HOME = $plan.config_home +$env:HERDR_SESSION = $plan.session +if ($plan.profile -ne 'default') { $env:HERDR_WINDOWS_INPUT_PROBE = $plan.profile } +Add-Type -Path "$PSScriptRoot/Native.cs" +$before = [HerdrInputGauntlet.ConsoleProbe]::Geometry() +$child = $null +try { + if ($plan.path -eq 'herdr') { + $child = New-GauntletProcess $plan.exe @('--session', $plan.session) $plan + } else { + $child = New-GauntletProcess $plan.pwsh @('-NoProfile', '-File', "$PSScriptRoot/Probe.ps1", '-PlanPath', $PlanPath) $plan + } + $sequence = 0 + while (-not $child.HasExited -and (Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'stop'))) { + $sequence++ + Write-GauntletJson (Join-Path $plan.work 'outer.json') @{ + nonce = $plan.nonce; pid = $PID; child_pid = $child.Id; sequence = $sequence + geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); before = $before + } + Start-Sleep -Milliseconds 100 + } +} finally { + $cleanup = @() + if ($null -ne $child) { + # Allow normal detach / observer disposal before forcing this owned child down. + if (-not $child.WaitForExit(5000)) { + $cleanup += 'Child required forced termination; normal console restoration was not qualified' + $child.Kill($true) + if (-not $child.WaitForExit(5000)) { $cleanup += 'Child remained active after forced termination' } + } + $child.Dispose() + } + if ($plan.path -eq 'herdr') { + foreach ($verb in @('stop', 'delete')) { + try { $null = Invoke-GauntletProcess $plan.exe @('session', $verb, $plan.session) $plan } + catch { $cleanup += $_.ToString() } + } + } + Write-GauntletJson (Join-Path $plan.work 'bootstrap-exit.json') @{ + nonce = $plan.nonce; before = $before; after = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); cleanup_errors = $cleanup + } +} diff --git a/scripts/windows_input/Common.ps1 b/scripts/windows_input/Common.ps1 new file mode 100644 index 00000000..537b66ae --- /dev/null +++ b/scripts/windows_input/Common.ps1 @@ -0,0 +1,88 @@ +# Shared test helpers. Dot-sourcing does not touch the desktop or console. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-GauntletJson($Path, $Value) { + $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + [IO.File]::WriteAllText($temporary, ($Value | ConvertTo-Json -Depth 30), [Text.UTF8Encoding]::new($false)) + for ($attempt = 0; ; $attempt++) { + try { [IO.File]::Move($temporary, $Path, $true); return } + catch [IO.IOException], [UnauthorizedAccessException] { + if ($attempt -ge 99) { throw } + Start-Sleep -Milliseconds 10 + } + } +} + +function Read-GauntletJson($Path) { + if (Test-Path -LiteralPath $Path) { + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + try { + $reader = [IO.StreamReader]::new($stream) + try { return $reader.ReadToEnd() | ConvertFrom-Json -AsHashtable } + finally { $reader.Dispose() } + } finally { $stream.Dispose() } + } + return $null +} + +function Quote-GauntletPS([string] $Text) { return "'" + $Text.Replace("'", "''") + "'" } + +function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) { + $info = [Diagnostics.ProcessStartInfo]::new($Exe) + $info.UseShellExecute = $false + foreach ($argument in $Arguments) { $info.ArgumentList.Add([string]$argument) } + foreach ($name in @($info.Environment.Keys)) { + if ($name -like 'HERDR_*' -or $name -in @('SSH_CONNECTION', 'SSH_TTY')) { $null = $info.Environment.Remove($name) } + } + if ($null -ne $Plan) { + $info.Environment['HERDR_CONFIG_PATH'] = $Plan.config + $info.Environment['XDG_CONFIG_HOME'] = $Plan.config_home + $info.Environment['HERDR_SESSION'] = $Plan.session + if ($Plan.profile -ne 'default') { $info.Environment['HERDR_WINDOWS_INPUT_PROBE'] = $Plan.profile } + if ($Plan.path -eq 'herdr') { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + } + $info.RedirectStandardOutput = $Capture.IsPresent + $info.RedirectStandardError = $Capture.IsPresent + $info.CreateNoWindow = $Capture.IsPresent + return [Diagnostics.Process]::Start($info) +} + +function Invoke-GauntletProcess($Exe, $Arguments, $Plan = $null, [int] $Timeout = 30) { + $process = New-GauntletProcess $Exe $Arguments $Plan -Capture + try { + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $timer = [Diagnostics.Stopwatch]::StartNew() + if (-not $process.WaitForExit($Timeout * 1000)) { + $process.Kill($true) + throw "Command timed out: $Exe $($Arguments -join ' ')" + } + $remaining = [Math]::Max(0, $Timeout * 1000 - [int]$timer.ElapsedMilliseconds) + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), $remaining)) { + throw "Output streams stayed open after exit: $Exe $($Arguments -join ' ')" + } + if ($process.ExitCode -ne 0) { throw "Command failed ($($process.ExitCode)): $Exe`n$($stderr.GetAwaiter().GetResult())" } + return $stdout.GetAwaiter().GetResult() + } finally { $process.Dispose() } +} + +function Update-GauntletLease($Root) { + [IO.File]::WriteAllText((Join-Path $Root 'lease'), [DateTime]::UtcNow.ToString('O')) +} + +function Test-GauntletLease($Root) { + $lease = Get-Item -LiteralPath (Join-Path $Root 'lease') -ErrorAction SilentlyContinue + return $null -ne $lease -and ([DateTime]::UtcNow - $lease.LastWriteTimeUtc).TotalSeconds -lt 90 +} + +function Wait-GauntletJson($Path, $Root, $Predicate, [int] $Timeout = 20) { + $deadline = [DateTime]::UtcNow.AddSeconds($Timeout) + do { + Update-GauntletLease $Root + $value = Read-GauntletJson $Path + if ($null -ne $value -and (& $Predicate $value)) { return $value } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + throw "Timed out waiting for fresh acknowledgement: $Path" +} diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs new file mode 100644 index 00000000..5f941f1a --- /dev/null +++ b/scripts/windows_input/Native.cs @@ -0,0 +1,439 @@ +// Test-only Windows desktop/console adapter. No calls occur when this file is compiled. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace HerdrInputGauntlet { + [ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] + class ApplicationActivationManager { } + + [ComImport, Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IApplicationActivationManager { + [PreserveSig] + int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, + [MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId); + } + + public static class Desktop { + [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; } + [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] struct Mouse { public int X, Y; public uint Data, Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Sequential)] struct Key { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Explicit)] struct Union { [FieldOffset(0)] public Mouse Mouse; [FieldOffset(0)] public Key Key; } + [StructLayout(LayoutKind.Sequential)] struct Input { public uint Type; public Union Data; } + delegate bool EnumProc(IntPtr hwnd, IntPtr arg); + delegate IntPtr HookProc(int code,IntPtr message,IntPtr data); + [StructLayout(LayoutKind.Sequential)] struct Message { public IntPtr Window; public uint Id; public UIntPtr WParam; public IntPtr LParam; public uint Time; public Point Point; public uint Private; } + [DllImport("user32.dll",SetLastError=true)] static extern IntPtr SetWindowsHookEx(int kind,HookProc callback,IntPtr module,uint thread); + [DllImport("user32.dll")] static extern bool UnhookWindowsHookEx(IntPtr hook); + [DllImport("user32.dll")] static extern IntPtr CallNextHookEx(IntPtr hook,int code,IntPtr message,IntPtr data); + [DllImport("user32.dll")] static extern int GetMessage(out Message message,IntPtr window,uint min,uint max); + [DllImport("user32.dll")] static extern bool PostThreadMessage(uint thread,uint message,UIntPtr wparam,IntPtr lparam); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr GetModuleHandle(string name); + static volatile bool stopped; + static Thread stopThread; + static uint stopThreadId; + static HookProc stopHook; + static readonly ManualResetEventSlim stopReady=new ManualResetEventSlim(); + static string stopError; + public static void StartEmergencyStop() { + stopThread=new Thread(()=> { + IntPtr hook=IntPtr.Zero; + try { + stopThreadId=GetCurrentThreadId(); + stopHook=(code,message,data)=> { + if(code>=0 && (message.ToInt64()==0x100 || message.ToInt64()==0x104) && Marshal.ReadInt32(data)==0x7B) stopped=true; + return CallNextHookEx(IntPtr.Zero,code,message,data); + }; + hook=SetWindowsHookEx(13,stopHook,GetModuleHandle(null),0); + if(hook==IntPtr.Zero) throw new Win32Exception(); + stopReady.Set(); + Message message; + while(GetMessage(out message,IntPtr.Zero,0,0)>0) {} + } catch(Exception e) { stopError=e.Message; stopped=true; stopReady.Set(); } + finally { if(hook!=IntPtr.Zero) UnhookWindowsHookEx(hook); } + }) { IsBackground=true }; + stopThread.Start(); + if(!stopReady.Wait(3000) || stopError!=null) throw new Exception("Cannot install emergency-stop latch: "+stopError); + } + public static void StopEmergencyStop() { + if(stopThread!=null) { PostThreadMessage(stopThreadId,0x12,UIntPtr.Zero,IntPtr.Zero); stopThread.Join(1000); } + } + [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,int pid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,out uint value,int size,out int returned); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + [StructLayout(LayoutKind.Sequential)] struct FileInformation { + public uint Attributes,CreationLow,CreationHigh,AccessLow,AccessHigh,WriteLow,WriteHigh; + public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow; + } + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(Microsoft.Win32.SafeHandles.SafeFileHandle file,out FileInformation info); + public static string FileIdentity(string path) { + using(var file=CreateFile(path,0,7,IntPtr.Zero,3,0x02000000,IntPtr.Zero)) { + FileInformation info; + if(file.IsInvalid || !GetFileInformationByHandle(file,out info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot establish Terminal file/installation identity"); + return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8"); + } + } + public static void AssertNotElevated(int pid) { + var process=OpenProcess(0x1000,false,pid); + if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input"); + IntPtr token=IntPtr.Zero; + try { + uint elevated; int returned; + if(!OpenProcessToken(process,8,out token) || !GetTokenInformation(token,20,out elevated,4,out returned) || returned!=4) + throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input"); + if(elevated!=0) throw new Exception("Elevated controller/Terminal is not allowed. Start PowerShell and Terminal without Run as administrator."); + } finally { if(token!=IntPtr.Zero) CloseHandle(token); CloseHandle(process); } + } + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr arg); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr hwnd, StringBuilder value, int count); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + [DllImport("user32.dll")] static extern IntPtr GetKeyboardLayout(uint thread); + [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] static extern bool GetCursorPos(out Point point); + [DllImport("user32.dll")] static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] static extern int GetSystemMetrics(int index); + [DllImport("user32.dll")] static extern short GetAsyncKeyState(int vk); + [DllImport("user32.dll")] static extern uint MapVirtualKeyEx(uint key, uint kind, IntPtr layout); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int ToUnicodeEx(uint key,uint scan,byte[] state,StringBuilder text,int count,uint flags,IntPtr layout); + [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] events, int size); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); + [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int width, int height, uint flags); + [DllImport("user32.dll")] public static extern int CountClipboardFormats(); + [DllImport("user32.dll")] public static extern uint GetClipboardSequenceNumber(); + [DllImport("user32.dll")] static extern IntPtr GetClipboardOwner(); + [DllImport("user32.dll")] static extern bool OpenClipboard(IntPtr owner); + [DllImport("user32.dll")] static extern bool CloseClipboard(); + [DllImport("user32.dll")] static extern bool EmptyClipboard(); + [DllImport("user32.dll")] static extern IntPtr SetClipboardData(uint format,IntPtr value); + [DllImport("kernel32.dll")] static extern IntPtr GlobalAlloc(uint flags,UIntPtr size); + [DllImport("kernel32.dll")] static extern IntPtr GlobalLock(IntPtr memory); + [DllImport("kernel32.dll")] static extern bool GlobalUnlock(IntPtr memory); + [DllImport("kernel32.dll")] static extern IntPtr GlobalFree(IntPtr memory); + + static string QuoteArgument(string value) { + if(value.Length>0 && value.IndexOfAny(new[]{' ', '\t', '\n', '\v', '"'})<0) return value; + var result=new StringBuilder("\""); + int slashes=0; + foreach(char c in value) { + if(c=='\\') { slashes++; continue; } + if(c=='"') result.Append('\\',slashes*2+1); + else result.Append('\\',slashes); + result.Append(c); slashes=0; + } + result.Append('\\',slashes*2).Append('"'); + return result.ToString(); + } + public static int ActivateApplication(string appUserModelId,string[] arguments) { + var manager=(IApplicationActivationManager)new ApplicationActivationManager(); + uint pid; + int result=manager.ActivateApplication(appUserModelId,string.Join(" ",Array.ConvertAll(arguments,QuoteArgument)),0,out pid); + if(result<0) Marshal.ThrowExceptionForHR(result); + return checked((int)pid); + } + + public static uint SetEmptyClipboard(IntPtr owner,string text) { + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; refusing replacement"); + IntPtr memory=IntPtr.Zero; + try { + if(CountClipboardFormats()!=0) throw new Exception("Clipboard changed or contains user data; refusing replacement"); + byte[] data=Encoding.Unicode.GetBytes(text+"\0"); + memory=GlobalAlloc(2,new UIntPtr((uint)data.Length)); + if(memory==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + var pointer=GlobalLock(memory); + if(pointer==IntPtr.Zero) throw new Exception("Clipboard lock failed"); + try { Marshal.Copy(data,0,pointer,data.Length); } finally { GlobalUnlock(memory); } + if(!EmptyClipboard() || SetClipboardData(13,memory)==IntPtr.Zero) throw new Exception("Clipboard write failed"); + memory=IntPtr.Zero; // ownership transferred to Windows + } finally { if(memory!=IntPtr.Zero) GlobalFree(memory); CloseClipboard(); } + // Closing can synthesize additional formats and advance the sequence. + // Reopen before adopting it so another writer cannot become our lease. + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership"); + try { + if(owner==IntPtr.Zero || GetClipboardOwner()!=owner) throw new Exception("Clipboard ownership changed; refusing cleanup lease"); + return GetClipboardSequenceNumber(); + } finally { CloseClipboard(); } + } + public static bool ClearOwnedClipboard(uint sequence) { + if(!OpenClipboard(IntPtr.Zero)) return false; + try { return GetClipboardSequenceNumber()!=sequence || EmptyClipboard(); } + finally { CloseClipboard(); } + } + public static string Title(IntPtr hwnd) { var text=new StringBuilder(1024); GetWindowText(hwnd,text,text.Capacity); return text.ToString(); } + public static IntPtr Find(string nonce) { + var matches=new List<IntPtr>(); + EnumWindows((h,a)=> { if (Title(h).Contains(nonce)) matches.Add(h); return true; },IntPtr.Zero); + if(matches.Count>1) throw new Exception("Ambiguous test window identity"); + return matches.Count==1 ? matches[0] : IntPtr.Zero; + } + public static int Pid(IntPtr hwnd) { uint pid; GetWindowThreadProcessId(hwnd,out pid); return checked((int)pid); } + public static string Layout(IntPtr hwnd) { uint pid; return GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out pid)).ToInt64().ToString("x"); } + public static bool IsOwned(IntPtr hwnd, string nonce, int pid) { return hwnd!=IntPtr.Zero && Pid(hwnd)==pid && Title(hwnd).Contains(nonce); } + public static void Guard(IntPtr hwnd, string nonce, int pid) { + if(!IsOwned(hwnd,nonce,pid) || GetForegroundWindow()!=hwnd) throw new Exception("Lost owned test-window focus; injection aborted"); + AssertNotElevated(pid); + if(stopped || (GetAsyncKeyState(0x7B)&0x8000)!=0) throw new Exception("F12 emergency stop"); + } + public static void Focus(IntPtr hwnd, string nonce, int pid) { + if(!IsOwned(hwnd,nonce,pid)) throw new Exception("Lost window ownership"); + AssertNotElevated(pid); + var deadline=DateTime.UtcNow.AddSeconds(3); + do { + SetForegroundWindow(hwnd); + if(GetForegroundWindow()==hwnd) return; + Thread.Sleep(100); + } while(DateTime.UtcNow<deadline); + throw new Exception("Cannot focus test window; no input sent"); + } + public static void FocusAwayAndBack(IntPtr other,IntPtr hwnd,string nonce,int pid) { + if(other==IntPtr.Zero || other==hwnd) throw new Exception("No separate controller window for focus-cycle qualification"); + AssertNotElevated(Pid(other)); + var deadline=DateTime.UtcNow.AddSeconds(3); + do { + SetForegroundWindow(other); + if(GetForegroundWindow()==other) break; + Thread.Sleep(100); + } while(DateTime.UtcNow<deadline); + if(GetForegroundWindow()!=other) throw new Exception("Cannot focus controller window for focus-cycle qualification"); + Thread.Sleep(250); + Focus(hwnd,nonce,pid); + Thread.Sleep(250); + } + public static void Neutral() { + foreach(int key in new[]{0x10,0x11,0x12,0x5B,0x5C,1,2,4}) + if((GetAsyncKeyState(key)&0x8000)!=0) throw new Exception("Release physical modifiers and mouse buttons before running"); + } + static Input Event(ushort vk, bool release, IntPtr layout) { + uint mapped=MapVirtualKeyEx(vk,4,layout); + if(mapped==0) throw new Exception("No scan code for virtual key "+vk); + return new Input { Type=1, Data=new Union { Key=new Key { Scan=(ushort)(mapped&255), Flags=8u|(release?2u:0u)|((mapped&0xff00)!=0?1u:0u) } } }; + } + public static int[] Scans(IntPtr hwnd,int[] keys) { + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + var scans=new List<int>(); + foreach(int key in keys) scans.Add(Event((ushort)key,false,layout).Data.Key.Scan); + return scans.ToArray(); + } + static int[] LayoutChord(int vk,int modifiers) { + var keys=new List<int>(); + if((modifiers&1)!=0) keys.Add(0x10); + if((modifiers&2)!=0) keys.Add(0x11); + if((modifiers&4)!=0) keys.Add(0x12); + keys.Add(vk); + return keys.ToArray(); + } + public static int[] DeadKeyChord(IntPtr hwnd,char accent) { + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + for(int modifiers=0;modifiers<8;modifiers++) for(uint vk=1;vk<255;vk++) { + uint scan=MapVirtualKeyEx(vk,4,layout); + if(scan==0) continue; + var state=new byte[256]; + if((modifiers&1)!=0) state[0x10]=0x80; + if((modifiers&2)!=0) state[0x11]=0x80; + if((modifiers&4)!=0) state[0x12]=0x80; + var text=new StringBuilder(4); + // Flag 4 discovers dead keys without changing keyboard state. + if(ToUnicodeEx(vk,scan,state,text,text.Capacity,4,layout)<0 && text.Length>0 && text[0]==accent) + return LayoutChord((int)vk,modifiers); + } + throw new Exception("Active layout has no "+accent+" dead key"); + } + // One balanced batch; no modifier is intentionally held between calls. + public static int Chord(IntPtr hwnd, string nonce, int pid, int[] keys) { + Guard(hwnd,nonce,pid); Neutral(); + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + var events=new List<Input>(); + foreach(int key in keys) events.Add(Event(checked((ushort)key),false,layout)); + for(int i=keys.Length-1;i>=0;i--) events.Add(Event(checked((ushort)keys[i]),true,layout)); + Guard(hwnd,nonce,pid); + uint sent=SendInput((uint)events.Count,events.ToArray(),Marshal.SizeOf<Input>()); + if(sent!=events.Count) { + // Release only keys whose down event was actually submitted and not yet released. + var held=new List<Input>(); + for(int i=0;i<keys.Length && i<sent;i++) + if(sent <= 2*keys.Length-1-i) held.Add(Event((ushort)keys[i],true,layout)); + if(held.Count>0) { + uint released=SendInput((uint)held.Count,held.ToArray(),Marshal.SizeOf<Input>()); + if(released!=held.Count) throw new Exception("SendInput cleanup incomplete: "+released+"/"+held.Count+" key-up events; release test modifiers manually before continuing"); + } + throw new Exception("SendInput incomplete (possible UIPI restriction): "+sent+"/"+events.Count); + } + return (int)sent; + } + public static long Cursor() { + Point point; + if(!GetCursorPos(out point)) throw new Win32Exception(); + return ((long)(uint)point.X<<32)|(uint)point.Y; + } + public static void RestoreCursor(long packed) { + if(!SetCursorPos(unchecked((int)(packed>>32)),unchecked((int)packed))) throw new Win32Exception(); + } + public static void MouseMoveInside(IntPtr hwnd,string nonce,int pid,int offset) { + Guard(hwnd,nonce,pid); Neutral(); Rect rect; + if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception(); + int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2; + if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior"); + int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79); + if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable"); + int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1)); + var input=new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } }; + if(SendInput(1,new[]{input},Marshal.SizeOf<Input>())!=1) throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed"); + } + public static void MouseClickWheelInside(IntPtr hwnd,string nonce,int pid,int offset) { + Guard(hwnd,nonce,pid); Neutral(); Rect rect; + if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception(); + int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2; + if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior"); + int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79); + if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable"); + int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1)); + var inputs=new[]{ + new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0002 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0004 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Data=120,Flags=0x0800 } } } + }; + uint sent=SendInput((uint)inputs.Length,inputs,Marshal.SizeOf<Input>()); + if(sent!=inputs.Length) { + int error=Marshal.GetLastWin32Error(); + if(sent==2 && SendInput(1,new[]{inputs[2]},Marshal.SizeOf<Input>())!=1) + throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed after button-down; release the left mouse button manually"); + throw new Win32Exception(error,"Mouse click/wheel injection failed"); + } + } + public static void Resize(IntPtr hwnd,string nonce,int pid,int dx,int dy) { + Guard(hwnd,nonce,pid); Rect r; + if(!GetWindowRect(hwnd,out r)) throw new Win32Exception(); + int w=r.Right-r.Left+dx,h=r.Bottom-r.Top+dy; + if(w<300 || h<200 || w>10000 || h>10000) throw new Exception("Unsafe resize request"); + if(!SetWindowPos(hwnd,IntPtr.Zero,0,0,w,h,0x16)) throw new Win32Exception(); + } + } + + public sealed class ConsoleProbe : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Coord { public short X,Y; } + [StructLayout(LayoutKind.Sequential)] struct SmallRect { public short Left,Top,Right,Bottom; } + [StructLayout(LayoutKind.Sequential)] struct Info { public Coord Size,Cursor; public ushort Attributes; public SmallRect Window; public Coord Maximum; } + [StructLayout(LayoutKind.Explicit,Size=20)] struct Record { + [FieldOffset(0)] public ushort Type; + [FieldOffset(4)] public int Down; + [FieldOffset(8)] public ushort Repeat; + [FieldOffset(10)] public ushort Vk; + [FieldOffset(12)] public ushort Scan; + [FieldOffset(14)] public ushort Unicode; + [FieldOffset(16)] public uint Control; + } + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetConsoleMode(IntPtr handle,out uint mode); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetConsoleMode(IntPtr handle,uint mode); + [DllImport("kernel32.dll")] static extern bool GetConsoleScreenBufferInfo(IntPtr handle,out Info info); + [DllImport("kernel32.dll")] static extern uint GetConsoleCP(); + [DllImport("kernel32.dll")] static extern bool SetConsoleCP(uint cp); + [DllImport("kernel32.dll")] static extern bool ReadFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap); + [DllImport("kernel32.dll")] static extern bool WriteFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap); + [DllImport("kernel32.dll")] static extern bool ReadConsoleInputW(IntPtr handle,Record[] records,uint size,out uint count); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll")] static extern IntPtr OpenThread(uint access,bool inherit,uint id); + [DllImport("kernel32.dll")] static extern bool CancelSynchronousIo(IntPtr thread); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + readonly object gate=new object(); + readonly List<byte> bytes=new List<byte>(); + readonly List<long[]> records=new List<long[]>(); + readonly IntPtr input=GetStdHandle(-10), output=GetStdHandle(-11); + readonly uint original, outputMode, cp; + readonly bool native; + volatile bool running=true; + Thread reader; + IntPtr threadHandle; + string error; + public string Error { + get { return Volatile.Read(ref error); } + private set { Volatile.Write(ref error,value); } + } + public ConsoleProbe(string mode) { + if(!GetConsoleMode(input,out original) || !GetConsoleMode(output,out outputMode)) throw new Exception("Probe needs a real console"); + cp=GetConsoleCP(); native=mode=="native"; + try { + if(!SetConsoleCP(65001) || !SetConsoleMode(output,outputMode|4) || !SetConsoleMode(input,native ? (original & ~0x247u)|0x98u : (original & ~7u)|0x200u)) throw new Win32Exception(); + Print("\x1b[?2004h"+(mode=="kitty"?"\x1b[>1u\x1b[?u":mode=="mok2"?"\x1b[>4;2m":"")); + reader=new Thread(Read) { IsBackground=true }; reader.Start(); + } catch { SetConsoleMode(input,original); SetConsoleMode(output,outputMode); SetConsoleCP(cp); throw; } + } + public static long[] Geometry() { + Info info; uint mode; + if(!GetConsoleScreenBufferInfo(GetStdHandle(-11),out info) || !GetConsoleMode(GetStdHandle(-10),out mode)) throw new Exception("Console geometry unavailable"); + return new long[]{ info.Window.Right-info.Window.Left+1,info.Window.Bottom-info.Window.Top+1,mode }; + } + public static void Print(string text) { + byte[] data=Encoding.UTF8.GetBytes(text); uint count; + if(!WriteFile(GetStdHandle(-11),data,(uint)data.Length,out count,IntPtr.Zero) || count!=data.Length) throw new Exception("Console output failed"); + } + public void SetKeyboardMode(string mode) { + string sequence="\x1b[<u\x1b[>4;0m"; + if(mode=="mok2") sequence+="\x1b[>4;2m"; + else if(mode=="kitty") sequence+="\x1b[>1u"; + else if(mode!="legacy") throw new Exception("Unknown keyboard mode transition"); + Print(sequence); + } + void Read() { + threadHandle=OpenThread(1,false,GetCurrentThreadId()); + if(threadHandle==IntPtr.Zero) { Error="Cannot acquire reader cancellation handle"; return; } + try { + while(running) { + if(Count()>1048576) { Error="Probe input limit exceeded"; break; } + uint count; + if(native) { + var batch=new Record[64]; + if(!ReadConsoleInputW(input,batch,64,out count)) { if(running) Error="ReadConsoleInputW failed"; break; } + lock(gate) for(int i=0;i<count;i++) { + var r=batch[i]; + // Preserve every native record, including non-key event type and payload. + records.Add(new long[]{r.Type,r.Down,r.Repeat,r.Vk,r.Scan,r.Unicode,r.Control}); + } + } else { + var batch=new byte[4096]; + if(!ReadFile(input,batch,4096,out count,IntPtr.Zero)) { if(running) Error="ReadFile failed"; break; } + lock(gate) for(int i=0;i<count;i++) bytes.Add(batch[i]); + } + } + } catch(Exception e) { Error=e.Message; } + } + public void Clear() { lock(gate) { bytes.Clear(); records.Clear(); } } + public bool ClearIfCount(int expected) { + lock(gate) { + if(bytes.Count+records.Count!=expected) return false; + bytes.Clear(); records.Clear(); return true; + } + } + public string Hex() { lock(gate) return BitConverter.ToString(bytes.ToArray()).Replace("-","").ToLowerInvariant(); } + public long[][] Records() { lock(gate) return records.ToArray(); } + public int Count() { lock(gate) return bytes.Count+records.Count; } + public string[] CleanupErrors { get; private set; } = new string[0]; + public void Dispose() { + var errors=new List<string>(); + running=false; + if(threadHandle!=IntPtr.Zero) CancelSynchronousIo(threadHandle); + if(reader!=null && !reader.Join(1000)) errors.Add("Reader did not stop"); + if(threadHandle!=IntPtr.Zero) CloseHandle(threadHandle); + try { Print("\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1006l"); } + catch(Exception e) { errors.Add(e.Message); } + if(!SetConsoleMode(input,original)) errors.Add("Input-mode restoration failed"); + if(!SetConsoleMode(output,outputMode)) errors.Add("Output-mode restoration failed"); + if(!SetConsoleCP(cp)) errors.Add("Code-page restoration failed"); + uint actual; + if(!GetConsoleMode(input,out actual) || actual!=original) errors.Add("Input mode differs after restoration"); + if(!GetConsoleMode(output,out actual) || actual!=outputMode) errors.Add("Output mode differs after restoration"); + if(GetConsoleCP()!=cp) errors.Add("Code page differs after restoration"); + CleanupErrors=errors.ToArray(); + } + } +} diff --git a/scripts/windows_input/Probe.ps1 b/scripts/windows_input/Probe.ps1 new file mode 100644 index 00000000..ad3a9c2b --- /dev/null +++ b/scripts/windows_input/Probe.ps1 @@ -0,0 +1,70 @@ +#Requires -Version 7.0 +param([Parameter(Mandatory)][string] $PlanPath) +. "$PSScriptRoot/Common.ps1" +$plan = Read-GauntletJson $PlanPath +Add-Type -Path "$PSScriptRoot/Native.cs" +$probe = $null +try { + $probe = [HerdrInputGauntlet.ConsoleProbe]::new($plan.mode) + [HerdrInputGauntlet.ConsoleProbe]::Print("`r`nHerdr input gauntlet observer $($plan.nonce)`r`n") + if ($plan.mode -eq 'kitty') { + $deadline = [DateTime]::UtcNow.AddSeconds(2) + while (-not $probe.Hex().Contains('1b5b3f3175') -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 50 } + } + Write-GauntletJson (Join-Path $plan.work 'ready.json') @{ + nonce = $plan.nonce; pid = $PID; mode = $plan.mode; geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry() + negotiation_hex = $probe.Hex(); kitty_acknowledged = $probe.Hex().Contains('1b5b3f3175') + } + # Negotiation replies are setup evidence, not input for the first case. + $probe.Clear() + $closedCount = 0 + $last = '' + while ((Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'probe-stop'))) { + $request = Read-GauntletJson (Join-Path $plan.work 'request.json') + if ($null -ne $request -and $request.id -ne $last) { + if ($request.nonce -ne $plan.nonce) { throw 'Unexpected observer nonce' } + $last = $request.id + $quietReached = $false + if ($request.action -eq 'mouse-on') { + [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003h`e[?1006h") + Start-Sleep -Milliseconds 200 + } + if ($request.action -eq 'mouse-off') { + [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003l`e[?1006l") + Start-Sleep -Milliseconds 100 + } + if ($request.action -eq 'set-mode') { + $probe.SetKeyboardMode([string]$request.value) + Start-Sleep -Milliseconds 200 + } + if ($request.action -eq 'begin' -and -not $probe.ClearIfCount($closedCount)) { throw 'Unexpected input arrived between captures' } + if ($request.action -eq 'end') { + # Capture trailing duplicates/releases as well as the first expected bytes. + $deadline = [DateTime]::UtcNow.AddSeconds(3) + $quiet = [DateTime]::UtcNow + $count = $probe.Count() + do { + Start-Sleep -Milliseconds 50 + $newCount = $probe.Count() + if ($newCount -ne $count) { $quiet = [DateTime]::UtcNow; $count = $newCount } + } while (([DateTime]::UtcNow - $quiet).TotalMilliseconds -lt 350 -and [DateTime]::UtcNow -lt $deadline) + $quietReached = ([DateTime]::UtcNow - $quiet).TotalMilliseconds -ge 350 + $closedCount = $probe.Count() + } + Write-GauntletJson (Join-Path $plan.work 'reply.json') @{ + id = $request.id; nonce = $plan.nonce; action = $request.action + hex = $probe.Hex(); records = @($probe.Records()); error = $probe.Error; quiet_reached = $quietReached + geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry() + } + } + Start-Sleep -Milliseconds 25 + } +} catch { + Write-GauntletJson (Join-Path $plan.work 'probe-error.json') @{ error = $_.ToString(); nonce = $plan.nonce } + throw +} finally { + if ($null -ne $probe) { + $probe.Dispose() + Write-GauntletJson (Join-Path $plan.work 'probe-exit.json') @{ nonce = $plan.nonce; cleanup_errors = @($probe.CleanupErrors) } + } +} diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md new file mode 100644 index 00000000..340ecb0e --- /dev/null +++ b/scripts/windows_input/README.md @@ -0,0 +1,213 @@ +# Local Windows input gauntlet (experimental) + +This is local Windows qualification infrastructure, not a claim that all Windows +input works. It sends real scan-code gestures through a new Windows Terminal +window, both directly to an observer and through an attached Herdr client. It +does not substitute `pane send-keys` for host input. + +## Safety and prerequisites + +Use an **unlocked, isolated interactive desktop** where you will not do other +work during automated input. The runner changes foreground focus and window +size. `SendInput` has an unavoidable check-to-use focus race; a foreground check +is not an OS security boundary. The runner rejects an elevated controller and +checks the target Terminal process token before focus, resize, and injection. +An elevation-query failure is also a refusal, not permission to proceed. Start +PowerShell and Terminal **without Run as administrator**, on a dedicated desktop. There is no unattended CI job or runner permission +change in this implementation. + +Required: Windows, PowerShell **7** (`pwsh`), Python 3, Rust/Cargo, and at least +one Windows Terminal channel. Stable and Preview are discovered independently +through their installed packages. Explicit paths are available when discovery +does not work. No software is installed or updated. The actual Terminal process path/version is recorded, +not inferred from the requested channel or bundled OpenConsole version. Stable +and Preview must resolve to distinct installations: the runner compares Windows +file/directory identities before launch, then checks actual image, installation, +and PID/start-time identities after activation. Aliases cannot count one install +twice. The portable report validator rejects duplicate identities too. + +**F12** aborts before the next automatic gesture. Moving focus away also aborts +further automatic input. Each injected chord contains its own releases; there +are no intentionally held modifiers between calls. Do not hold physical modifiers +or mouse buttons while starting a run. Normal controller cancellation runs cleanup; +bootstrap/probe leases also expire if the controller disappears. Lease expiry is +not a replacement for a secure isolated desktop. + +Clipboard tests require an **empty clipboard**. Clear it yourself only after +saving anything you need. The runner will not replace existing text, images, +rich formats, or files. It writes its synthetic text while holding the clipboard +lock and clears it afterward only if its sequence number is unchanged. A later +user clipboard update is left alone. No original clipboard contents are logged. + +## Run + +From the repository in PowerShell 7: + +```powershell +just test-windows-input +``` + +The full catalogue normally exits `2` because operator-assisted and explicitly +unimplemented qualification cases remain. That is incomplete coverage, not an +automated test failure; inspect the printed matrix and retained `report.json`. + +The recipe itself is the explicit opt-in to foreground input injection. It builds +the current checkout in release mode, stages that exact binary with the pinned +ConPTY runtime, and prints its path and hash. Use `-ExePath` only to compare a +specific old/new packaged binary. + +Or invoke the script directly: + +```powershell +pwsh -NoProfile -File scripts/test_windows_input.ps1 ` + -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection ` + -StablePath 'C:\TerminalStable\WindowsTerminal.exe' ` + -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe' +``` + +The default tests current Herdr's **default** input policy. Diagnostic runs may +use `-Profile win32` or `-Profile vt`; they do not replace the default run. +`-Modes native,legacy,mok2,kitty`, `-Channels`, `-Paths`, `-Cases`, `-Widths`, and +`-Heights` select a focused campaign. In a direct PowerShell invocation, supply +arrays normally: + +```powershell +.\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' ` + -AllowInputInjection -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` + -Widths 120 -Heights 30 +``` + +Dead-key acute composition is automatic when the target Terminal thread's +active layout exposes that physical mapping. The runner discovers and injects +the real scan-code chord; it never substitutes pasted or Unicode-packet text. +`-Manual` enables AltGr, IME, and the remaining guided composition cases. The operator must activate the +indicated layout/IME, perform the gesture in the test window, then return to the +controller and press Enter. Finish each prompt within the 90-second lease. +Record the layout you actually selected in your qualification notes; the report +also includes the observed foreground thread layout handle. Do not paste text +for a composition case. The runner does not install/change global layouts. + +You can inspect the full catalogue without Windows or desktop interaction: + +```powershell +pwsh -NoProfile -File scripts/test_windows_input.ps1 -ExePath unused -MatrixOnly +``` + +Every run needs a **new** output directory. By default it is +`.local/windows-input/<unique-id>/`. Do not reuse an old report directory. + +## What the implementation measures + +- Native console records, including down/up, repeat, virtual/scan codes, Unicode, + modifier state, and raw payloads for other native record types. +- Legacy VT, modifyOtherKeys level 2, and Kitty disambiguation observer modes. + These are **pane consumer modes**, not names for Herdr's outer reader. +- Physical-style Enter/Shift+Enter and other modifier chords, navigation and word + movement chords, control keys, and editing keys. The oracle checks bytes or + records; it does not claim that a particular editor implements word selection + correctly merely because Ctrl+Shift+Right reached it. +- Actual Windows clipboard paste via Ctrl+V, including LF/CRLF/CR, whitespace, + BMP Unicode/combining characters, and escape-looking text. + A host binding or multiline-paste confirmation dialog can intercept the gesture; + the runner does not dismiss unexpected dialogs or rebind Terminal shortcuts. +- A full case pass at an observed 120×30 host size; keyboard/paste sentinels at + **80, 119, 120, 121, 132, 160, 240 columns**, at 24 and 50 rows; then return to + 80 columns. This exercises narrow→wide→narrow resizing of the actual outer + window. Both outer and pane dimensions are captured. Herdr chrome means pane + width is not the same as outer width. An unreachable size is not a pass. +- Automatic active-layout dead-key acute input, plus guided AltGr, extended + accent, and IME commits, with exact expected committed text. +- Ordered typing, mouse-motion reports, and bracketed paste in one capture, plus + click/wheel reporting before and after a real focus cycle, the same mouse + sentinel after resize, and legacy→modifyOtherKeys→Kitty→modifyOtherKeys→legacy + transitions without restart. + +The focus/resize mouse check runs in Windows Terminal. It guards Herdr's recovery +sequence but does not certify the Tabby/Alacritty host-specific report in #4284. +The catalogue also lists explicit **qualification gaps**: mouse drag and +right-edge coordinate mapping; visual reflow/wrapping; native held-key repeat; +lock/keypad combinations; dead-key cancellation; IME cancellation; capture/config +reload and attach cycles; injected setup/recovery faults; supplementary-plane and +confirmation-triggering burst paste; image/file clipboard integrations; and +positive host-scrollback evidence for native PageUp/PageDown. These are recorded +`not_run` or `inconclusive`, not fabricated successes. They need +separate fixtures/oracles before becoming automated assertions. The catalogue is +broad; the automated run does **not cover every row**. + +Injected PageUp/PageDown remains `inconclusive` through Herdr. On Windows +Terminal 1.24, both `SendInput` and `keybd_event` produced zeroed non-key records +even in the direct-host baseline, so absence of pane input cannot prove that +Herdr consumed the key. Conclusive qualification requires literal physical input +plus a positive pane scroll-offset change; that guided check is not automated yet. + +## Evidence and verdicts + +The retained directory contains: + +- `matrix.json`: all cases, profiles, and declared byte/record expectations; +- `observations.json`: raw captures, per-case identity, measured dimensions, + readiness/focus flags, errors, and cleanup results; +- `report.json`: interpreted verdicts and counts; +- per-window plans, nonce-bound observer acknowledgements, initial/final console + modes, and bootstrap/probe error records. + +The console ends with a capability matrix derived only from that run's captured +observations. Filtered, unavailable, or operator-assisted cases remain +`NOT TESTED` or `MANUAL`; the full evidence and reasons remain in `report.json`. +The Herdr column is labelled Win32 only after the current binary's selected +reader decodes a real nonce-owned Win32 serialized record. Configuration defaults +alone do not select the label; missing runtime evidence prints `UNKNOWN`. + +Direct-host and through-Herdr observations are labelled separately. A direct-host +failure is **not automatically a Herdr bug**. Each row labels its failure scope as +`direct_host` or `through_herdr_not_yet_attributed`. For example, a terminal may +intercept Alt+Enter or use a different control-key encoding. + +One explicit known-gap rule labels nonce-bound, complete **WT 1.24/1.25 +direct-host mOK captures** of Shift/Ctrl/Ctrl+Shift+Enter or Shift+Tab as +`unsupported` only when they contain the exact legacy bytes. It does not exempt +other tests or versions, an empty/malformed capture, Kitty, or any through-Herdr +failure. Through-Herdr still must preserve the requested gesture. +Unsupported remains incomplete coverage, not green. Review captures before +adding other capability exceptions; never bless lost Shift information in Herdr +just to make a report green. + +Comparisons consume the **complete captured sequence**, including a quiet interval +to catch trailing duplicates/releases. Paste must have one intact bracketed +wrapper and the complete payload. CR/LF variants are compared as logical newlines; +this is not proof of byte-exact line-ending policy. Native keys require their +non-modifier down/up pair, modifier state, and repeat count. + +Exit codes: + +- `0`: all observed assertions passed and no recorded coverage gaps/errors; +- `1`: an assertion, harness, or cleanup failed; +- `2`: coverage is incomplete (including deliberately unimplemented catalogue + rows, missing hosts, or unavailable geometry). + +An empty or missing report never means success. None of these statuses certifies +an entire Windows host. + +## Cleanup and native handoff + +Every window has a fresh nonce, named session, isolated config root, known +PowerShell pane shell, and observer acknowledgement. Inherited Herdr socket, +backend, session, and SSH variables are cleared in the actual launched process, +not just in the controller. Setup APIs only create/focus the observer pane. + +Cleanup targets the named session, retained child-process handles, and the exact +nonce-bearing Terminal window. There is no process-name-wide kill or sweep of +newly appeared OpenConsole processes. Artifacts are retained. Forced cleanup and +console-mode mismatches are reported, not hidden behind a passing key test. + +For release-risk input changes, qualify Stable and Preview, real focus-loss/F12 +interruption, partial startup, clipboard changes, cleanup, and measured width +boundaries. Use old and new Herdr binaries in **separate runs** to establish that +a known regression is caught. Do not overwrite installations or reuse an earlier +server. Portable unit tests validate the catalogue and verdict logic; they do not +replace native Windows evidence. + +The existing `windows_conpty_enhanced_input_probe.ps1` remains the downstream API +control. Existing Rust keyboard/Windows-translator tests remain the deterministic +parser controls. This runner complements them rather than cross-producting their +fixtures or pretending they establish host behavior. diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py new file mode 100644 index 00000000..7c15c149 --- /dev/null +++ b/scripts/windows_input/report.py @@ -0,0 +1,446 @@ +"""Portable case catalogue and strict evidence verdicts for the local Windows gauntlet. + +This is not a host simulator. A report cannot turn absent Windows evidence into a pass. +""" +import argparse +import json +import re +from pathlib import Path + +WIDTHS = [80, 119, 120, 121, 132, 160, 240] +HEIGHTS = [24, 50] +MODES = ["native", "legacy", "mok2", "kitty"] + + +def hex_of(text): + return text.encode("utf-8").hex() + + +def catalogue(): + cases = [] + + def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): + mask = (1 if 16 in modifiers else 0) | (2 if 18 in modifiers else 0) | (4 if 17 in modifiers else 0) + unicode = (vk + 32 if 65 <= vk <= 90 else vk if vk in (8, 9, 13, 27) else 0) + if 65 <= vk <= 90 and 16 in modifiers: + unicode = vk + if 17 in modifiers: + unicode = vk - 64 if 65 <= vk <= 90 else 10 if vk == 13 else 127 if vk == 8 else 0 + expected = {"native": {"vk": vk, "modifiers": mask, "unicode": unicode, "modifier_keys": list(modifiers)}} + if text is not None: + expected.update({mode: {"hex": [hex_of(text)]} for mode in MODES[1:]}) + if legacy is not None: + expected["legacy"] = {"hex": [hex_of(legacy)]} + if enhanced is not None: + expected["mok2"] = {"hex": [hex_of(f"\x1b[27;{mask + 1};{enhanced}~")]} + expected["kitty"] = {"hex": [hex_of(f"\x1b[{enhanced};{mask + 1}u")]} + cases.append(dict(id=name, kind="key", chords=[[*modifiers, vk]], expected=expected)) + + key("letter-a", 65, "a") + key("shift-letter", 65, "A", (16,)) + key("enter", 13, "\r") + key("shift-enter", 13, None, (16,), 13) + cases[-1]["expected"]["legacy"] = {"loss": "enter", "hex": ["0d"]} + key("ctrl-enter", 13, None, (17,), 13) + cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]} + key("ctrl-shift-enter", 13, None, (17, 16), 13) + cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]} + key("tab", 9, "\t") + key("shift-tab", 9, "\x1b[Z", (16,)) + cases[-1]["expected"]["mok2"] = {"hex": [hex_of("\x1b[27;2;9~")]} + cases[-1]["expected"]["kitty"]["hex"].append(hex_of("\x1b[9;2u")) + key("backspace", 8, "\x7f") + key("ctrl-backspace", 8, None, (17,), 127, "\x08") + key("alt-backspace", 8, None, (18,), 127, "\x1b\x7f") + key("escape", 27, "\x1b") + # Kitty's disambiguation flag encodes Escape distinctly. + cases[-1]["expected"]["kitty"] = {"hex": [hex_of("\x1b[27u")]} + for name, vk, seq, kitty in [("up", 38, "A", 57419), ("down", 40, "B", 57420), ("right", 39, "C", 57418), + ("left", 37, "D", 57417), ("home", 36, "H", 57423), ("end", 35, "F", 57424)]: + for prefix, modifiers, modifier in [("", (), 1), ("shift-", (16,), 2), ("ctrl-", (17,), 5), ("ctrl-shift-", (17, 16), 6)]: + if prefix == "ctrl-shift-" and name in ("up", "down", "home", "end"): + continue + key(prefix + name, vk, "\x1b[" + ("" if modifier == 1 else f"1;{modifier}") + seq, modifiers) + cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}{'' if modifier == 1 else f';{modifier}'}u")) + for name, vk, code, kitty in [("insert", 45, 2, 57425), ("delete", 46, 3, 57426), + ("page-up", 33, 5, 57421), ("page-down", 34, 6, 57422)]: + key(name, vk, f"\x1b[{code}~") + cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}u")) + for letter in "acdj lmqsu z".replace(" ", ""): + key("ctrl-" + letter, ord(letter.upper()), None, (17,), ord(letter), chr(ord(letter) - 96)) + key("alt-v", 86, None, (18,), ord("v"), "\x1bv") + for name, text in [ + ("paste-lf", "line 1\nline 2"), + ("paste-crlf", "line 1\r\nline 2\r\n"), + ("paste-cr", "line 1\rline 2"), + ("paste-whitespace", " one\t\n\n two \n"), + ("paste-unicode", "é e\u0301 日本語\nnext"), + ("paste-escape-looking", "literal [200~ and \\x1b[31m\nend"), + ]: + cases.append(dict(id=name, kind="paste", text=text, expected={mode: {"paste": text} for mode in MODES[1:]})) + cases.append(dict(id="mouse-interleave", kind="mouse-interleave", text="mouse\npaste", + expected={mode: {"mouse_interleave": True} for mode in MODES[1:]})) + cases.append(dict(id="mouse-focus-refresh", kind="mouse-focus-refresh", + expected={mode: {"mouse_focus_refresh": True} for mode in MODES[1:]})) + transitions = "a\r" + "b\x1b[27;2;13~" + "c\x1b[13;2u" + "d\x1b[27;2;13~" + "e\r" + "f" + cases.append(dict(id="mode-transitions", kind="mode-transitions", + expected={mode: {"hex": [hex_of(transitions)]} for mode in MODES[1:]})) + cases.append(dict(id="dead-acute", kind="layout-key", dead="´", base_vk=69, + expected={mode: {"hex": [hex_of("é")]} for mode in MODES[1:]})) + for name, prompt, text in [ + ("dead-grave", "With US-International active, type grave then e, once; no Enter.", "è"), + ("dead-circumflex", "With US-International active, type circumflex then e, once; no Enter.", "ê"), + ("dead-tilde", "With US-International active, type tilde then n, once; no Enter.", "ñ"), + ("dead-diaeresis", "With US-International active, type diaeresis then u, once; no Enter.", "ü"), + ("dead-space", "With US-International active, type acute then Space, once; no Enter.", "'"), + ("altgr-euro", "With your declared euro-producing AltGr layout, type € using its AltGr chord, once; no Enter.", "€"), + ("ime-commit", "Using your declared IME, compose and commit 日本語 (not paste); do not submit the prompt.", "日本語"), + ]: + cases.append(dict(id=name, kind="manual", prompt=prompt, expected={mode: {"hex": [hex_of(text)]} for mode in MODES[1:]})) + # Explicit qualification tasks, never auto-passed by key/byte checks. + for name, prompt in [ + ("dead-cancel-repeat", "Qualify dead-key cancellation, repeated accent and non-composing next character against the direct-host baseline."), + ("ime-cancel", "Qualify IME cancel and partial composition without committed/duplicate text."), + ("native-repeat-release", "Qualify held-key repeat, release identity, and modifier interleaving using native records."), + ("locks-keypad", "Qualify CapsLock/NumLock, keypad Enter/operators/decimal and restore lock states."), + ("mouse-right-edge", "Click/drag/wheel at a marked pane column above 120; compare actual report coordinates and visual target."), + ("wrap-rendering", "Verify the displayed ruler and wrapped multiline text at both window heights and every observed width."), + ("capture-refresh", "Toggle mouse capture/config reload, refocus, detach/reattach; repeat paste and Shift+Enter."), + ("setup-recovery", "Inject a recoverable setup failure and late VT activation; verify mode restoration and the same input sentinels."), + ("ctrl-v", "Qualify raw Ctrl+V only with the Windows Terminal paste binding explicitly removed."), + ("alt-enter", "Qualify Alt+Enter with the Windows Terminal fullscreen binding explicitly controlled."), + ("ctrl-shift-up", "Qualify Ctrl+Shift+Up with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-down", "Qualify Ctrl+Shift+Down with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-home", "Qualify Ctrl+Shift+Home with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-end", "Qualify Ctrl+Shift+End with the Windows Terminal scroll binding explicitly controlled."), + ("paste-supplementary", "Qualify supplementary-plane clipboard text, including emoji, against the direct-host baseline."), + ("paste-burst", "Qualify a 200-line clipboard burst with the host multiline-paste warning configured or handled explicitly."), + ("clipboard-nontext", "Qualify supported image/file clipboard integrations separately; do not infer from text paste."), + ]: + cases.append(dict(id=name, kind="qualification", prompt=prompt, expected={})) + return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases) + + +def verdict(case, mode, evidence): + """The full capture, not a matching prefix, is the primary assertion.""" + if evidence.get("status") in {"not_run", "unsupported", "inconclusive"}: + return evidence["status"], evidence.get("reason", "No qualifying observation") + expected = case["expected"].get(mode) + if expected is None: + return "not_run", "No automatic oracle for this case/profile; requires qualification" + if not evidence.get("ready") or not evidence.get("focus_verified") or not evidence.get("complete"): + return "inconclusive", "Missing readiness, focus, or complete capture" + if evidence.get("error"): + return "inconclusive", evidence["error"] + if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" not in expected: + expected = {"hex": [""]} # Plain page keys intentionally control Herdr's host scrollback. + def geometry(name): + value = evidence.get(name) + return value[:2] if isinstance(value, list) and len(value) >= 2 and all(type(v) is int for v in value[:2]) else None + outer, pane = geometry("outer_geometry"), geometry("pane_geometry") + final_pane, final_outer = geometry("final_pane_geometry"), geometry("final_outer_geometry") + if outer != [evidence.get("width"), evidence.get("height")]: + return "inconclusive", "Requested geometry was not observed" + if pane is None or min(pane) <= 0: + return "inconclusive", "Missing actual pane dimensions" + if final_pane != pane: + return "inconclusive", "Pane geometry changed during capture" + if final_outer != outer: + return "inconclusive", "Outer geometry changed during capture" + if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" in expected: + records = evidence.get("records") + if not isinstance(records, list) or any(not isinstance(record, list) or len(record) != 7 + or not all(type(field) is int for field in record) for record in records): + return "inconclusive", "Malformed native record" + return (("inconclusive", "No positive host-scrollback evidence for the consumed page key") + if all(record[0] in (4, 16) for record in records) + else ("fail", "Plain page key unexpectedly reached the pane")) + if (evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") + and evidence.get("hex") == ""): + return "inconclusive", "No positive host-scrollback evidence for the consumed page key" + if "vk" in expected: + records = evidence.get("records") + scans = evidence.get("scans", []) + chord = case["chords"][0] + if not isinstance(records, list): + return "inconclusive", "Malformed native record" + if not isinstance(scans, list) or len(scans) != len(chord) or not all(type(scan) is int for scan in scans): + return "inconclusive", "Missing injected scan-code evidence" + expected_scans = dict(zip(chord, scans)) + aliases = {160: 16, 161: 16, 162: 17, 163: 17, 164: 18, 165: 18} + held = set() + keys = [] + for record in records: + if not isinstance(record, list) or len(record) != 7 or not all(type(field) is int for field in record): + return "inconclusive", "Malformed native record" + if record[0] in (4, 16): # Resize/focus notifications carry no typed text. + continue + if record[0] != 1: + return "fail", "Unexpected non-key native input" + vk = aliases.get(record[3], record[3]) + if vk not in (16, 17, 18): + keys.append(record) + continue + if vk not in expected["modifier_keys"] or record[5] != 0 or record[2] != 1 or record[4] != expected_scans[vk]: + return "fail", "Unexpected/corrupted modifier record" + if record[1] == 1 and vk not in held: + held.add(vk) + elif record[1] == 0 and vk in held: + held.remove(vk) + else: + return "fail", "Unbalanced modifier sequence" + if held: + return "fail", "Modifier release missing" + if len(keys) != 2 or [r[1] for r in keys] != [1, 0]: + return "fail", "Expected exactly one non-modifier down/up pair" + for record in keys: + control = record[6] + modifiers = bool(control & 16) + 2 * bool(control & 3) + 4 * bool(control & 12) + if (record[3] != expected["vk"] or modifiers != expected["modifiers"] or record[2] != 1 + or record[5] != expected["unicode"] or record[4] != expected_scans[expected["vk"]]): + return "fail", "Native identity/modifiers/repeat differ" + return "pass", "Native down/up pair matches" + try: + raw = bytes.fromhex(evidence["hex"]) + except (KeyError, ValueError, TypeError): + return "inconclusive", "Missing or malformed raw bytes" + if expected.get("mouse_interleave"): + motion = rb"(?:\x1b\[<35;\d+;\d+M)+" + newline = rb"(?:\r\n|\r|\n)" + pattern = b"a" + motion + rb"\x1b\[200~mouse" + newline + rb"paste\x1b\[201~" + motion + b"b" + return ("pass", "Typing, mouse motion and paste remained ordered") if re.fullmatch(pattern, raw) else ("fail", "Mouse interleave order or payload differs") + if expected.get("mouse_focus_refresh"): + coords = rb"\d+;\d+" + gesture = (rb"(?:\x1b\[<35;" + coords + rb"M)*" + + rb"\x1b\[<0;" + coords + rb"M" + + rb"\x1b\[<0;" + coords + rb"m" + + rb"\x1b\[<64;" + coords + rb"M") + pattern = b"a" + gesture + b"b" + b"c" + gesture + b"d" + return ("pass", "Click/wheel reporting survived focus loss and regain") if re.fullmatch(pattern, raw) else ("fail", "Mouse reporting failed before or after focus regain") + if case["id"] == "mode-transitions" and evidence.get("path") == "direct": + legacy_only = hex_of("a\rb\rc\rd\re\rf") + kitty_only = hex_of("a\rb\rc\x1b[13;2u" + "d\re\rf") + if raw.hex() in {legacy_only, kitty_only}: + return "unsupported", "Direct Windows Terminal ignored modifyOtherKeys during the transition chain" + return "fail", "Unexpected direct-host runtime transition bytes" + if "loss" in expected: + return ("unsupported", "Plain VT cannot preserve modified Enter") if raw.hex() in expected["hex"] else ("fail", "Unexpected plain-VT modified Enter result") + if "hex" in expected: + return ("pass", "Exact bytes match") if raw.hex() in expected["hex"] else ("fail", "Bytes differ (including any duplicates/trailing input)") + if not raw.startswith(b"\x1b[200~") or not raw.endswith(b"\x1b[201~"): + return "fail", "Missing bracketed-paste envelope; newlines may be key events" + try: + actual = raw[6:-6].decode("utf-8") + except UnicodeDecodeError: + return "fail", "Paste is not intact UTF-8" + # Line-ending conversion is expected on Windows; all other payload bytes matter. + normalize = lambda text: text.replace("\r\n", "\n").replace("\r", "\n") + return ("pass", "One complete paste matches") if normalize(actual) == normalize(expected["paste"]) else ("fail", "Paste payload differs") + + +def known_host_gap(observation, case, terminal_version): + """A narrowly observed host limitation, never a blanket version exemption.""" + legacy = case["expected"].get("legacy", {}).get("hex", []) + mok2 = case["expected"].get("mok2", {}).get("hex", []) + return (observation.get("path") == "direct" + and observation.get("mode") == "mok2" + and case["id"] in {"shift-enter", "ctrl-enter", "ctrl-shift-enter", "shift-tab"} + and re.match(r"^1\.(?:24|25)\.", str(terminal_version)) and set(legacy) != set(mok2) + and observation.get("hex") in legacy) + + +def channel_identity_errors(hosts): + """Compare both launcher identities and the processes actually activated.""" + identities = {} + for host in hosts: + values = identities.setdefault(host.get("channel"), set()) + for field, kind in (("launcher_identity", "file"), ("installation_identity", "installation")): + if host.get(field): + values.add((kind, host[field])) + for run in host.get("runs", []): + for field, kind in (("image_identity", "file"), ("installation_identity", "installation"), ("process_identity", "process")): + if run.get(field): + values.add((kind, run[field])) + return ["Stable and Preview share a Terminal executable, installation, or process identity"] if identities.get("stable", set()) & identities.get("preview", set()) else [] + + +def summarize(document): + matrix = catalogue() + cases = {case["id"]: case for case in matrix["cases"]} + rows = [] + seen = set() + captures = set() + for observation in document.get("observations", []): + identity = tuple(observation.get(k) for k in ("host", "path", "mode", "phase", "width", "height", "case")) + if identity in seen: + raise ValueError(f"Duplicate observation identity: {identity}") + seen.add(identity) + case = cases[observation["case"]] + status, reason = verdict(case, observation["mode"], observation) + scope = "direct_host" if observation.get("path") == "direct" else "through_herdr_not_yet_attributed" + if status in ("pass", "fail"): + bound = [run for host in document.get("hosts", []) if host.get("channel") == observation.get("host") + for run in host.get("runs", []) + if run.get("nonce") == observation.get("nonce") and run.get("path") == observation.get("path") + and run.get("mode") == observation.get("mode") and run.get("pid", 0) > 0 and run.get("hwnd", 0) != 0 + and run.get("elevated") is False and run.get("image_identity") and run.get("installation_identity")] + capture = observation.get("capture_id") + if len(bound) != 1 or document.get("controller_elevated") is not False or not capture or capture in captures: + status, reason = "inconclusive", "Missing non-elevated owned-run binding or fresh capture identity" + else: + captures.add(capture) + if status == "fail" and known_host_gap(observation, case, bound[0].get("terminal_version")): + status, reason = "unsupported", "Direct host ignored mOK and emitted the case's legacy bytes" + rows.append({**observation, "status": status, "reason": reason, "failure_scope": scope}) + counts = {status: sum(r["status"] == status for r in rows) for status in ("pass", "fail", "not_run", "unsupported", "inconclusive")} + # No run may claim all-green just because it produced zero/missing observations. + planned = set() + geometries = [(120, 30, True)] + [(w, h, False) for h in document.get("heights", HEIGHTS) for w in document.get("widths", WIDTHS)] + [(80, 30, False)] + selected_cases = document.get("cases") or list(cases) + for host in document.get("channels") or ("stable", "preview"): + for path in document.get("paths") or ("direct", "herdr"): + for mode in document.get("modes", MODES): + for phase, (width, height, full) in enumerate(geometries, 1): + for case_id in selected_cases if full else (case_id for case_id in ("letter-a", "shift-enter", "paste-lf", "mouse-focus-refresh") if case_id in selected_cases): + planned.add((host, path, mode, phase, width, height, case_id)) + missing = planned - seen + if seen - planned: + raise ValueError("Observations outside the declared run matrix") + hosts = document.get("hosts", []) + errors = list(document.get("errors", [])) + channel_identity_errors(hosts) + expected_hosts = set(document.get("channels") or ("stable", "preview")) + complete = (bool(rows) and not missing and {h.get("channel") for h in hosts} == expected_hosts + and all(h.get("runs") for h in hosts) + and not errors and not document.get("cleanup_errors") + and all(r["status"] == "pass" for r in rows)) + return {**document, "errors": errors, "observations": rows, "counts": counts, "coverage_missing": len(missing), "observed_checks_passed": complete, + "native_qualification": "Required; this report is not a full Windows support certificate"} + + +def qualification_matrix(result): + """Collapse real observations into the user-facing capability summary.""" + rows = result.get("observations", []) + required_channels = set(result.get("channels") or ("stable", "preview")) + manual_cases = {case["id"] for case in catalogue()["cases"] if case["kind"] == "manual"} + groups = [ + ("Printable keys", {"letter-a", "shift-letter"}, None), + ("Shift+Enter", {"shift-enter"}, None), + ("Ctrl+Enter", {"ctrl-enter"}, None), + ("Ctrl+Shift+Enter", {"ctrl-shift-enter"}, None), + ("Navigation/editing", {"up", "down", "left", "right", "home", "end", "insert", "delete", "tab", "shift-tab", "page-up", "page-down"}, None), + ("Multiline paste", {"paste-lf"}, None), + ("CR/LF/CRLF paste", {"paste-lf", "paste-crlf", "paste-cr"}, None), + ("Unicode/whitespace paste", {"paste-unicode", "paste-whitespace"}, None), + ("Paste framing/ordering", {case["id"] for case in catalogue()["cases"] if case["kind"] == "paste"}, None), + ("Resize 120 -> 80", {"letter-a", "shift-enter", "paste-lf"}, 80), + ("Mouse while typing/pasting", {"mouse-interleave"}, None), + ("Mouse after focus regain", {"mouse-focus-refresh"}, None), + ("Mouse after resize", {"mouse-focus-refresh"}, 80), + ("Dead-key composition", {"dead-acute"}, None), + ("AltGr", {"altgr-euro"}, None), + ("IME composition", {"ime-commit"}, None), + ("Runtime mode transitions", {"mode-transitions"}, None), + ] + + def cell(case_ids, width, path, modes): + matched = [row for row in rows if row.get("case") in case_ids and row.get("path") == path + and row.get("mode") in modes and (width is None or row.get("width") == width)] + statuses = {row.get("status") for row in matched} + if "fail" in statuses: + return "FAIL" + if case_ids <= manual_cases and not any(status == "pass" for status in statuses): + return "MANUAL" + if {row.get("case") for row in matched} != case_ids: + return "PARTIAL" if "pass" in statuses else "INCONCLUSIVE" if "inconclusive" in statuses else "NOT TESTED" + if required_channels and {(row.get("host"), row.get("case")) for row in matched} != { + (channel, case_id) for channel in required_channels for case_id in case_ids}: + return "PARTIAL" + if case_ids == {"mode-transitions"} and path == "direct" and statuses <= {"unsupported", "inconclusive"}: + return "X - mOK ignored" + if path == "direct" and modes == {"legacy"} and statuses == {"unsupported"}: + return "X - becomes Enter" if case_ids == {"shift-enter"} else "X - loses modifier" + def case_passed(case_id): + statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id} + return "pass" in statuses_for_case or (width == 80 and path == "direct" and modes == {"legacy"} + and case_id == "shift-enter" and statuses_for_case == {"unsupported"}) + per_case_passed = all(case_passed(case_id) for case_id in case_ids) + allowed = {"pass", "inconclusive"} + if width == 80 and path == "direct" and modes == {"legacy"}: + allowed.add("unsupported") + if per_case_passed and statuses <= allowed: + return "PASS**" if "inconclusive" in statuses else "PASS" + if "inconclusive" in statuses: + return "INCONCLUSIVE" + if "unsupported" in statuses: + return "UNSUPPORTED" + return "NOT TESTED" + + table = [] + for name, case_ids, width in groups: + herdr_modes = {"legacy"} if name in {"Mouse after resize", "Runtime mode transitions"} else {"mok2"} if "Enter" in name or name in {"Resize 120 -> 80", "Dead-key composition", "AltGr", "IME composition"} else {"legacy"} + table.append((name, cell(case_ids, width, "herdr", herdr_modes), + cell(case_ids, width, "direct", {"legacy"}), cell(case_ids, width, "direct", {"kitty"}))) + return table + + +def herdr_protocol_label(result): + runs = {(host.get("channel"), run.get("path"), run.get("mode"), run.get("nonce")) + for host in result.get("hosts", []) for run in host.get("runs", []) + if run.get("path") == "herdr" and run.get("nonce")} + proven = {(row.get("host"), row.get("path"), row.get("mode"), row.get("nonce")) + for row in result.get("observations", []) + if row.get("path") == "herdr" and row.get("input_reader") == "windows-console" + and row.get("input_transport") == "win32-serialized" and row.get("nonce")} + return "Win32 (Herdr)*" if runs and runs <= proven else "Herdr default (UNKNOWN)*" + + +def print_qualification_matrix(result): + table = [("Thing", herdr_protocol_label(result), "Plain VT", "Kitty"), *qualification_matrix(result)] + widths = [max(len(str(row[column])) for row in table) for column in range(4)] + line = lambda row: " | ".join(str(value).ljust(widths[index]) for index, value in enumerate(row)) + print("\nWindows input qualification results") + print(line(table[0])) + print("-+-".join("-" * width for width in widths)) + for row in table[1:]: + print(line(row)) + print("* Herdr protocol label comes from runtime evidence; UNKNOWN is never treated as Win32.") + print("** At least one capable host passed; host capability gaps remain visible in report.json.") + print("MANUAL requires an operator-assisted -Manual run; no automated result is claimed.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["matrix", "report"]) + parser.add_argument("--input", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = catalogue() if args.command == "matrix" else summarize(json.loads(args.input.read_text(encoding="utf-8-sig"))) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if args.command == "report": + counts = result["counts"] + through_failures = sum(row["status"] == "fail" and row.get("path") == "herdr" for row in result["observations"]) + direct_failures = sum(row["status"] == "fail" and row.get("path") == "direct" for row in result["observations"]) + print(f"Observed: {counts['pass']} pass, {counts['fail']} fail, {counts['unsupported']} unsupported, " + f"{counts['inconclusive']} inconclusive, {counts['not_run']} not run; " + f"{result['coverage_missing']} planned rows missing") + if through_failures: + print(f"Assessment: {through_failures} through-Herdr failures need attribution; inspect report.json before treating them as product regressions") + elif result.get("errors") or result.get("cleanup_errors"): + print("Assessment: harness or cleanup failed; this run does not qualify input behavior") + elif direct_failures: + print(f"Assessment: {direct_failures} direct-host differences observed; these are not automatically Herdr bugs") + elif counts["inconclusive"] or counts["not_run"] or counts["unsupported"] or result["coverage_missing"]: + print("Assessment: observed assertions passed, but host limitations or qualification gaps remain") + else: + print("Assessment: all planned observed assertions passed") + if result.get("errors"): + print("Run errors: " + " | ".join(result["errors"])) + if result.get("cleanup_errors"): + print("Cleanup errors: " + " | ".join(result["cleanup_errors"])) + print_qualification_matrix(result) + return 1 if result["counts"]["fail"] or result.get("errors") or result.get("cleanup_errors") else 2 if not result["observed_checks_passed"] else 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
