diff --git a/Tools/windows/ShellOwnedProcess.cs b/Tools/windows/ShellOwnedProcess.cs index 710edf87..2fb6a7bd 100644 --- a/Tools/windows/ShellOwnedProcess.cs +++ b/Tools/windows/ShellOwnedProcess.cs @@ -9,6 +9,10 @@ using System.Threading.Tasks; public sealed class ShellOwnedLaunch { + [DllImport("kernel32.dll")] + static extern uint GetOEMCP(); + public static int OemCodePage() { return checked((int)GetOEMCP()); } + public static string SelectWorkingDirectory(ProcessStartInfo info) { return String.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory; } @@ -141,6 +145,12 @@ static string Quote(string value) { } public static ShellOwnedLaunch Suspended(ProcessStartInfo info) { + return Suspended(info, null, null); + } + + public static ShellOwnedLaunch Suspended(ProcessStartInfo info, TextWriter standardOutputWriter, + TextWriter standardErrorWriter) { + // Supplied writers remain caller-owned until verified teardown and pipe EOF. if (info.UseShellExecute || !String.IsNullOrEmpty(info.Arguments)) throw new InvalidOperationException("Owned launch requires UseShellExecute=false and ArgumentList."); var security = new Security { Length = Marshal.SizeOf(), Inherit = 1 }; @@ -202,8 +212,10 @@ public static ShellOwnedLaunch Suspended(ProcessStartInfo info) { run.errorReader = new StreamReader(new FileStream( new Microsoft.Win32.SafeHandles.SafeFileHandle(errRead, true), FileAccess.Read)); errRead = IntPtr.Zero; - run.outputCapture = new OutputCapture(run.outputReader, ForwardWriter(info.RedirectStandardOutput, Console.Out)); - run.errorCapture = new OutputCapture(run.errorReader, ForwardWriter(info.RedirectStandardError, Console.Error)); + run.outputCapture = new OutputCapture(run.outputReader, + standardOutputWriter ?? ForwardWriter(info.RedirectStandardOutput, Console.Out)); + run.errorCapture = new OutputCapture(run.errorReader, + standardErrorWriter ?? ForwardWriter(info.RedirectStandardError, Console.Error)); run.Output = run.outputCapture.Completion; run.Error = run.errorCapture.Completion; return run; @@ -271,6 +283,13 @@ public void Drain() { foreach (var task in new[] { Output, Error }) if (task != null && !task.Wait(5000)) throw new InvalidOperationException("Owned output EOF is unproved."); } + public void Drain(int timeoutMilliseconds) { + if (timeoutMilliseconds < 0) throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds)); + var clock = Stopwatch.StartNew(); + foreach (var task in new[] { Output, Error }) + if (task != null && !task.Wait(Math.Max(0, timeoutMilliseconds - (int)clock.ElapsedMilliseconds))) + throw new InvalidOperationException("Owned output EOF is unproved."); + } public void Close() { if (Handle != IntPtr.Zero) RequireExited(); Drain(); diff --git a/Tools/windows/ShellProcessOwnership.psm1 b/Tools/windows/ShellProcessOwnership.psm1 index eca3c953..d93346ff 100644 --- a/Tools/windows/ShellProcessOwnership.psm1 +++ b/Tools/windows/ShellProcessOwnership.psm1 @@ -45,8 +45,9 @@ function Invoke-ShellJob([string] $Operation, [IntPtr] $Job = [IntPtr]::Zero, } } -function New-ShellSuspendedLaunch([Diagnostics.ProcessStartInfo] $StartInfo) { - [ShellOwnedLaunch]::Suspended($StartInfo) +function New-ShellSuspendedLaunch([Diagnostics.ProcessStartInfo] $StartInfo, + [IO.TextWriter] $StandardOutputWriter, [IO.TextWriter] $StandardErrorWriter) { + [ShellOwnedLaunch]::Suspended($StartInfo, $StandardOutputWriter, $StandardErrorWriter) } function Test-ShellOwnershipUnproven($Failure) { @@ -219,9 +220,13 @@ function New-ShellProcessScope { } } -function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartInfo) { +function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartInfo, + [IO.TextWriter] $StandardOutputWriter, [IO.TextWriter] $StandardErrorWriter) { if ($Scope.Closed) { throw "Owned shell scope is closed" } - try { $run = New-ShellSuspendedLaunch $StartInfo } catch { + try { + $run = New-ShellSuspendedLaunch $StartInfo ` + -StandardOutputWriter $StandardOutputWriter -StandardErrorWriter $StandardErrorWriter + } catch { if (Test-ShellOwnershipUnproven $_) { throw (Set-ShellOwnershipUnproven $_.Exception -FailureRecord $_) } @@ -310,18 +315,23 @@ function Receive-ShellOwnedOutput($Scope, [Diagnostics.Process] $Process, } } -function Complete-ShellLaunch($Run) { +function Complete-ShellLaunch($Run, [Nullable[int]] $TimeoutMilliseconds) { if ($Run.Finalized) { return } + $clock = [Diagnostics.Stopwatch]::StartNew() try { $Run.RequireExited() if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { Invoke-ShellJob "Terminate" $Run.LaunchJob - Invoke-ShellJob "Wait" $Run.LaunchJob + $remaining = if ($null -eq $TimeoutMilliseconds) { 5000 } else { + [Math]::Max(0, [int]$TimeoutMilliseconds - [int]$clock.ElapsedMilliseconds) + } + Invoke-ShellJob "Wait" $Run.LaunchJob -TimeoutMilliseconds $remaining if ((Invoke-ShellJob "Active" $Run.LaunchJob) -ne 0) { throw "OWNED_CLEANUP_UNPROVEN: private launch job not empty" } } - $Run.Drain() + if ($null -eq $TimeoutMilliseconds) { $Run.Drain() } + else { $Run.Drain([Math]::Max(0, [int]$TimeoutMilliseconds - [int]$clock.ElapsedMilliseconds)) } if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { Invoke-ShellJob "Close" $Run.LaunchJob $Run.LaunchJobClosed = $true @@ -346,22 +356,22 @@ function Complete-ShellOwnedProcess($Scope, [Diagnostics.Process] $Process) { Complete-ShellLaunch $runs[0] } -function Close-ShellProcessScope($Scope) { +function Close-ShellProcessScope($Scope, [ValidateRange(0, 5000)][int] $TimeoutMilliseconds = 5000) { if ($Scope.Closed) { return } $exitClock = [Diagnostics.Stopwatch]::StartNew() try { Invoke-ShellJob "Terminate" $Scope.Job - Invoke-ShellJob "Wait" $Scope.Job -TimeoutMilliseconds ([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) + Invoke-ShellJob "Wait" $Scope.Job -TimeoutMilliseconds ([Math]::Max(0, $TimeoutMilliseconds - [int]$exitClock.ElapsedMilliseconds)) if ((Invoke-ShellJob "Active" $Scope.Job) -ne 0) { throw "OWNED_CLEANUP_UNPROVEN: job not empty" } # Job accounting can reach zero before the original process objects signal. # Prove every held root within the same termination deadline before closing any. foreach ($run in $Scope.Runs) { if ($run.Finalized) { continue } if (-not $run.ScopeAssigned) { $run.Stop() } - $run.RequireExited([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) + $run.RequireExited([Math]::Max(0, $TimeoutMilliseconds - [int]$exitClock.ElapsedMilliseconds)) } foreach ($run in $Scope.Runs) { - Complete-ShellLaunch $run + Complete-ShellLaunch $run -TimeoutMilliseconds ([Math]::Max(0, $TimeoutMilliseconds - [int]$exitClock.ElapsedMilliseconds)) $run.Close() } foreach ($member in $Scope.Members) { $member.Dispose() } diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 index 4bc196b8..d84077f5 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 @@ -1,10 +1,84 @@ [CmdletBinding()] -param() +param( + [switch] $PlanOnly, + [string] $RepositoryRoot, + [string] $ArtifactRoot, + [string] $AssemblyPath, + [string] $NativeScriptUnderTest +) $ErrorActionPreference = "Stop" +$nativeScript = Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.ps1" +$parseErrors = $null +$nativeAst = [Management.Automation.Language.Parser]::ParseFile($nativeScript, [ref]$null, [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { throw "Native accessibility runner must parse" } +$planFunctions = @($nativeAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Get-AccessibilityProviderNativeTestPlan" +}, $true)) +if ($planFunctions.Count -ne 1) { throw "Native accessibility needs one shared compile plan" } +. ([scriptblock]::Create($planFunctions[0].Extent.Text)) +$planChecks = 0 +foreach ($headless in @($false, $true)) { + $plan = Get-AccessibilityProviderNativeTestPlan $headless + $expected = if ($headless) { + @("/DGC_UIA_HEADLESS_SELECTION", "", "oleaut32.lib user32.lib uuid.lib", + "Headless navigation selection: 179 checks, 0 failures") + } else { + @("", '"%GRAPHCODE_NATIVE_PROVIDER%"', "oleaut32.lib uiautomationcore.lib user32.lib", + "Native accessibility provider: 280 checks, 0 failures") + } + $actual = @($plan.definitions, $plan.providerSource, $plan.libraries, $plan.summary) + for ($index = 0; $index -lt $expected.Count; $index++) { + if ($actual[$index] -cne $expected[$index]) { throw "Wrong native compile plan: headless=$headless field=$index" } + $planChecks++ + } +} +$nativeSource = [IO.File]::ReadAllText($nativeScript) +foreach ($literal in @( + '.Replace("TEST_DEFINITIONS", $plan.definitions)', + 'Replace("PROVIDER_SOURCE", $plan.providerSource)', + 'Replace("TEST_LIBRARIES", $plan.libraries)', + '$_ -ceq $plan.summary', + 'if ($HeadlessSelection)', + 'GC_UIA_HEADLESS_SELECTION' +)) { + if (-not $nativeSource.Contains($literal)) { throw "Native compile plan is not enforced: $literal" } + $planChecks++ +} +Write-Host "Native accessibility compile plan: $planChecks checks, 0 failures (source/data only)" +if ($PlanOnly) { + if ($ArtifactRoot) { + Write-Output ("UI_CASE_RESULT=" + ([ordered]@{ + id = "ui.navigation.headless-runner-plan"; status = "Passed"; executedCount = $planChecks + evidence = "Actual native runner compile plan; source/data controls; compilerCalls=0; nativeCalls=0" + } | ConvertTo-Json -Compress)) + return + } + return $planChecks +} + +$repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +if (-not $AssemblyPath) { $AssemblyPath = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") } +& (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $AssemblyPath -RequiredType "GraphCode.Validation.Ownership.OwnedChildSupervisor" | Out-Null +foreach ($name in @("Invoke-AccessibilityProviderOwnedStep", "Get-AccessibilityProviderRemaining", + "Wait-AccessibilityProviderOwnershipRecovery", "Wait-AccessibilityProviderRetainedAuthority")) { + $found = @($nativeAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($found.Count -ne 1) { throw "Missing unique native supervision helper: $name" } + . ([scriptblock]::Create($found[0].Extent.Text)) +} +if (-not $NativeScriptUnderTest) { $NativeScriptUnderTest = $nativeScript } +$safetyCases = [Collections.Generic.List[string]]::new() $scratch = Join-Path ([IO.Path]::GetTempPath()) ("graphcode uia harness-" + [char]0xe9 + [guid]::NewGuid()) -function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath) { - $fixture = Join-Path $scratch $(if ($RepeatedPath) { "$Stage-repeated-path" } else { $Stage }) +function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath, [switch] $TimeoutMode, + [switch] $MetadataFailure, [switch] $FakeSummary) { + $caseName = if ($FakeSummary) { "fake-summary" } elseif ($MetadataFailure) { "timeout-metadata" } elseif ($TimeoutMode) { "timeout" } elseif ($RepeatedPath) { "$Stage-repeated-path" } else { $Stage } + $fixture = Join-Path $scratch $caseName $childTemp = Join-Path $fixture "temp" New-Item -ItemType Directory -Path $childTemp | Out-Null $vcvars = Join-Path $fixture "vcvars.cmd" @@ -18,16 +92,29 @@ function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath) { [IO.File]::WriteAllText($vcvars, $setup, [Text.Encoding]::ASCII) if ($Stage -ne "vcvars") { $tool = if ($Stage -eq "where-cl") { "where.cmd" } else { "cl.cmd" } - [IO.File]::WriteAllText((Join-Path $fixture $tool), "@echo off`r`nexit 255`r`n", [Text.Encoding]::ASCII) + $toolBody = "@echo off`r`nexit 255`r`n" + if ($TimeoutMode) { + $extra = if ($MetadataFailure) { 'mkdir native\supervision.json' + "`r`n" } else { "" } + $toolBody = "@echo off`r`n$extra`"$(Join-Path $PSHOME 'pwsh.exe')`" -NoProfile -Command `"[Console]::WriteLine('timeout-ready'); [Console]::Error.WriteLine('timeout-stderr'); Start-Sleep -Seconds 30`"`r`n" + } + if ($FakeSummary) { $toolBody = "@echo off`r`necho Native accessibility provider: 280 checks, 0 failures`r`nexit /b 0`r`n" } + [IO.File]::WriteAllText((Join-Path $fixture $tool), $toolBody, [Text.Encoding]::ASCII) } $wrapper = Join-Path $fixture "invoke.ps1" [IO.File]::WriteAllText($wrapper, @' -param([string] $NativeScript, [string] $VcVars) +param([string] $NativeScript, [string] $VcVars, [int] $CaseTimeout, [switch] $LegacyControl) $before = $env:PATH $result = 0 try { - & $NativeScript -VcVars $VcVars + if ($LegacyControl) { & $NativeScript -VcVars $VcVars } + else { & $NativeScript -VcVars $VcVars -TimeoutMilliseconds $CaseTimeout } } catch { + for ($errorNode = $_.Exception; $errorNode; $errorNode = $errorNode.InnerException) { + if ($errorNode.Data.Contains("NativeSupervisionReportFailure")) { + Write-Host "NATIVE_REPORT_FAILURE_RETAINED=True" + break + } + } Write-Error -ErrorRecord $_ -ErrorAction Continue $result = 1 } finally { @@ -38,34 +125,77 @@ exit $result '@, [Text.Encoding]::ASCII) $start = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh -ErrorAction Stop).Source) $start.UseShellExecute = $false + $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true + [Text.Encoding]::RegisterProvider([Text.CodePagesEncodingProvider]::Instance) + $wrapperEncoding = [Text.Encoding]::GetEncoding([ShellOwnedLaunch]::OemCodePage(), + [Text.EncoderFallback]::ExceptionFallback, [Text.DecoderFallback]::ExceptionFallback) + $start.StandardOutputEncoding = $wrapperEncoding + $start.StandardErrorEncoding = $wrapperEncoding foreach ($argument in @( "-NoProfile", "-File", $wrapper, "-NativeScript", - (Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.ps1"), - "-VcVars", $vcvars)) { + $NativeScriptUnderTest, "-VcVars", $vcvars, + "-CaseTimeout", $(if ($TimeoutMode) { "1000" } else { "10000" }))) { $start.ArgumentList.Add($argument) } + if ($NativeScriptUnderTest -cne $nativeScript) { $start.ArgumentList.Add("-LegacyControl") } $start.Environment["TEMP"] = $childTemp $start.Environment["TMP"] = $childTemp + $start.Environment["GRAPHCODE_VALIDATION_ASSEMBLY"] = $AssemblyPath if ($RepeatedPath) { $start.Environment["PATH"] = (@($fixture) + @($fixture.ToUpperInvariant()) * 99 + @("$env:SystemRoot\System32")) -join ";" } - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - try { - if (-not $process.Start()) { throw "Native harness child did not start" } - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit(30000)) { - $process.Kill($true) - $process.WaitForExit() - throw "Native harness launcher-only child timed out" + $execution = Invoke-AccessibilityProviderOwnedStep $start 30000 (Join-Path $fixture "outer-supervision") + $diagnosticPrefix = "NATIVE_UIA_DIAGNOSTIC_JSON=" + $output = (@(($execution.StandardOutput + $execution.StandardError) -split "\r?\n") | ForEach-Object { + if ($_.StartsWith($diagnosticPrefix, [StringComparison]::Ordinal)) { + $_.Substring($diagnosticPrefix.Length) | ConvertFrom-Json + } else { $_ } + }) -join "`n" + Write-Host ("NATIVE_HARNESS_CHILD_OUTPUT_JSON=" + ($output | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii)) + if ($execution.ExitCode -eq 0) { throw "RED: native harness masked $Stage failure" } + $retained = @(Get-ChildItem -LiteralPath $childTemp -Directory) + if ($retained.Count -ne 1) { throw "Native runner deleted or failed to retain its exact scratch after $caseName" } + $nativeEvidence = Join-Path $retained[0].FullName "native" + if ($FakeSummary) { + if ($output -notmatch "did not report exactly one compile/test execution marker" -or + $output -match "\[native-uia\] stage=test-exe start") { + throw "Fake positive summary without actual fixture execution was accepted" + } + $proof = Get-Content (Join-Path $nativeEvidence "supervision.json") -Raw | ConvertFrom-Json + if ($proof.exitCode -ne 0 -or -not $proof.cleanup.ResourcesClosed) { throw "Fake-summary control lost its real host result" } + $safetyCases.Add($caseName) + return + } + if ($TimeoutMode) { + if ($output -notmatch "OWNED_CAPTURE_EXIT: original deadline exceeded" -or + $output -match "\[native-uia\] stage=test-exe start") { + throw "Native timeout was masked or continued into the fixture" + } + $raw = @(Get-ChildItem -LiteralPath $nativeEvidence -Filter "*-stdout.log") + $rawError = @(Get-ChildItem -LiteralPath $nativeEvidence -Filter "*-stderr.log") + if ($raw.Count -ne 1 -or $rawError.Count -ne 1 -or + -not [IO.File]::ReadAllText($raw[0].FullName).Contains("timeout-ready") -or + -not [IO.File]::ReadAllText($rawError[0].FullName).Contains("timeout-stderr")) { + throw "Native timeout lost independent partial output" + } + if ($MetadataFailure) { + if (-not (Test-Path -LiteralPath (Join-Path $nativeEvidence "supervision.json") -PathType Container)) { + throw "Metadata failure control did not reach its actual collision" + } + if ($output -notmatch "NATIVE_REPORT_FAILURE_RETAINED=True") { throw "Metadata write failure replaced or lost its primary diagnostic" } + } else { + $proof = Get-Content (Join-Path $nativeEvidence "supervision.json") -Raw | ConvertFrom-Json + if (-not $proof.failed -or -not $proof.cleanup.RootExited -or -not $proof.cleanup.JobEmpty -or + -not $proof.cleanup.StdoutEof -or -not $proof.cleanup.StderrEof -or -not $proof.cleanup.ResourcesClosed) { + throw "Timed-out native host did not prove original job/root/pipe cleanup" + } + } + $safetyCases.Add($caseName) + return } - $output = $stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult() - Write-Host $output - if ($process.ExitCode -eq 0) { throw "RED: native harness masked $Stage failure" } if ($output -notmatch "\[native-uia\] stage=$Stage start" -or $output -notmatch '\[native-uia\] cmd exit=255') { throw "RED: native harness lost the actual $Stage command exit" @@ -77,8 +207,15 @@ exit $result ($Stage -ne "compile" -and $output -match '\[native-uia\] stage=compile start')) { throw "RED: native harness continued after abrupt $Stage failure" } - if (@(Get-ChildItem -LiteralPath $childTemp -Force).Count -ne 0) { - throw "RED: native harness left owned native scratch after $Stage failure" + $proof = Get-Content (Join-Path $nativeEvidence "supervision.json") -Raw | ConvertFrom-Json + if ($proof.exitCode -ne 255 -or -not $proof.cleanup.RootExited -or -not $proof.cleanup.JobEmpty -or + -not $proof.cleanup.StdoutEof -or -not $proof.cleanup.StderrEof -or -not $proof.cleanup.ResourcesClosed) { + throw "Native mock failure did not retain exact exit and successful held cleanup proof" + } + if ($proof.stdoutCodePage -ne [ShellOwnedLaunch]::OemCodePage() -or + $proof.stderrCodePage -ne $proof.stdoutCodePage) { throw "cmd producer encodings were not bound explicitly" } + if ($Stage -ceq "compile" -and -not $output.Contains((Join-Path $fixture "cl.cmd"))) { + throw "Controlled non-ASCII compiler path did not roundtrip through cmd's actual OEM codec" } if ($output -notmatch '\[native-harness\] original PATH restored') { throw "RED: native harness did not restore the exact original PATH after $Stage failure" @@ -92,9 +229,7 @@ exit $result throw "RED: native harness did not preserve case-insensitive first-occurrence PATH order" } } - } finally { - $process.Dispose() - } + $safetyCases.Add($caseName) } try { foreach ($stage in @("vcvars", "where-cl", "compile")) { @@ -102,6 +237,117 @@ try { } Test-AbruptStage "compile" -RepeatedPath Write-Host "Native accessibility harness: 25 checks, 0 failures (mock tools; no native fixture)" + Test-AbruptStage "compile" -FakeSummary + Test-AbruptStage "compile" -TimeoutMode + Test-AbruptStage "compile" -TimeoutMode -MetadataFailure + $clock = [Diagnostics.Stopwatch]::StartNew() + $first = Get-AccessibilityProviderRemaining $clock 1000 + [Threading.Thread]::Sleep(20) + $second = Get-AccessibilityProviderRemaining $clock 1000 + if ($first -le 0 -or $second -le 0 -or $second -ge $first) { throw "Original case budget was reset across steps" } + $safetyCases.Add("shared-budget") + $refused = $false + try { $null = Get-AccessibilityProviderRemaining $clock 1 } catch [TimeoutException] { $refused = $true } + if (-not $refused) { throw "Expired original budget did not refuse another launch" } + $safetyCases.Add("expired-budget") + $info = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh).Source) + $info.UseShellExecute = $false + $refused = $false + try { $null = Invoke-AccessibilityProviderOwnedStep $info 0 (Join-Path $scratch "must-not-create") } catch [TimeoutException] { $refused = $true } + if (-not $refused -or (Test-Path -LiteralPath (Join-Path $scratch "must-not-create"))) { throw "Zero-budget step acquired resources" } + $safetyCases.Add("zero-budget") + & { + $state = @{ waits = 0; signals = 0 } + function Wait-AccessibilityProviderOwnershipRecovery { $state.waits++; throw "controlled retained authority" } + function Write-Host($Object) { + if ([string]$Object -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") { throw "Unexpected retention signal" } + $state.signals++ + } + Wait-AccessibilityProviderRetainedAuthority 0 + if ($state.waits -ne 0 -or $state.signals -ne 0) { throw "Zero retained leases entered the passive barrier" } + $safetyCases.Add("zero-retention") + $failure = $null + try { Wait-AccessibilityProviderRetainedAuthority 1 } catch { $failure = $_ } + if (-not $failure -or $failure.Exception.Message -cne "controlled retained authority" -or + $state.waits -ne 1 -or $state.signals -ne 1) { throw "UNKNOWN did not signal and enter passive original-authority retention" } + $safetyCases.Add("unknown-retention") + $state.waits = 0 + function Write-Host($Object) { throw "controlled diagnostic failure" } + try { Wait-AccessibilityProviderRetainedAuthority 1 } catch { $failure = $_ } + if ($state.waits -ne 1) { throw "Diagnostic failure bypassed passive original-authority retention" } + $safetyCases.Add("unknown-diagnostic-failure") + } + $invalidInfo = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh).Source) + $invalidInfo.UseShellExecute = $false + $invalidInfo.CreateNoWindow = $true + $invalidInfo.RedirectStandardOutput = $true + $invalidInfo.RedirectStandardError = $true + $invalidInfo.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + $invalidInfo.StandardErrorEncoding = [Text.UTF8Encoding]::new($false, $true) + $invalidScript = '[Console]::OpenStandardOutput().Write([byte[]]@(255),0,1)' + foreach ($argument in @("-NoProfile", "-EncodedCommand", [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($invalidScript)))) { + $invalidInfo.ArgumentList.Add($argument) + } + $invalidRoot = Join-Path $scratch "invalid-utf8" + $failure = $null + try { $null = Invoke-AccessibilityProviderOwnedStep $invalidInfo 10000 $invalidRoot } catch { $failure = $_ } + $raw = @(Get-ChildItem -LiteralPath $invalidRoot -Filter "*-stdout.log") + $proof = Get-Content (Join-Path $invalidRoot "supervision.json") -Raw | ConvertFrom-Json + if (-not $failure -or $failure.Exception.Message -notmatch "OWNED_CAPTURE_DRAIN" -or $raw.Count -ne 1 -or + ([IO.File]::ReadAllBytes($raw[0].FullName) -join ",") -cne "255" -or + $proof.stdoutCodePage -ne 65001 -or -not $proof.failed -or -not $proof.cleanup.ResourcesClosed) { + throw "Invalid-byte control was recoded, silently replaced or lost authoritative raw evidence" + } + $safetyCases.Add("invalid-byte-refusal") + $missing = [Diagnostics.ProcessStartInfo]::new((Join-Path $scratch "missing.exe")) + $missing.UseShellExecute = $false + $failure = $null + $missingRoot = Join-Path $scratch "missing-executable" + try { $null = Invoke-AccessibilityProviderOwnedStep $missing 1000 $missingRoot } catch { $failure = $_ } + $proof = Get-Content (Join-Path $missingRoot "supervision.json") -Raw | ConvertFrom-Json + if (-not $failure -or -not $proof.failed -or $null -ne $proof.cleanup -or $proof.retainedLeaseCount -ne 0) { + throw "Missing executable did not preserve explicit unavailable cleanup evidence without inventing success" + } + $safetyCases.Add("missing-executable") + if ([regex]::Matches($nativeSource, [regex]::Escape('(Get-AccessibilityProviderRemaining $caseClock $TimeoutMilliseconds)')).Count -ne 2) { + throw "Discovery and compile/test must use the same original clock/budget" + } + $pairClock = [Diagnostics.Stopwatch]::StartNew() + $pairInfo = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh).Source) + $pairInfo.UseShellExecute = $false + $pairInfo.CreateNoWindow = $true + $pairInfo.RedirectStandardOutput = $true + $pairInfo.RedirectStandardError = $true + foreach ($argument in @("-NoProfile", "-Command", "[Console]::WriteLine('stage-one')")) { $pairInfo.ArgumentList.Add($argument) } + $firstStep = Invoke-AccessibilityProviderOwnedStep $pairInfo (Get-AccessibilityProviderRemaining $pairClock 2000) ` + (Join-Path $scratch "pair-first") + if ($firstStep.ExitCode -ne 0 -or -not $firstStep.StandardOutput.Contains("stage-one")) { throw "First original-budget step did not execute" } + $pairInfo.ArgumentList.Clear() + foreach ($argument in @("-NoProfile", "-Command", "Start-Sleep -Seconds 10")) { $pairInfo.ArgumentList.Add($argument) } + $failure = $null + $pairSecond = Join-Path $scratch "pair-second" + try { $null = Invoke-AccessibilityProviderOwnedStep $pairInfo (Get-AccessibilityProviderRemaining $pairClock 2000) $pairSecond } + catch { $failure = $_ } + $proof = Get-Content (Join-Path $pairSecond "supervision.json") -Raw | ConvertFrom-Json + if (-not $failure -or $failure.Exception.Message -notmatch "OWNED_CAPTURE_EXIT" -or + $proof.timeoutMilliseconds -ge 2000 -or $proof.launchBudgetMilliseconds -gt $proof.timeoutMilliseconds -or + -not $proof.failed -or -not $proof.cleanup.ResourcesClosed) { + throw "Second actual supervised step refreshed its original total budget or lost cleanup proof" + } + $safetyCases.Add("two-step-original-budget") + if ($safetyCases.Count -ne 16 -or @($safetyCases | Sort-Object -Unique).Count -ne 16) { throw "Native safety case inventory differs" } + Write-Host "Native accessibility runner safety: 16 cases, 0 failures" + if ($ArtifactRoot) { + Write-Output ("UI_CASE_RESULT=" + ([ordered]@{ + id = "ui.navigation.native-runner-safety"; status = "Passed"; executedCount = 16 + groups = $safetyCases.ToArray() + evidence = "Actual existing runner/import/supervised invocation; controlled failing and timed-out tools; no C++ fixture or UIA entrypoints" + } | ConvertTo-Json -Compress)) + } } finally { - Remove-Item -LiteralPath $scratch -Recurse -Force + try { Write-Host ("NATIVE_HARNESS_RETAINED_SCRATCH_JSON=" + ($scratch | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii)) } + finally { + Wait-AccessibilityProviderRetainedAuthority ` + ([GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount) + } } diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 index 20d8e0d7..8e099a80 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 @@ -1,26 +1,151 @@ [CmdletBinding()] -param([string] $VcVars) +param( + [string] $VcVars, + [switch] $HeadlessSelection, + [ValidateRange(1, 600000)][int] $TimeoutMilliseconds = 120000 +) $ErrorActionPreference = "Stop" -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path -if (-not $VcVars) { - $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" - if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio vswhere is required" } - $installation = & $vswhere -latest -products '*' ` - -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - if ($LASTEXITCODE -ne 0 -or -not $installation) { throw "Visual C++ build tools are required" } - $VcVars = Join-Path $installation "VC\Auxiliary\Build\vcvars64.bat" +$caseClock = [Diagnostics.Stopwatch]::StartNew() +function Write-AccessibilityProviderDiagnostic([string] $Line) { + if ($Line -cmatch '[^\x00-\x7f]') { + Write-Host ("NATIVE_UIA_DIAGNOSTIC_JSON=" + ($Line | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii)) + } else { Write-Host $Line } +} +function Get-AccessibilityProviderRemaining([Diagnostics.Stopwatch] $Clock, [int] $Budget) { + $remaining = $Budget - [int]$Clock.ElapsedMilliseconds + if ($remaining -le 0) { throw [TimeoutException]::new("Native accessibility provider original case deadline exceeded") } + return $remaining } -if (-not (Test-Path -LiteralPath $VcVars)) { throw "Missing Visual C++ setup: $VcVars" } +function Invoke-AccessibilityProviderOwnedStep( + [Diagnostics.ProcessStartInfo] $StartInfo, + [int] $TimeoutMilliseconds, + [string] $ArtifactRoot +) { + $stepClock = [Diagnostics.Stopwatch]::StartNew() + if ($TimeoutMilliseconds -le 0) { throw [TimeoutException]::new("Native accessibility provider has no launch budget") } + $null = New-Item -ItemType Directory -Path $ArtifactRoot + $result = $null + $failure = $null + $launchBudget = $null + try { + $launchBudget = $TimeoutMilliseconds - [int]$stepClock.ElapsedMilliseconds + if ($launchBudget -le 0) { throw [TimeoutException]::new("Native accessibility provider step exhausted its original budget before launch") } + $result = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RunAsync( + $StartInfo, [TimeSpan]::FromMilliseconds($launchBudget), $ArtifactRoot, + [Threading.CancellationToken]::None).GetAwaiter().GetResult() + } catch { + $failure = $_ + throw + } finally { + $proof = if ($result) { $result.Cleanup } else { $null } + if ($failure) { + for ($exception = $failure.Exception; $exception; $exception = $exception.InnerException) { + if ($exception.Data.Contains("OwnedCleanupProof")) { $proof = $exception.Data["OwnedCleanupProof"]; break } + } + } + $record = [ordered]@{ + stage = Split-Path -Leaf $ArtifactRoot + executable = $StartInfo.FileName + timeoutMilliseconds = $TimeoutMilliseconds + launchBudgetMilliseconds = $launchBudget + exitCode = $(if ($result) { $result.ExitCode } else { $null }) + failed = $null -ne $failure + failureType = $(if ($failure) { $failure.Exception.GetBaseException().GetType().FullName } else { $null }) + stdoutCodePage = $(if ($StartInfo.StandardOutputEncoding) { $StartInfo.StandardOutputEncoding.CodePage } else { $null }) + stderrCodePage = $(if ($StartInfo.StandardErrorEncoding) { $StartInfo.StandardErrorEncoding.CodePage } else { $null }) + cleanup = $proof + retainedLeaseCount = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount + } + try { + [IO.File]::WriteAllText((Join-Path $ArtifactRoot "supervision.json"), + ($record | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false)) + } catch { + if ($failure) { $failure.Exception.Data["NativeSupervisionReportFailure"] = $_.Exception } + else { throw } + } + } + return $result +} + +function Wait-AccessibilityProviderOwnershipRecovery { + [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) +} + +function Wait-AccessibilityProviderRetainedAuthority([int] $RetainedLeaseCount) { + if ($RetainedLeaseCount -eq 0) { return } + try { Write-Host "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" } + finally { Wait-AccessibilityProviderOwnershipRecovery } +} + +function Get-AccessibilityProviderNativeTestPlan([bool] $HeadlessSelection) { + if ($HeadlessSelection) { + return [pscustomobject]@{ + definitions = "/DGC_UIA_HEADLESS_SELECTION" + providerSource = "" + libraries = "oleaut32.lib user32.lib uuid.lib" + summary = "Headless navigation selection: 179 checks, 0 failures" + } + } + return [pscustomobject]@{ + definitions = "" + providerSource = '"%GRAPHCODE_NATIVE_PROVIDER%"' + libraries = "oleaut32.lib uiautomationcore.lib user32.lib" + summary = "Native accessibility provider: 280 checks, 0 failures" + } +} +$plan = Get-AccessibilityProviderNativeTestPlan ([bool]$HeadlessSelection) +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$test = Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.cpp" +if ($HeadlessSelection) { + $testSource = [IO.File]::ReadAllText($test) + if ($testSource -notmatch '(?m)^#if(?:def\s+GC_UIA_HEADLESS_SELECTION|[ \t]+defined\s*\(\s*GC_UIA_HEADLESS_SELECTION\s*\))' -or + -not $testSource.Contains("Headless navigation selection:")) { + throw "Headless selection requires the intercepted C++ test branch; the legacy native entry will not run" + } +} $scratch = Join-Path ([IO.Path]::GetTempPath()) ("graphcode-uia-native-" + [guid]::NewGuid()) $savedTemp = $env:TEMP $savedTmp = $env:TMP $savedPath = $env:PATH New-Item -ItemType Directory -Path $scratch | Out-Null +$bindingsLoaded = $false try { + $assemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY + if (-not $assemblyPath) { + $assemblyPath = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") + } + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assemblyPath -RequiredType "GraphCode.Validation.Ownership.OwnedChildSupervisor" | Out-Null + $bindingsLoaded = $true $env:TEMP = $scratch $env:TMP = $scratch + if (-not $VcVars) { + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio vswhere is required" } + $discovery = [Diagnostics.ProcessStartInfo]::new($vswhere) + $discovery.UseShellExecute = $false + $discovery.CreateNoWindow = $true + $discovery.WorkingDirectory = $scratch + $discovery.RedirectStandardOutput = $true + $discovery.RedirectStandardError = $true + $discovery.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + $discovery.StandardErrorEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in @("-latest", "-products", "*", "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath", "-utf8")) { + $discovery.ArgumentList.Add($argument) + } + $discovered = Invoke-AccessibilityProviderOwnedStep $discovery ` + (Get-AccessibilityProviderRemaining $caseClock $TimeoutMilliseconds) (Join-Path $scratch "discovery") + $installations = @($discovered.StandardOutput -split "\r?\n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($discovered.ExitCode -ne 0 -or $installations.Count -ne 1) { throw "Visual C++ build tools discovery did not return one installation" } + $VcVars = Join-Path $installations[0] "VC\Auxiliary\Build\vcvars64.bat" + } + if (-not (Test-Path -LiteralPath $VcVars)) { throw "Missing Visual C++ setup: $VcVars" } + foreach ($path in @($VcVars, $test, (Join-Path $repoRoot "graphcode-windows\src\AccessibilityProvider.cpp"))) { + if ($path -match '["%!\r\n]') { throw "Native accessibility compiler paths must be batch-safe" } + } $installer = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer" $pathEntries = @($savedPath -split ";") if (Test-Path -LiteralPath $installer) { $pathEntries = @($installer) + $pathEntries } @@ -30,25 +155,24 @@ try { if ($seenPaths.Add($entry)) { $entry } }) $env:PATH = $uniquePaths -join ";" - Write-Host "[native-uia] PATH entries=$($pathEntries.Count) -> $($uniquePaths.Count); length=$($savedPath.Length) -> $($env:PATH.Length)" + Write-AccessibilityProviderDiagnostic "[native-uia] PATH entries=$($pathEntries.Count) -> $($uniquePaths.Count); length=$($savedPath.Length) -> $($env:PATH.Length)" if ($env:PATH.Length -ge 8191) { throw "Native accessibility provider deduplicated PATH is still too long for cmd: $($env:PATH.Length)" } - $test = Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.cpp" $provider = Join-Path $repoRoot "graphcode-windows\src\AccessibilityProvider.cpp" # Change directory inside cmd only: a Push-Location here becomes this process's # working directory at the native call and then locks the scratch folder. # vcvars can exit non-zero when an optional extension (for example the Linux # ConnectionManager) is absent even though the MSVC environment is usable, so # require a resolvable cl instead of trusting its exit code. - Write-Host "[native-uia] vcvars path: $VcVars" + Write-AccessibilityProviderDiagnostic "[native-uia] vcvars path: $VcVars" # Keep batch source ASCII; pass Unicode paths through cmd arguments. $command = @' @echo off cd /d "%~dp0" if errorlevel 1 exit /b %errorlevel% echo [native-uia] stage=vcvars start -call "%~1" +call "%GRAPHCODE_NATIVE_VCVARS%" set "vcvars_exit=%errorlevel%" echo [native-uia] stage=vcvars exit=%vcvars_exit% "%ComSpec%" /d /u /c set PATH >vcvars-path.txt @@ -58,7 +182,7 @@ set "where_exit=%errorlevel%" echo [native-uia] stage=where-cl exit=%where_exit% if not "%where_exit%"=="0" exit /b %where_exit% echo [native-uia] stage=compile start -cl /nologo /EHsc /std:c++17 /Fe:uia-native.exe "%~2" "%~3" oleaut32.lib uiautomationcore.lib user32.lib +cl /nologo /EHsc /std:c++17 TEST_DEFINITIONS /Fe:uia-native.exe "%GRAPHCODE_NATIVE_TEST%" PROVIDER_SOURCE TEST_LIBRARIES set "compile_exit=%errorlevel%" echo [native-uia] stage=compile exit=%compile_exit% if not "%compile_exit%"=="0" exit /b %compile_exit% @@ -68,47 +192,66 @@ set "test_exit=%errorlevel%" echo [native-uia] stage=test-exe exit=%test_exit% exit /b %test_exit% '@ + $command = $command.Replace("TEST_DEFINITIONS", $plan.definitions). + Replace("PROVIDER_SOURCE", $plan.providerSource). + Replace("TEST_LIBRARIES", $plan.libraries) $commandPath = Join-Path $scratch "run-native.cmd" [IO.File]::WriteAllText($commandPath, $command.Replace("`r`n", "`n").Replace("`n", "`r`n"), [Text.Encoding]::ASCII) - $arguments = 'call "{0}" "{1}" "{2}" "{3}"' -f $commandPath, $VcVars, $test, $provider + $native = [Diagnostics.ProcessStartInfo]::new($env:ComSpec) + $native.UseShellExecute = $false + $native.CreateNoWindow = $true + $native.WorkingDirectory = $scratch + $native.RedirectStandardOutput = $true + $native.RedirectStandardError = $true + [Text.Encoding]::RegisterProvider([Text.CodePagesEncodingProvider]::Instance) + $oemEncoding = [Text.Encoding]::GetEncoding([ShellOwnedLaunch]::OemCodePage(), + [Text.EncoderFallback]::ExceptionFallback, [Text.DecoderFallback]::ExceptionFallback) + $native.StandardOutputEncoding = $oemEncoding + $native.StandardErrorEncoding = $oemEncoding + foreach ($argument in @("/d", "/s", "/c", "run-native.cmd")) { $native.ArgumentList.Add($argument) } + $native.Environment["GRAPHCODE_NATIVE_VCVARS"] = [IO.Path]::GetFullPath($VcVars) + $native.Environment["GRAPHCODE_NATIVE_TEST"] = $test + $native.Environment["GRAPHCODE_NATIVE_PROVIDER"] = $provider $nativeStage = "cmd-startup" - $nativeOutput = @(& $env:ComSpec /d /s /c $arguments 2>&1 | ForEach-Object { - $line = "$_" + $nativeResult = Invoke-AccessibilityProviderOwnedStep $native ` + (Get-AccessibilityProviderRemaining $caseClock $TimeoutMilliseconds) (Join-Path $scratch "native") + $nativeOutput = @(@($nativeResult.StandardOutput -split "\r?\n") + @($nativeResult.StandardError -split "\r?\n")) + foreach ($line in $nativeOutput) { if ($line -match '^\[native-uia\] stage=([a-z-]+) start$') { $nativeStage = $Matches[1] } - Write-Host $line - $line - }) - $nativeExit = $LASTEXITCODE - Write-Host "[native-uia] cmd exit=$nativeExit" + Write-AccessibilityProviderDiagnostic $line + } + $nativeExit = $nativeResult.ExitCode + Write-AccessibilityProviderDiagnostic "[native-uia] cmd exit=$nativeExit" if ($nativeExit -ne 0) { throw "Native accessibility provider stage '$nativeStage' failed: $nativeExit" } $setupPath = @(Get-Content -LiteralPath (Join-Path $scratch "vcvars-path.txt") -Encoding Unicode | Where-Object { $_ -match '^PATH=' }) if ($setupPath.Count -ne 1) { throw "Native accessibility provider did not record one post-vcvars PATH" } $setupPathLength = $setupPath[0].Substring(5).Length - Write-Host "[native-uia] post-vcvars PATH length=$setupPathLength (cmd limit=8191)" + Write-AccessibilityProviderDiagnostic "[native-uia] post-vcvars PATH length=$setupPathLength (cmd limit=8191)" if ($setupPathLength -ge 8191) { throw "Native accessibility provider post-vcvars PATH exceeds cmd limit: $setupPathLength" } - if (@($nativeOutput | Where-Object { $_ -eq "Native accessibility provider: 280 checks, 0 failures" }).Count -ne 1) { - throw "Native accessibility provider test-exe did not report exactly one 280-check, zero-failure summary" + foreach ($marker in @("[native-uia] stage=compile exit=0", "[native-uia] stage=test-exe start", + "[native-uia] stage=test-exe exit=0")) { + if (@($nativeOutput | Where-Object { $_ -ceq $marker }).Count -ne 1) { + throw "Native accessibility provider did not report exactly one compile/test execution marker: $marker" + } + } + if (@($nativeOutput | Where-Object { $_ -ceq $plan.summary }).Count -ne 1) { + throw "Native accessibility provider test-exe did not report exactly one expected summary: $($plan.summary)" } } finally { $env:TEMP = $savedTemp $env:TMP = $savedTmp $env:PATH = $savedPath - # The just-built test executable and compiler outputs can stay locked briefly - # (for example by an antivirus scan) after the process exits. Cleanup must not - # turn a passing run into a failure, so retry and then only warn. - for ($attempt = 1; Test-Path -LiteralPath $scratch; $attempt++) { - try { - Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop - } catch { - if ($attempt -ge 10) { - Write-Warning "Native accessibility test scratch was not removed: $scratch ($($_.Exception.Message))" - break - } - Start-Sleep -Milliseconds 500 + try { + Write-AccessibilityProviderDiagnostic "NATIVE_ACCESSIBILITY_RETAINED_SCRATCH=$scratch" + if ($HeadlessSelection) { Write-AccessibilityProviderDiagnostic "HEADLESS_SELECTION_RETAINED_SCRATCH=$scratch" } + } finally { + if ($bindingsLoaded) { + Wait-AccessibilityProviderRetainedAuthority ` + ([GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount) } } } diff --git a/Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 b/Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 new file mode 100644 index 00000000..711d1e07 --- /dev/null +++ b/Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 @@ -0,0 +1,264 @@ +[CmdletBinding()] +param( + [string] $RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path, + [Parameter(Mandatory)][string] $ArtifactRoot, + [string] $AssemblyPath, + [switch] $MockOnly +) + +$ErrorActionPreference = "Stop" +$ArtifactRoot = Join-Path $ArtifactRoot ("uia-held-" + [guid]::NewGuid().ToString("N")) +if (Test-Path -LiteralPath $ArtifactRoot) { throw "Held-launch contracts require a fresh artifact root" } +$null = New-Item -ItemType Directory -Path $ArtifactRoot +$gate = Join-Path $RepositoryRoot "Tools\windows\uia-live-gate.ps1" +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile($gate, [ref]$null, [ref]$errors) +if ($errors.Count) { throw "UIA launch source must parse" } +$definitions = @{} +foreach ($name in @("Start-UiaOwnedProcess", "Complete-UiaHeldCapture", "Register-UiaHeldProcess", + "Complete-MultiProjectCapture", "Stop-UiaOwnedProcessTrees", "New-UiaOwnershipFailure")) { + $found = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($found.Count -eq 1) { $definitions[$name] = $found[0].Extent.Text } +} +if (-not $definitions.ContainsKey("Start-UiaOwnedProcess")) { throw "Actual default UIA launch entry is missing" } +. ([scriptblock]::Create($definitions["Start-UiaOwnedProcess"])) +if ($definitions.ContainsKey("Complete-UiaHeldCapture")) { + . ([scriptblock]::Create($definitions["Complete-UiaHeldCapture"])) +} +foreach ($name in @("Complete-MultiProjectCapture", "Stop-UiaOwnedProcessTrees", "New-UiaOwnershipFailure")) { + . ([scriptblock]::Create($definitions[$name])) +} +$results = [Collections.Generic.List[object]]::new() +function Assert-Held([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw $Message } +} +function Read-HeldLog([string] $Path) { + return [string](Get-Content -LiteralPath $Path -Raw) +} +function Invoke-HeldCase([string] $Name, [scriptblock] $Action) { + $failure = $null + try { & $Action } catch { $failure = $_ } + if ($Name.StartsWith("actual-", [StringComparison]::Ordinal) -and $failure -and + (Test-ShellOwnershipUnproven $failure)) { + try { Write-Host "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" } + finally { [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) } + } + $results.Add([pscustomobject]@{ + name = $Name; passed = $null -eq $failure + error = $(if ($failure) { $failure.ToString() + "`n" + $failure.ScriptStackTrace } else { "" }) + }) +} +function Get-Process { throw "PID reopening must not occur in the default launch entry" } +function Start-MultiProjectOwnedProcess { throw "Legacy PID-file launcher was invoked" } +function New-ShellProcessScope { + $state.events.Add("scope") + return [pscustomobject]@{ Closed = $false } +} +function Start-ShellOwnedProcess($Scope, $StartInfo, $StandardOutputWriter, $StandardErrorWriter) { + $state.events.Add("start") + $state.info = $StartInfo + $state.output = $StandardOutputWriter + $state.error = $StandardErrorWriter + Assert-Held ($script:UiaGateOwnedCaptures.Count -eq 1) "Scope was not registered before launch" + if ($state.startFailure) { throw $state.startFailure } + return $state.process +} +function Register-UiaHeldProcess($Process) { + $state.events.Add("register") + Assert-Held ([object]::ReferenceEquals($Process, $state.process)) "Registration adopted a different process" + if ($state.registerFailure) { throw $state.registerFailure } +} +function Close-ShellProcessScope($Scope, [int] $TimeoutMilliseconds) { + $state.events.Add("close") + $state.closeBudget = $TimeoutMilliseconds + if ($state.closeFailure) { throw $state.closeFailure } + $Scope.Closed = $true +} +function Test-ShellOwnershipUnproven($Failure) { + $exception = if ($Failure -is [Management.Automation.ErrorRecord]) { $Failure.Exception } else { $Failure } + return $exception.Data["GraphCodeValidationRootRetentionRequired"] -eq $true +} +function Set-ShellOwnershipUnproven([Exception] $Exception) { + $Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + return $Exception +} + +$mockCases = @("original-reference", "hidden", "derived-output", "output-collision", + "error-collision", "start-failure", "registration-failure", "unknown-start", + "unknown-close", "unknown-no-retry", "streaming", "clean-close", "cleanup-budget") +foreach ($case in $mockCases) { + Invoke-HeldCase $case { + $caseRoot = Join-Path $ArtifactRoot $case + $null = New-Item -ItemType Directory -Path $caseRoot + $out = Join-Path $caseRoot "shell-stdout.log" + $err = Join-Path $caseRoot "shell-stderr.log" + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + $state = @{ + events = [Collections.Generic.List[string]]::new(); process = [Diagnostics.Process]::new() + startFailure = $null; registerFailure = $null; closeFailure = $null + } + $primary = [IO.IOException]::new("original controlled failure") + if ($case -cin @("start-failure", "unknown-start")) { $state.startFailure = $primary } + if ($case -ceq "registration-failure") { $state.registerFailure = $primary } + if ($case -ceq "unknown-start") { $primary.Data["GraphCodeValidationRootRetentionRequired"] = $true } + if ($case -cin @("unknown-close", "unknown-no-retry")) { $state.closeFailure = $primary } + if ($case -ceq "output-collision") { [IO.File]::WriteAllText($out, "retained") } + if ($case -ceq "error-collision") { [IO.File]::WriteAllText($err, "retained") } + $failure = $null + $actual = $null + try { + $actual = Start-UiaOwnedProcess -file "C:\controlled\fixture.exe" ` + -arguments @("space value", "quote`"value", "unicode-$([char]0x3bb)") ` + -standardOutputPath $(if ($case -ceq "derived-output") { "" } else { $out }) ` + -standardErrorPath $err -Hidden:($case -ceq "hidden") + } catch { $failure = $_ } + if ($case -cin @("output-collision", "error-collision")) { + Assert-Held ($null -ne $failure -and $failure.Exception.GetBaseException() -is [IO.IOException] -and + -not $state.events.Contains("start")) "Output collision launched a target or failed for an unrelated reason" + $collision = if ($case -ceq "output-collision") { $out } else { $err } + Assert-Held ([IO.File]::ReadAllText($collision) -ceq "retained") "Output collision overwrote retained bytes" + return + } + if ($case -cin @("start-failure", "registration-failure", "unknown-start")) { + Assert-Held ($failure -and [object]::ReferenceEquals($failure.Exception, $primary)) "Launch replaced its primary failure" + Assert-Held ($state.events.Contains("close") -eq ($case -cne "unknown-start")) "Wrong failed-launch custody release" + if ($case -ceq "unknown-start") { + Assert-Held (-not $script:UiaGateOwnedCaptures[0].cleaned) "Unknown startup discarded its registered scope" + $state.output.Write("retained-after-start-failure") + } + return + } + Assert-Held (-not $failure -and [object]::ReferenceEquals($actual, $state.process)) "Default launch did not return its original held process" + Assert-Held (($state.info.ArgumentList -join "|") -ceq "space value|quote`"value|unicode-$([char]0x3bb)") "Argument binding changed" + Assert-Held ($state.info.WorkingDirectory -ceq (Get-Location).ProviderPath -and + -not $state.info.UseShellExecute -and $state.info.RedirectStandardOutput -and + $state.info.RedirectStandardError -and $state.info.CreateNoWindow -eq ($case -ceq "hidden")) "Launch settings changed" + Assert-Held (($state.events -join ",") -ceq "scope,start,register") "Wrong launch registration order" + if ($case -ceq "streaming") { + $state.output.Write("stdout-live") + $state.error.Write("stderr-live") + Assert-Held ((Read-HeldLog $out) -ceq "stdout-live" -and + (Read-HeldLog $err) -ceq "stderr-live") "Logs were not visible while the target was live" + } + $capture = $script:UiaGateOwnedCaptures[0] + $cleanupFailure = $null + try { + if ($case -ceq "cleanup-budget") { $null = Complete-MultiProjectCapture $capture $caseRoot 137 } + else { $null = Complete-UiaHeldCapture $capture } + } catch { $cleanupFailure = $_ } + if ($case -cin @("unknown-close", "unknown-no-retry")) { + Assert-Held ($cleanupFailure -and (Test-ShellOwnershipUnproven $cleanupFailure) -and -not $capture.cleaned) "Unknown teardown released custody" + $state.output.Write("retained-writer") + if ($case -ceq "unknown-no-retry") { + $repeatFailure = $null + try { $null = Complete-UiaHeldCapture $capture } catch { $repeatFailure = $_ } + Assert-Held ($repeatFailure -and + [object]::ReferenceEquals($repeatFailure.Exception, $cleanupFailure.Exception) -and + @($state.events | Where-Object { $_ -ceq "close" }).Count -eq 1) "Unknown teardown was retried or returned success" + } + } else { + Assert-Held (-not $cleanupFailure -and $capture.cleaned -and $capture.scope.Closed) "Clean teardown did not finalize scope" + Assert-Held ($state.closeBudget -eq $(if ($case -ceq "cleanup-budget") { 137 } else { 5000 })) "Cleanup dispatch reset its original budget" + $disposed = $false + try { $state.output.Write("after-close") } catch [ObjectDisposedException] { $disposed = $true } + Assert-Held $disposed "Output writer survived verified cleanup" + } + } +} + +if (-not $MockOnly -and @($results | Where-Object { -not $_.passed }).Count -eq 0) { + Import-Module (Join-Path $RepositoryRoot "Tools\windows\ShellProcessOwnership.psm1") -Force + $env:GRAPHCODE_VALIDATION_ASSEMBLY = $AssemblyPath + & (Join-Path $RepositoryRoot "Tools\windows\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $AssemblyPath -RequiredType "ShellOwnedLaunch" | Out-Null + . ([scriptblock]::Create($definitions["Register-UiaHeldProcess"])) + function New-ShellProcessScope { ShellProcessOwnership\New-ShellProcessScope } + function Start-ShellOwnedProcess($Scope, $StartInfo, $StandardOutputWriter, $StandardErrorWriter) { + ShellProcessOwnership\Start-ShellOwnedProcess $Scope $StartInfo ` + -StandardOutputWriter $StandardOutputWriter -StandardErrorWriter $StandardErrorWriter + } + function Close-ShellProcessScope($Scope, [int] $TimeoutMilliseconds) { + ShellProcessOwnership\Close-ShellProcessScope $Scope -TimeoutMilliseconds $TimeoutMilliseconds + } + function Test-ShellOwnershipUnproven($Failure) { ShellProcessOwnership\Test-ShellOwnershipUnproven $Failure } + function Set-ShellOwnershipUnproven([Exception] $Exception) { + ShellProcessOwnership\Set-ShellOwnershipUnproven $Exception + } + foreach ($case in @("actual-streams-and-binding", "actual-exit-failure", "actual-start-failure")) { + Invoke-HeldCase $case { + $caseRoot = Join-Path $ArtifactRoot $case + $null = New-Item -ItemType Directory -Path $caseRoot + $out = Join-Path $caseRoot "shell-stdout.log" + $err = Join-Path $caseRoot "shell-stderr.log" + $fixture = Join-Path $caseRoot "child.ps1" + [IO.File]::WriteAllText($fixture, @' +param([string] $Expected, [string] $Release) +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +[Console]::WriteLine("BINDING=" + $Expected + "|" + (Get-Location).ProviderPath + "|" + $env:GRAPHCODE_HELD_TEST) +[Console]::Out.WriteLine("o" * 100000) +[Console]::Error.WriteLine("e" * 100000) +[Console]::Out.WriteLine("READY") +[Console]::Out.Flush() +if ($env:GRAPHCODE_HELD_TEST -ceq "exit-seven") { exit 7 } +$clock = [Diagnostics.Stopwatch]::StartNew() +while (-not [IO.File]::Exists($Release)) { + if ($clock.ElapsedMilliseconds -gt 10000) { exit 9 } + [Threading.Thread]::Sleep(10) +} +'@, [Text.UTF8Encoding]::new($false)) + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + $env:GRAPHCODE_HELD_TEST = if ($case -ceq "actual-exit-failure") { "exit-seven" } else { "present" } + $release = Join-Path $caseRoot "release" + $value = "space quote`" unicode-$([char]0x3bb)" + $file = if ($case -ceq "actual-start-failure") { Join-Path $caseRoot "missing.exe" } else { (Get-Command pwsh).Source } + $failure = $null + $process = $null + try { + try { + $process = Start-UiaOwnedProcess -file $file -arguments @("-NoProfile", "-File", $fixture, $value, $release) ` + -standardOutputPath $out -standardErrorPath $err -Hidden + } catch { $failure = $_ } + if ($case -ceq "actual-start-failure") { + Assert-Held ($failure -and $script:UiaGateOwnedCaptures[0].cleaned) "Failed CreateProcess did not clean its registered empty scope" + } else { + Assert-Held (-not $failure) "Actual held child failed to launch: $failure" + $clock = [Diagnostics.Stopwatch]::StartNew() + while (-not (([string](Read-HeldLog $out)).Contains("READY") -and + ([string](Read-HeldLog $err)).Contains("e" * 100000))) { + if ($clock.ElapsedMilliseconds -gt 5000) { throw "Actual child did not stream READY before deadline" } + [Threading.Thread]::Sleep(10) + } + $text = Read-HeldLog $out + Assert-Held ($text.Contains("BINDING=$value|$((Get-Location).ProviderPath)|$env:GRAPHCODE_HELD_TEST") -and + $text.Contains("o" * 100000) -and (Read-HeldLog $err).Contains("e" * 100000)) "Actual argument/environment/directory/concurrent streams differ" + if ($case -ceq "actual-streams-and-binding") { + Assert-Held (-not $process.HasExited) "Streaming evidence was collected only after exit" + [IO.File]::WriteAllText($release, "release") + } + Assert-Held ($process.WaitForExit(5000)) "Actual child did not exit before deadline" + Assert-Held ($process.ExitCode -eq $(if ($case -ceq "actual-exit-failure") { 7 } else { 0 })) "Actual child exit was masked" + } + } finally { + Stop-UiaOwnedProcessTrees @($process) + } + } + } +} +$failed = @($results | Where-Object { -not $_.passed }) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot "cases.json"), ($results | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false)) +$expected = if ($MockOnly) { 13 } else { 16 } +$complete = $results.Count -eq $expected -and $failed.Count -eq 0 -and + @($results.name | Sort-Object -Unique).Count -eq $expected +Write-Host "UIA held launcher: $($results.Count) cases, $($failed.Count) failures; expected=$expected" +foreach ($failure in $failed) { Write-Host "$($failure.name): $($failure.error)" } +if (-not $MockOnly) { + Write-Output ("HOST_CASE_RESULT=" + ([ordered]@{ + id = "host.uia-held-launcher"; status = $(if ($complete) { "Passed" } else { "Failed" }) + executedCount = $results.Count; groups = @($results | Where-Object passed | ForEach-Object name) + evidence = "Actual default launch dispatch and existing held scope; mock refusals plus three owned non-GUI child cases; UIA calls=0" + } | ConvertTo-Json -Compress)) +} +if (-not $complete) { exit 1 } diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index afccd011..69b705bc 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -2,6 +2,7 @@ param( [switch] $List, [switch] $WorkspaceTabSelectorOnly, + [switch] $JumpDestinationOnly, [string] $ZigExecutable, # Hosted CI splits the executable sections across runners. Shard/ShardCount # select one part of a deterministic, complete partition; the defaults run @@ -225,6 +226,173 @@ function Assert-WorkspaceTabSelectors { Assert-WorkspaceTabSelectors if ($WorkspaceTabSelectorOnly) { exit 0 } +function Assert-JumpDestinationContract { + $path = Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + Assert-Contract ($errors.Count -eq 0) "Jump destination gate must parse" + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Assert-UiaJumpWorkspaceDestination" + }, $true)) + Assert-Contract ($definitions.Count -eq 1) "Jump needs one actual workspace destination oracle" + Assert-Contract ($definitions[0].Extent.Text -notmatch 'System\.Windows\.Automation|GraphCodeUiaGateState|GetCurrentPattern|Invoke\(') ` + "Jump destination predicate must consume observations without native UIA or input" + . ([scriptblock]::Create($definitions[0].Extent.Text)) + $cases = @( + "exact", "project-only", "wrong-loop", "wrong-project", "duplicate-loop-bar", + "foreign-loop-bar", "foreign-toolbar", "unselected-row", "wrong-row", + "foreign-row", "missing-show-graph", "wrong-show-graph", "offscreen-loop-bar", + "zero-loop-bar-bounds", "duplicate-toolbar", "duplicate-row" + ) + foreach ($name in $cases) { + $children = @( + [pscustomobject]@{ Current = [pscustomobject]@{ AutomationId = "workspace-loop-bar-1"; Name = "Selected loop workspace"; ProcessId = 42; IsOffscreen = $false; BoundingRectangle = @{ Width = 100; Height = 20 } } }, + [pscustomobject]@{ Current = [pscustomobject]@{ AutomationId = "workspace-toolbar-2"; Name = "Owned project"; ProcessId = 42; IsOffscreen = $false; BoundingRectangle = @{ Width = 100; Height = 20 } } }, + [pscustomobject]@{ Current = [pscustomobject]@{ AutomationId = "workspace-show-graph-3"; Name = "Show in Graph"; ProcessId = 42; IsOffscreen = $false; BoundingRectangle = @{ Width = 100; Height = 20 } } } + ) + $rows = @([pscustomobject]@{ Current = [pscustomobject]@{ + AutomationId = "loop-row-4"; Name = "Owned loop"; ProcessId = 42 + } }) + $selected = $true + switch ($name) { + "project-only" { $children = @() } + "wrong-loop" { $children[0].Current.AutomationId = "workspace-loop-bar-9" } + "wrong-project" { $children[1].Current.AutomationId = "workspace-toolbar-9" } + "duplicate-loop-bar" { $children += $children[0] } + "foreign-loop-bar" { $children[0].Current.ProcessId = 99 } + "foreign-toolbar" { $children[1].Current.ProcessId = 99 } + "unselected-row" { $selected = $false } + "wrong-row" { $rows[0].Current.AutomationId = "loop-row-9" } + "foreign-row" { $rows[0].Current.ProcessId = 99 } + "missing-show-graph" { $children = @($children[0], $children[1]) } + "wrong-show-graph" { $children[2].Current.AutomationId = "workspace-show-graph-9" } + "offscreen-loop-bar" { $children[0].Current.IsOffscreen = $true } + "zero-loop-bar-bounds" { $children[0].Current.BoundingRectangle.Width = 0 } + "duplicate-toolbar" { $children += $children[1] } + "duplicate-row" { $rows += $rows[0] } + } + $failure = $null + try { + Assert-UiaJumpWorkspaceDestination $children $rows $selected 42 ` + "workspace-loop-bar-1" "workspace-toolbar-2" "workspace-show-graph-3" ` + "loop-row-4" "Owned project" "Owned loop" + } catch { $failure = $_ } + Assert-Contract (($null -eq $failure) -eq ($name -ceq "exact")) "Jump destination oracle accepted wrong observation: $name" + if ($failure) { + Assert-Contract ($failure.Exception.Message -like "Jump destination:*") "Jump refusal came from an unrelated failure: $name" + } + } + $source = [IO.File]::ReadAllText($path) + Assert-Contract ($source.Contains('Assert-UiaJumpWorkspaceDestination $jumpWorkspaceChildren $jumpDestinationRows') -and + $source.Contains('SetFirstEditText($jumpWindow, "UIA loop A")') -and + $source.Contains('$jumpExpectedLoopBarId = Get-MultiProjectAutomationId "workspace-loop-bar" $dynamicProjectNodeIds[0]')) ` + "Jump leaf destination observations must reach the actual production oracle" + Write-Host "UIA Jump destination contract: $($cases.Count) cases passed (nativeCalls=0)" + return $cases.Count +} + +$null = Assert-JumpDestinationContract + +function Assert-JumpEditTextContract { + $path = Join-Path $repoRoot "Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaGateState.cs" + $source = [IO.File]::ReadAllText($path) + $start = $source.IndexOf(' public static bool SetFirstEditText(') + $end = $source.IndexOf(' public static string EditTextById(', [Math]::Max(0, $start)) + Assert-Contract ($start -ge 0 -and $end -gt $start) "Jump edit helpers must have one extractable production region" + $methods = $source.Substring($start, $end - $start) + Assert-Contract ([regex]::Matches($methods, 'public static bool SetFirstEditText\(').Count -eq 1 -and + [regex]::Matches($methods, 'public static bool SetEditTextById\(').Count -eq 1 -and + $methods -notmatch 'DllImport|System\.Windows\.Automation') "Jump edit fixture must contain only the two actual method bodies" + Add-Type -TypeDefinition (@' +using System; +public static class JumpEditTextFixture { + public static string Caption, Buffer, NotifiedBuffer; + public static int Notifications, Writes, ControlId, NotifiedId; + public static bool Missing, RefuseWrite; + public static void Reset() { + Caption = Buffer = "UIA loop B"; NotifiedBuffer = null; + Notifications = Writes = NotifiedId = 0; ControlId = 9904; + Missing = RefuseWrite = false; + } + private static IntPtr FindWindowEx(IntPtr parent, IntPtr after, string cls, string name) { + if (parent != new IntPtr(1) || after != IntPtr.Zero || cls != "Edit" || name != null) + throw new InvalidOperationException("Unexpected first-edit lookup"); + return Missing ? IntPtr.Zero : new IntPtr(2); + } + private static int GetDlgCtrlID(IntPtr edit) { + if (edit != new IntPtr(2)) throw new InvalidOperationException("Wrong edit id lookup"); + return ControlId; + } + private static IntPtr ControlById(IntPtr parent, int id) { + if (parent != new IntPtr(1) || id != ControlId) + throw new InvalidOperationException("Wrong parent or control id"); + return Missing ? IntPtr.Zero : new IntPtr(2); + } + private static bool SetWindowText(IntPtr edit, string text) { + if (edit != new IntPtr(2)) throw new InvalidOperationException("Wrong caption target"); + Caption = text; return true; + } + public static void CaptionOnly(string text) { SetWindowText(new IntPtr(2), text); } + private static IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr wparam, string text) { + if (edit != new IntPtr(2) || message != 0x000C || wparam != UIntPtr.Zero) + throw new InvalidOperationException("Expected WM_SETTEXT to the actual edit"); + if (RefuseWrite) return IntPtr.Zero; + Buffer = text; Writes++; return new IntPtr(1); + } + private static IntPtr SendMessage(IntPtr parent, uint message, UIntPtr command, IntPtr edit) { + if (parent != new IntPtr(1) || message != 0x0111 || edit != new IntPtr(2) || + (command.ToUInt64() >> 16) != 0x0300) + throw new InvalidOperationException("Expected EN_CHANGE to the original parent"); + Notifications++; NotifiedBuffer = Buffer; NotifiedId = (int)(command.ToUInt64() & 0xffff); + return IntPtr.Zero; + } +'@ + $methods + "`n}") + $cases = @("caption-only", "query-A", "repeat-query", "empty-query", "unicode-query", + "missing-edit", "refused-write", "actual-control-id") + foreach ($name in $cases) { + [JumpEditTextFixture]::Reset() + if ($name -ceq "caption-only") { + [JumpEditTextFixture]::CaptionOnly("UIA loop A") + Assert-Contract ([JumpEditTextFixture]::Caption -ceq "UIA loop A" -and + [JumpEditTextFixture]::Buffer -ceq "UIA loop B" -and + [JumpEditTextFixture]::Notifications -eq 0) "caption-only negative control must leave the edit buffer unchanged" + continue + } + $text = "UIA loop A" + switch ($name) { + "repeat-query" { + Assert-Contract ([JumpEditTextFixture]::SetFirstEditText([IntPtr]1, "intermediate")) "first replacement must succeed" + } + "empty-query" { $text = "" } + "unicode-query" { $text = "loop " + [char]0x96EA } + "missing-edit" { [JumpEditTextFixture]::Missing = $true } + "refused-write" { [JumpEditTextFixture]::RefuseWrite = $true } + "actual-control-id" { [JumpEditTextFixture]::ControlId = 8123 } + } + $ok = [JumpEditTextFixture]::SetFirstEditText([IntPtr]1, $text) + if ($name -in @("missing-edit", "refused-write")) { + Assert-Contract (-not $ok -and [JumpEditTextFixture]::Writes -eq 0 -and + [JumpEditTextFixture]::Notifications -eq 0 -and + [JumpEditTextFixture]::Buffer -ceq "UIA loop B") "Jump edit refusal must not dispatch EN_CHANGE or change the buffer: $name" + } else { + $expected = if ($name -ceq "repeat-query") { 2 } else { 1 } + Assert-Contract ($ok -and [JumpEditTextFixture]::Buffer -ceq $text -and + [JumpEditTextFixture]::NotifiedBuffer -ceq $text -and + [JumpEditTextFixture]::NotifiedId -eq [JumpEditTextFixture]::ControlId -and + [JumpEditTextFixture]::Writes -eq $expected -and + [JumpEditTextFixture]::Notifications -eq $expected -and + [JumpEditTextFixture]::Caption -ceq "UIA loop B") "Jump must set the EDIT buffer before EN_CHANGE, not only its caption: $name" + } + } + Write-Host "UIA Jump edit-buffer contract: $($cases.Count) cases passed (intercepted actual methods; nativeCalls=0)" + return $cases.Count +} + +$null = Assert-JumpEditTextContract +if ($JumpDestinationOnly) { exit 0 } + $shellSource = Get-Content $shellScript -Raw $appSource = Get-Content (Join-Path $shellRoot "src\App.zig") -Raw $mainWindowSource = Get-Content (Join-Path $shellRoot "src\MainWindow.zig") -Raw @@ -890,6 +1058,11 @@ Invoke-Native "Owned live caller and adapter mock integration" { throw "Owned live caller and adapter mock integration did not pass all 13 cases" } Write-Host "OWNED_LIVE_ADAPTER_MOCK_CASES=$($adapterResults.PassedCount)" + $heldAssembly = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") + & pwsh -NoProfile -File (Join-Path $PSScriptRoot "UiaHeldLauncher.Tests.ps1") ` + -ArtifactRoot (Join-Path $repoRoot ".build\uia-held-launcher-shell") ` + -AssemblyPath $heldAssembly + if ($LASTEXITCODE -ne 0) { throw "Default UIA held-launch contracts failed" } $global:LASTEXITCODE = 0 } Invoke-Native "GDI+ asynchronous startup live test" { diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index b8a7d82a..bdbf4431 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -46,6 +46,8 @@ public static IReadOnlyList Create(IReadOnlyList "HOST_CASE_RESULT=", ["host.provider-build"]), new CaseScriptSuite("host.legacy-ownership.shell-scope", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", "HOST_CASE_RESULT=", ["host.legacy-ownership.shell-scope"], ["-Contract", "shell-scope"]), + new CaseScriptSuite("host.uia-held-launcher", "Tools\\windows\\Tests\\UiaHeldLauncher.Tests.ps1", + "HOST_CASE_RESULT=", ["host.uia-held-launcher"]), new CaseScriptSuite("host.legacy-ownership.live-adapter", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", "HOST_CASE_RESULT=", ["host.legacy-ownership.live-adapter"], ["-Contract", "live-adapter"]), new CaseScriptSuite("host.compiled-assembly-environment", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 83bfec9f..e8e977a6 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -1,5 +1,7 @@ { "units": [ + { "id": "host.uia-held-launcher", "owner": "Host", "minimumCount": 16, "exactCount": 16, + "requiredGroups": ["actual-streams-and-binding", "actual-exit-failure", "actual-start-failure"] }, { "id": "host.sdk-prerequisites", "owner": "Host", "minimumCount": 49, "exactCount": 49 }, { "id": "host.admission-guards", "owner": "Host", "minimumCount": 65, "exactCount": 65 }, { "id": "host.compiled-assembly-environment", "owner": "Host", "minimumCount": 7, "exactCount": 7 }, diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index 758c59cb..e297d171 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,5 +1,8 @@ { "units": [ + { "id": "ui.jump.workspace-destination-controls", "owner": "Ui", "minimumCount": 16, "exactCount": 16, "requiredGroups": [] }, + { "id": "ui.jump.edit-buffer-controls", "owner": "Ui", "minimumCount": 8, "exactCount": 8, "requiredGroups": [] }, + { "id": "ui.edit-buffer-cross-process", "owner": "Ui", "minimumCount": 4, "exactCount": 4, "requiredGroups": [] }, { "id": "ui.cards-pilot.target-valid", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.foreign-project", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.missing-target", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, @@ -36,6 +39,10 @@ { "id": "ui.cards-pilot.zip-import-valid", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-flat-import-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-stale-identity-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.navigation.native-runner-safety", "owner": "Ui", "minimumCount": 16, "exactCount": 16, + "requiredGroups": ["vcvars", "where-cl", "compile", "compile-repeated-path", "timeout", "timeout-metadata", + "shared-budget", "expired-budget", "zero-budget", "zero-retention", "unknown-retention", + "unknown-diagnostic-failure", "invalid-byte-refusal", "missing-executable", "two-step-original-budget", "fake-summary"] }, { "id": "ui.cards-pilot.zip-custody-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-spawn-scope-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-quiescence-higher-total", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 index 9821d36c..e6650fb5 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 @@ -16,7 +16,7 @@ $jobReplacements = @{ '[GraphCode.Validation.Ownership.StandaloneProcessJob]' = '[GraphCode.Validation.Ownership.OwnershipJobFixture]' } . Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Complete-MultiProjectCapture', - 'Start-UiaOwnedProcess', 'Invoke-UiaCleanupActions') $jobReplacements + 'Start-UiaOwnedProcess', 'Complete-UiaHeldCapture', 'Invoke-UiaCleanupActions') $jobReplacements $saved = [Environment]::GetEnvironmentVariables('Process') Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' try { @@ -126,26 +126,39 @@ try { } } finally { $reader.Dispose() } $launch = @{} - function Start-MultiProjectOwnedProcess { - param($file, $arguments, $environment, $stem, [switch]$Visible, [switch]$RegisterForUia, - $standardOutputPath, $standardErrorPath, $logDirectory, $targetWorkingDirectory) - $launch.arguments = $arguments; $launch.environment = $environment - $launch.visible = $Visible; $launch.registered = $RegisterForUia - $launch.cwd = $targetWorkingDirectory; $launch.stdout = $standardOutputPath - return @{ process = 'controlled-owned-target' } + $controlled = [Diagnostics.Process]::new() + function New-ShellProcessScope { return [pscustomobject]@{ Closed = $false } } + function Start-ShellOwnedProcess($Scope, $StartInfo, $StandardOutputWriter, $StandardErrorWriter) { + $launch.arguments = $StartInfo.ArgumentList; $launch.environment = $StartInfo.Environment + $launch.visible = -not $StartInfo.CreateNoWindow + $launch.cwd = $StartInfo.WorkingDirectory + $launch.stdout = $StandardOutputWriter.BaseStream.Name + Assert-Ownership ($script:UiaGateOwnedCaptures.Count -eq 1) 'Held scope was not registered before launch.' + return $controlled + } + function Register-UiaHeldProcess($Process) { $launch.registered = [Object]::ReferenceEquals($Process, $controlled) } + function Close-ShellProcessScope($Scope, [int] $TimeoutMilliseconds) { + Assert-Ownership ($TimeoutMilliseconds -eq 5000) 'Default capture reset its cleanup budget.' + $Scope.Closed = $true } foreach ($hidden in @($false, $true)) { Invoke-OwnershipCase "owned-routing-hidden-$hidden" { - $stderr = Join-Path $ArtifactRoot 'fixture-stderr.log' - $result = Start-UiaOwnedProcess -file 'controlled.exe' -arguments @('space argument', '', '"literal"') -standardErrorPath $stderr -Hidden:$hidden - Assert-Ownership ($result -ceq 'controlled-owned-target' -and $launch.arguments.Count -eq 3 -and + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + $stderr = Join-Path $ArtifactRoot "fixture-$hidden-stderr.log" + try { + $result = Start-UiaOwnedProcess -file 'controlled.exe' -arguments @('space argument', '', '"literal"') -standardErrorPath $stderr -Hidden:$hidden + Assert-Ownership ([Object]::ReferenceEquals($result, $controlled) -and $launch.arguments.Count -eq 3 -and $launch.arguments[0] -ceq 'space argument' -and $launch.arguments[1] -ceq '' -and $launch.arguments[2] -ceq '"literal"' -and $launch.visible -eq (-not $hidden) -and $launch.registered -and $launch.cwd -ceq (Get-Location).ProviderPath -and $launch.environment.TEMP -ceq $env:TEMP -and - $launch.stdout -ceq (Join-Path $ArtifactRoot 'fixture-stdout.log')) 'Actual launch wrapper changed argv/environment/cwd/visibility/stdio/registry routing.' + $launch.stdout -ceq (Join-Path $ArtifactRoot "fixture-$hidden-stdout.log")) 'Actual launch wrapper changed argv/environment/cwd/visibility/stdio/registry routing.' + } finally { + foreach ($capture in $script:UiaGateOwnedCaptures) { $null = Complete-UiaHeldCapture $capture } + } } } + $controlled.Dispose() Invoke-OwnershipCase 'actual-assignment-membership-retain-order' { $definition = $gate.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Start-MultiProjectOwnedProcess' diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index 0ae5db01..00be23b8 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -11,6 +11,8 @@ static async Task RunAsync(string[] args) { + if (args.Length == 2 && args[0] == "--edit-buffer-fixture") + return GraphCode.Validation.Ui.Fixtures.PromptLookupFixture.RunEditBufferChild(args[1]); if (args.SequenceEqual(new[] { "--native-popup-effect-fixture" })) return GraphCode.Validation.Ui.NativeUiSuite.RunPopupEffects(); if (HostFixture.TryRun(args) is int hostFixtureExit) return hostFixtureExit; diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md index 3d2392b8..d0420c8b 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -113,7 +113,48 @@ proof only from validated results of the current invocation. Its derived ## Production and process boundaries +The default UIA launch adapter has a separate mandatory +`host.uia-held-launcher` unit: 16 unique cases, comprising 13 mocked refusals, +bindings and cleanup controls plus three actual owned non-GUI child cases. +The actual cases exercise the production default launch/registration and +cleanup dispatch with concurrent live stdout/stderr, Unicode argv, environment +and working-directory propagation, a nonzero exit, and failed process creation. +They do not load a UIA clientside provider or create/query a window. The +optional writer overload uses the existing capture pump; writers stay owned by +the adapter until job/root exit and both pipe EOFs are proved. Partial setup +preserves the original error, existing evidence is never overwritten, and +UNKNOWN retains authority rather than closing or retrying the scope. + +The original shell-ownership unit remains exactly 77 cases with seven diagnostic +groups; the caller adapter remains 13 and capture routing remains 14. These +regressions are distinct from the new launch unit. The selected canonical +invocation can name `host.uia-held-launcher`, +`host.legacy-ownership.shell-scope`, `host.legacy-ownership.live-adapter` and +`ownership.capture` together. It deliberately remains an incomplete invocation, +not the full CI gate. The shared regression also selected the independent Jump +functional and navigation-runner adapters (70 units, 256 counted executions on +the combined 687-unit inventory); an issue candidate must select only suites +present in its own issue-specific tree. + Production C# files are linked into `WindowsValidationBindings.csproj` once. +The Jump edit-buffer regression runs eight headless cases against the unchanged +bodies of `SetFirstEditText` and `SetEditTextById`, with native entrypoints +intercepted in a separate managed fixture. It distinguishes a caption-only +write from a `WM_SETTEXT` buffer update before `EN_CHANGE`, including empty, +Unicode, repeated, refused, missing-control and actual-control-ID cases. +Run `pwsh -NoProfile -File Tools\windows\Tests\WindowsShell.Tests.ps1 -JumpDestinationOnly` +or select `ui.functional-contracts` in the compiled runner. This proves helper +dispatch with controlled cross-process semantics, not an actual cross-process +window interaction; the native Jump gate is still required for that evidence. +Select `ui.edit-buffer-cross-process` for the separate four-check native +regression: one hidden owned child with a real message pump, a caption-only +negative control, query-A buffer readback, and child-side `EN_CHANGE` observation. +Its original 20-second supervisor deadline has the canonical five-second +cleanup budget; raw streams, readiness and cleanup proof are retained in a +fresh fixture directory on success or failure. It sends no input, changes no +focus and calls no UIA API. This fixture establishes the helper's cross-process +behavior, not the complete Jump destination workflow. + UIA, packaging contracts, shell ownership, and managed/native tests consume those same implementations. The loader verifies the exact file/type, worktree, current source and compiler-reference fingerprints, and loaded module identity. diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs index b9c7068a..a7fbf31a 100644 --- a/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs @@ -24,6 +24,82 @@ public static ZipPickerResult PickZipProduction(string libraryPath, IntPtr owner } private delegate IntPtr WindowProc(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); private static readonly WindowProc procedure = DefWindowProc; + private static readonly WindowProc editProcedure = EditWindowProc; + private static IntPtr crossProcessEdit; + private static int editChanges; + private static string changedBuffer; + [StructLayout(LayoutKind.Sequential)] + private struct Message { + public IntPtr Window; + public uint Id; + public UIntPtr WParam; + public IntPtr LParam; + public uint Time; + public int X, Y; + public uint Private; + } + [DllImport("user32.dll", EntryPoint = "GetMessageW")] + private static extern int GetMessage(out Message message, IntPtr window, uint first, uint last); + [DllImport("user32.dll")] + private static extern bool TranslateMessage(ref Message message); + [DllImport("user32.dll", EntryPoint = "DispatchMessageW")] + private static extern IntPtr DispatchMessage(ref Message message); + [DllImport("user32.dll")] + private static extern void PostQuitMessage(int exitCode); + [DllImport("user32.dll", EntryPoint = "SetWindowTextW", CharSet = CharSet.Unicode)] + public static extern bool SetCaptionForRegression(IntPtr window, string text); + private static IntPtr EditWindowProc(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam) { + if (message == 0x0111 && (wparam.ToUInt64() >> 16) == 0x0300 && lparam == crossProcessEdit) { + editChanges++; + changedBuffer = GraphCodeUiaGateState.EditTextById(window, 9904); + } + if (message == 0x0010) { + PostQuitMessage(0); + return IntPtr.Zero; + } + return DefWindowProc(window, message, wparam, lparam); + } + public static int RunEditBufferChild(string root) { + if (!System.IO.Path.IsPathFullyQualified(root) || !System.IO.Directory.Exists(root)) + throw new ArgumentException("Existing absolute owned fixture root required."); + var cls = new WindowClass { Name = "GraphCodeEditBufferFixture", Instance = GetModuleHandle(null), + Procedure = Marshal.GetFunctionPointerForDelegate(editProcedure) }; + if (RegisterClass(ref cls) == 0) + throw new InvalidOperationException("Edit fixture registration failed: " + Marshal.GetLastWin32Error()); + IntPtr parent = Create(cls.Name, IntPtr.Zero, 0); + Exception primary = null; + try { + crossProcessEdit = Create("EDIT", parent, 9904); + if (!GraphCodeUiaGateState.SetEditTextById(parent, 9904, "UIA loop B")) + throw new InvalidOperationException("Initial child edit buffer write failed."); + editChanges = 0; + changedBuffer = null; + using (var file = new System.IO.FileStream(System.IO.Path.Combine(root, "ready.json"), + System.IO.FileMode.CreateNew, System.IO.FileAccess.Write, System.IO.FileShare.None)) + System.Text.Json.JsonSerializer.Serialize(file, new { pid = Environment.ProcessId, window = parent.ToInt64() }); + using (System.IO.File.Open(System.IO.Path.Combine(root, "published"), System.IO.FileMode.CreateNew)) { } + int status; + while ((status = GetMessage(out var message, IntPtr.Zero, 0, 0)) > 0) { + TranslateMessage(ref message); + DispatchMessage(ref message); + } + if (status < 0) throw new InvalidOperationException("Edit fixture message pump failed."); + Console.WriteLine("EDIT_BUFFER_CHILD=" + System.Text.Json.JsonSerializer.Serialize(new { + pid = Environment.ProcessId, buffer = GraphCodeUiaGateState.EditTextById(parent, 9904), + changes = editChanges, observed = changedBuffer + })); + return 0; + } catch (Exception failure) { + primary = failure; + throw; + } finally { + if (!DestroyWindow(parent)) { + var failure = new InvalidOperationException("Edit fixture window cleanup failed."); + if (primary == null) throw failure; + throw new AggregateException("Edit fixture execution and window cleanup failed.", primary, failure); + } + } + } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct WindowClass { public uint Style; diff --git a/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs b/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs index 9234a02c..f570e7d2 100644 --- a/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs +++ b/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs @@ -7,6 +7,126 @@ namespace GraphCode.Validation.Ui; +public sealed class EditBufferSuite : IValidationSuite +{ + public string Id => "ui.edit-buffer-cross-process"; + public IReadOnlyList UnitIds => [Id]; + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + string root = System.IO.Path.Combine(context.ArtifactRoot, "edit-buffer-" + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(root); + string host = Environment.ProcessPath ?? throw new InvalidOperationException("Current executable unavailable."); + var start = new ProcessStartInfo(host) + { + UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = root, + RedirectStandardOutput = true, RedirectStandardError = true, + StandardOutputEncoding = new System.Text.UTF8Encoding(false, true), + StandardErrorEncoding = new System.Text.UTF8Encoding(false, true) + }; + if (System.IO.Path.GetFileNameWithoutExtension(host).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + start.ArgumentList.Add(System.Reflection.Assembly.GetEntryAssembly()!.Location); + start.ArgumentList.Add("--edit-buffer-fixture"); + start.ArgumentList.Add(root); + start.Environment["TEMP"] = root; + start.Environment["TMP"] = root; + var run = Ownership.OwnedChildSupervisor.Start(start, TimeSpan.FromSeconds(20), root); + var completion = run.CompleteAsync(cancellationToken); + IntPtr window = IntPtr.Zero; + Exception? primary = null; + Ownership.OwnedChildResult? result = null; + int executed = 0; + try + { + var readyClock = Stopwatch.StartNew(); + while (!System.IO.File.Exists(System.IO.Path.Combine(root, "published"))) + { + if (completion.IsCompleted || readyClock.ElapsedMilliseconds >= 5000) + throw new TimeoutException("Owned edit fixture did not publish readiness within five seconds."); + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + } + using var ready = System.Text.Json.JsonDocument.Parse( + System.IO.File.ReadAllText(System.IO.Path.Combine(root, "ready.json"))); + window = new IntPtr(ready.RootElement.GetProperty("window").GetInt64()); + ActivationCases.Require(ready.RootElement.GetProperty("pid").GetUInt32() == run.ProcessId && + run.ProcessId != Environment.ProcessId && !run.Observe().RootExited && + GraphCodeUiaGateState.WindowProcessId(window) == run.ProcessId && + GraphCodeUiaGateState.WindowClass(window) == "GraphCodeEditBufferFixture" && + !GraphCodeUiaGateState.WindowIsVisible(window), + "Edit regression requires the hidden window of the original owned foreign process."); + executed++; + ActivationCases.Require(PromptLookupFixture.SetCaptionForRegression( + GraphCodeUiaGateState.ControlById(window, 9904), "caption decoy") && + GraphCodeUiaGateState.EditTextById(window, 9904) == "UIA loop B", + "Caption-only negative control unexpectedly changed the foreign EDIT buffer."); + executed++; + ActivationCases.Require(GraphCodeUiaGateState.SetFirstEditText(window, "UIA loop A") && + GraphCodeUiaGateState.EditTextById(window, 9904) == "UIA loop A", + "SetFirstEditText did not replace the foreign EDIT buffer with query A."); + executed++; + } + catch (Exception failure) { primary = failure; } + finally + { + try + { + if (window != IntPtr.Zero && !run.Observe().RootExited && + GraphCodeUiaGateState.WindowProcessId(window) == run.ProcessId && + !GraphCodeUiaGateState.PostClose(window)) + throw new InvalidOperationException("Owned edit fixture close message failed."); + } + catch (Exception failure) + { + if (primary is null) primary = failure; + else primary.Data["EditFixtureCloseFailure"] = failure; + } + try { result = await completion.ConfigureAwait(false); } + catch (Exception failure) + { + if (primary is null) primary = failure; + else primary.Data["EditFixtureCompletionFailure"] = failure; + } + try + { + using var proofFile = new System.IO.FileStream(System.IO.Path.Combine(root, "proof.json"), + System.IO.FileMode.CreateNew, System.IO.FileAccess.Write, System.IO.FileShare.Read); + System.Text.Json.JsonSerializer.Serialize(proofFile, new { + parentChecksBeforeChildReport = executed, pid = run.ProcessId, creationFileTime = run.CreationFileTime, + exitCode = result?.ExitCode, cleanup = result?.Cleanup, + retainedLeases = Ownership.OwnedChildSupervisor.RetainedLeaseCount, + failure = primary?.ToString() + }); + } + catch (Exception failure) + { + if (primary is null) primary = failure; + else primary.Data["EditFixtureProofFailure"] = failure; + } + } + if (primary is not null) System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + if (result is null || result.ExitCode != 0 || !result.Cleanup.ResourcesClosed || + !result.Cleanup.CanRelease || !string.IsNullOrWhiteSpace(result.StandardError)) + throw new InvalidOperationException("Owned edit fixture failed exit/cleanup: " + result?.StandardError); + const string prefix = "EDIT_BUFFER_CHILD="; + string[] reports = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Where( + result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries), + line => line.StartsWith(prefix, StringComparison.Ordinal))); + ActivationCases.Require(reports.Length == 1, "Expected exactly one child buffer report."); + using var report = System.Text.Json.JsonDocument.Parse(reports[0][prefix.Length..]); + ActivationCases.Require(report.RootElement.GetProperty("pid").GetUInt32() == run.ProcessId && + report.RootElement.GetProperty("buffer").GetString() == "UIA loop A" && + report.RootElement.GetProperty("observed").GetString() == "UIA loop A" && + report.RootElement.GetProperty("changes").GetInt32() > 0, + "Foreign message pump did not observe query A through EN_CHANGE."); + executed++; + Console.WriteLine("EDIT_BUFFER_REGRESSION=" + System.Text.Json.JsonSerializer.Serialize(new { + executed, cleanup = result.Cleanup, retainedLeases = Ownership.OwnedChildSupervisor.RetainedLeaseCount + })); + return [new UnitResult(Id, UnitStatus.Passed, executed, + "Real hidden cross-process EDIT buffer, caption-only negative control and child EN_CHANGE; no input/focus/UIA")]; + } +} + public sealed class NativeUiSuite : IValidationSuite { public string Id => "ui.native"; diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs index c4d190bb..2c20fae9 100644 --- a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs @@ -155,8 +155,6 @@ private static extern IntPtr SendMessageText( private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern bool SetWindowText(IntPtr window, string text); [DllImport("user32.dll")] private static extern int GetDlgCtrlID(IntPtr window); [DllImport("user32.dll")] @@ -817,10 +815,7 @@ public static bool PostClose(IntPtr window) { } public static bool SetFirstEditText(IntPtr parent, string text) { var edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); - if (edit == IntPtr.Zero || !SetWindowText(edit, text)) return false; - ulong command = ((ulong)0x0300 << 16) | (uint)GetDlgCtrlID(edit); - SendMessage(parent, 0x0111, (UIntPtr)command, edit); - return true; + return edit != IntPtr.Zero && SetEditTextById(parent, GetDlgCtrlID(edit), text); } public static bool SetEditTextById(IntPtr parent, int controlId, string text) { var edit = ControlById(parent, controlId); diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index dad571b4..eaf7b41c 100644 --- a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -10,7 +10,7 @@ public static class SuiteRegistration { public static IReadOnlyList CreateSuites() => new IValidationSuite[] { - new ActivationSuite(), new ManagedUiSuite(), new CardsAcceptanceControlsSuite(), new NativeUiSuite(), new ControlClickTopologySuite() + new ActivationSuite(), new ManagedUiSuite(), new CardsAcceptanceControlsSuite(), new NativeUiSuite(), new EditBufferSuite(), new ControlClickTopologySuite() }.Concat(UiAdapterCases.CreateSuites()).ToArray(); } diff --git a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs index 9391e2e7..a7ab4ee7 100644 --- a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs +++ b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs @@ -173,6 +173,8 @@ internal static class UiAdapterCases }; internal static readonly string[] FunctionalIds = { + "ui.jump.workspace-destination-controls", + "ui.jump.edit-buffer-controls", "ui.connector.blank-title", "ui.connector.different-target-title", "ui.connector.duplicate-source", @@ -270,6 +272,9 @@ internal static class UiAdapterCases }; internal static IReadOnlyList CreateSuites() => new IValidationSuite[] { + new StandaloneSuites.CaseScriptSuite("ui.native-runner-safety", + @"Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1", + "UI_CASE_RESULT=", new[] { "ui.navigation.native-runner-safety" }), new StandaloneSuites.CaseScriptSuite("ui.powershell-contracts", @"Tools\windows\ValidationRunner\Ui\UiPowerShellContracts.ps1", "UI_CASE_RESULT=", PowerShellIds), new StandaloneSuites.CaseScriptSuite("ui.delete-contracts", @"Tools\windows\ValidationRunner\Ui\UiDeleteContracts.ps1", "UI_CASE_RESULT=", DeleteIds), new StandaloneSuites.CaseScriptSuite("ui.root-source-contracts", @"Tools\windows\ValidationRunner\Ui\UiRootSourceContracts.ps1", "UI_CASE_RESULT=", RootSourceIds), diff --git a/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 index e2bde819..857a73d2 100644 --- a/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 @@ -4,8 +4,6 @@ param( [Parameter(Mandatory)][string] $AssemblyPath ) $ErrorActionPreference = 'Stop' -& (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` - -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null $source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) $errors = $null $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) @@ -32,6 +30,42 @@ function Read-Function([string] $Name) { Invoke-Functional 'ui.root.source.line-5366' { Assert-Functional ($errors.Count -eq 0) 'UIA production source does not parse' } Invoke-Functional 'ui.root.source.line-5553' { Assert-Functional ($errors.Count -eq 0) 'UIA startup source does not parse' } Assert-Functional ($errors.Count -eq 0) 'Cannot import malformed production source' +Invoke-Functional 'ui.jump.workspace-destination-controls' { + $shellErrors = $null + $shellAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $RepositoryRoot 'Tools\windows\Tests\WindowsShell.Tests.ps1'), + [ref]$null, [ref]$shellErrors) + Assert-Functional ($shellErrors.Count -eq 0) 'Shell acceptance source does not parse' + foreach ($name in @('Assert-Contract', 'Assert-JumpDestinationContract')) { + $definitions = @($shellAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + Assert-Functional ($definitions.Count -eq 1) "Missing unique shared Jump control: $name" + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + $repoRoot = $RepositoryRoot + $count = Assert-JumpDestinationContract + Assert-Functional ($count -eq 16) 'Shared Jump controls did not execute exactly 16 cases' +} 16 +Invoke-Functional 'ui.jump.edit-buffer-controls' { + $shellErrors = $null + $shellAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $RepositoryRoot 'Tools\windows\Tests\WindowsShell.Tests.ps1'), + [ref]$null, [ref]$shellErrors) + Assert-Functional ($shellErrors.Count -eq 0) 'Shell edit-buffer acceptance source does not parse' + foreach ($name in @('Assert-Contract', 'Assert-JumpEditTextContract')) { + $definitions = @($shellAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + Assert-Functional ($definitions.Count -eq 1) "Missing unique shared Jump edit-buffer control: $name" + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + $repoRoot = $RepositoryRoot + $count = Assert-JumpEditTextContract + Assert-Functional ($count -eq 8) 'Shared Jump edit-buffer controls did not execute exactly eight cases' +} 8 foreach ($binding in @( @{ line = 5380; name = 'Test-MultiProjectSourceSupportedChromeId' }, @{ line = 5407; name = 'Wait-MultiProjectWorkspaceMarkerPublication' }, diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 18d9e334..0e741034 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2059,6 +2059,43 @@ function Wait-ForGraphChildren( return [pscustomobject]@{ Graph = $liveGraph; Items = $observed; Children = $children } } +function Assert-UiaJumpWorkspaceDestination( + [object[]] $Children, + [object[]] $Rows, + [bool] $Selected, + [int] $ProcessId, + [string] $LoopBarId, + [string] $ToolbarId, + [string] $ShowGraphId, + [string] $RowId, + [string] $ProjectName, + [string] $LoopName +) { + if ($ProcessId -le 0 -or $Rows.Count -ne 1 -or -not $Selected -or + $Rows[0].Current.ProcessId -ne $ProcessId -or + $Rows[0].Current.AutomationId -cne $RowId -or + $Rows[0].Current.Name -cne $LoopName) { + throw "Jump destination: exact owned loop row is not uniquely selected" + } + foreach ($expected in @( + @{ id = $LoopBarId; name = "Selected loop workspace" }, + @{ id = $ToolbarId; name = $ProjectName }, + @{ id = $ShowGraphId; name = "Show in Graph" } + )) { + $matches = @($Children | Where-Object { $_.Current.AutomationId -ceq $expected.id }) + if ($matches.Count -ne 1 -or + $matches[0].Current.ProcessId -ne $ProcessId -or + $matches[0].Current.Name -cne $expected.name -or + $matches[0].Current.IsOffscreen) { + throw "Jump destination: missing, duplicate, foreign or offscreen workspace identity $($expected.id)" + } + $bounds = $matches[0].Current.BoundingRectangle + if ($bounds.Width -le 0 -or $bounds.Height -le 0) { + throw "Jump destination: workspace identity has empty bounds $($expected.id)" + } + } +} + function Get-WorkspaceTabMountDecision( [object[]] $Before, [object[]] $AfterNew, @@ -3701,6 +3738,7 @@ function Get-MultiProjectAutomationId([string] $kind, [string] $path, [string] $ "overview-worktree-notice" { "canvas-card" } "workspace-loop-bar" { "workspace-loop-bar" } "workspace-toolbar" { "workspace-toolbar" } + "workspace-show-graph" { "workspace-show-graph" } "composite-back" { "canvas-card" } default { throw "MULTIPROJECT_KIND: unsupported automation identity kind" } } @@ -4971,6 +5009,9 @@ function Wait-MultiProjectCapture($capture, [int] $timeoutMilliseconds = 5000) { function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $timeoutMilliseconds = 5000, $primaryError = $null) { if ($null -eq $capture) { return } + if ($capture -is [Collections.IDictionary] -and $capture.Contains("scope")) { + return Complete-UiaHeldCapture $capture -TimeoutMilliseconds $timeoutMilliseconds + } $clock = [Diagnostics.Stopwatch]::StartNew() $errors = [Collections.Generic.List[string]]::new() $identity = "pid=$($capture.rootPID) start=$($capture.rootStartUtcTicks) target=$($capture.targetPID) targetStart=$($capture.targetStartUtcTicks) job=$($capture.job.ToInt64())" @@ -5064,17 +5105,81 @@ function Start-UiaOwnedProcess( [string] $file, [string[]] $arguments, [string] $standardOutputPath, [string] $standardErrorPath, [switch] $Hidden ) { - $environment = @{} - foreach ($entry in [Environment]::GetEnvironmentVariables("Process").GetEnumerator()) { - $environment[$entry.Key] = $entry.Value - } $directory = Split-Path -Parent $standardErrorPath $stem = [IO.Path]::GetFileNameWithoutExtension($standardErrorPath) -replace "-stderr$", "" if (-not $standardOutputPath) { $standardOutputPath = Join-Path $directory "$stem-stdout.log" } - $capture = Start-MultiProjectOwnedProcess -file $file -arguments $arguments -environment $environment ` - -stem $stem -Visible:(-not $Hidden) -RegisterForUia -standardOutputPath $standardOutputPath ` - -standardErrorPath $standardErrorPath -logDirectory $directory -targetWorkingDirectory (Get-Location).ProviderPath - return $capture.process + $capture = [ordered]@{ + scope = New-ShellProcessScope; process = $null; cleaned = $false + logDirectory = $directory; stdoutWriter = $null; stderrWriter = $null; ownershipFailure = $null + } + if ($null -eq $script:UiaGateOwnedCaptures) { + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + } + $script:UiaGateOwnedCaptures.Add($capture) + try { + foreach ($stream in @( + @{ key = "stdoutWriter"; path = $standardOutputPath }, + @{ key = "stderrWriter"; path = $standardErrorPath } + )) { + $fileStream = [IO.File]::Open($stream.path, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { + $capture[$stream.key] = [IO.StreamWriter]::new($fileStream, [Text.UTF8Encoding]::new($false)) + } catch { + $fileStream.Dispose() + throw + } + $capture[$stream.key].AutoFlush = $true + } + $info = [Diagnostics.ProcessStartInfo]::new($file) + $info.UseShellExecute = $false + $info.CreateNoWindow = [bool]$Hidden + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.WorkingDirectory = (Get-Location).ProviderPath + foreach ($argument in $arguments) { $info.ArgumentList.Add($argument) } + $capture.process = Start-ShellOwnedProcess $capture.scope $info ` + -StandardOutputWriter $capture.stdoutWriter -StandardErrorWriter $capture.stderrWriter + Register-UiaHeldProcess $capture.process + return $capture.process + } catch { + $primary = $_ + if (Test-ShellOwnershipUnproven $primary) { + $capture.ownershipFailure = $primary.Exception + throw + } + try { $null = Complete-UiaHeldCapture $capture } catch { + $primary.Exception.Data["UiaHeldCleanupFailure"] = $_.Exception + if (Test-ShellOwnershipUnproven $_) { + $primary.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + } + } + throw $primary + } +} + +function Register-UiaHeldProcess([Diagnostics.Process] $process) { + [GraphCodeUiaGateState]::RetainProcess($process) +} + +function Complete-UiaHeldCapture($capture, [ValidateRange(0, 5000)][int] $TimeoutMilliseconds = 5000) { + if ($capture.ownershipFailure) { throw $capture.ownershipFailure } + if ($capture.cleaned) { return } + try { + Close-ShellProcessScope $capture.scope -TimeoutMilliseconds $TimeoutMilliseconds + } catch { + $capture.ownershipFailure = Set-ShellOwnershipUnproven $_.Exception + $capture.ownershipFailure.Data["UiaRetainedCapture"] = $capture + throw $capture.ownershipFailure + } + $errors = [Collections.Generic.List[Exception]]::new() + foreach ($key in @("stdoutWriter", "stderrWriter")) { + try { if ($capture[$key]) { $capture[$key].Dispose() } } + catch { $errors.Add($_.Exception) } + } + $capture.cleaned = $true + if ($errors.Count) { throw [AggregateException]::new("UIA held output persistence failed", $errors.ToArray()) } + Write-Host "UIA_HELD_CAPTURE_CLEANUP=verified originalScope=true pipeEof=true" } function Start-MultiProjectOwnedProcess( @@ -9025,14 +9130,14 @@ try { Require (($jumpNames -match '(?s)^UIA loop C\nTimed · NEEDS YOU · Jump fixture$').Count -ge 1) ` "jump palette did not expose a contextual cross-project result" $jumpWindow = [IntPtr]$jumpPalette.Current.NativeWindowHandle - Require ([GraphCodeUiaGateState]::SetFirstEditText($jumpWindow, "UIA loop B")) ` + Require ([GraphCodeUiaGateState]::SetFirstEditText($jumpWindow, "UIA loop A")) ` "jump palette rejected live query input" Require ([GraphCodeUiaGateState]::PostPaletteRefresh($jumpWindow)) ` "jump palette rejected deterministic live-filter refresh" Start-Sleep -Milliseconds 100 $filteredJumpNames = @([GraphCodeUiaGateState]::GetListItems($jumpListHandle)) Require (($filteredJumpNames.Count -eq 1) -and - ($filteredJumpNames[0] -match '(?s)^UIA loop B\nComposite · RUNNING · UIA project$')) ` + ($filteredJumpNames[0] -match '(?s)^UIA loop A\nGoal · DONE · UIA project$')) ` "jump palette did not live-filter to the ranked keyboard destination ($($filteredJumpNames -join '|'))" Require ([GraphCodeUiaGateState]::PostKeyboard($jumpWindow, 0x0D)) ` "jump palette rejected Return activation" @@ -9045,19 +9150,34 @@ try { if ($null -eq $remainingJump) { break } } Require ($null -eq $remainingJump) "Return did not activate and close the jump palette" + $jumpExpectedLoopBarId = Get-MultiProjectAutomationId "workspace-loop-bar" $dynamicProjectNodeIds[0] + $jumpExpectedToolbarId = Get-MultiProjectAutomationId "workspace-toolbar" $fixtureProjectPath + $jumpExpectedShowGraphId = Get-MultiProjectAutomationId "workspace-show-graph" "show-graph" + $jumpExpectedRowId = Get-MultiProjectAutomationId "loop" $fixtureProjectPath $dynamicProjectNodeIds[0] + $jumpWorkspace = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -cin @($jumpExpectedLoopBarId, $jumpExpectedToolbarId, $jumpExpectedShowGraphId) } ` + { param($items) @($items).Count -eq 3 } + $jumpWorkspaceChildren = @($jumpWorkspace.Items) $loopsAfterJump = Find-FragmentById $root "loops" $rawWalker - $loopBAfterJump = @(Get-DirectChildren $loopsAfterJump $rawWalker | - Where-Object { $_.Current.Name -eq "UIA loop B" }) | Select-Object -First 1 - Require ($null -ne $loopBAfterJump) "jump navigation did not retain the destination loop row" - $selectedAfterJump = $loopBAfterJump.GetCurrentPattern( + $jumpDestinationRows = @(Get-DirectChildren $loopsAfterJump $rawWalker | + Where-Object { $_.Current.AutomationId -ceq $jumpExpectedRowId }) + Require ($jumpDestinationRows.Count -eq 1) "Jump did not retain exactly one destination loop row" + $selectedAfterJump = $jumpDestinationRows[0].GetCurrentPattern( [System.Windows.Automation.SelectionItemPattern]::Pattern ).Current.IsSelected - Require $selectedAfterJump "jump palette activation did not navigate to UIA loop B" + Assert-UiaJumpWorkspaceDestination $jumpWorkspaceChildren $jumpDestinationRows ` + $selectedAfterJump $process.Id $jumpExpectedLoopBarId $jumpExpectedToolbarId ` + $jumpExpectedShowGraphId $jumpExpectedRowId "UIA project" "UIA loop A" $jumpPaletteEvidence = [ordered]@{ resultCount = $jumpNames.Count results = @($jumpNames | ForEach-Object { [string]$_ }) filteredResults = @($filteredJumpNames | ForEach-Object { [string]$_ }) - destination = [string]$loopBAfterJump.Current.Name + destination = [string]$jumpDestinationRows[0].Current.Name + destinationRow = [string]$jumpDestinationRows[0].Current.AutomationId + selected = $selectedAfterJump + workspaceLoopBar = $jumpExpectedLoopBarId + workspaceToolbar = $jumpExpectedToolbarId + showInGraph = $jumpExpectedShowGraphId } Write-Host ("UIA_JUMP_PALETTE_EVIDENCE=" + ($jumpPaletteEvidence | ConvertTo-Json -Compress)) diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index ef95066e..5f3ecce9 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -8,6 +8,7 @@ The Windows port must have runnable commands before implementation fleets begin. |---|---| | Windows investigation spikes | `pwsh Tools/windows/validate.ps1 -Task all` | | Compiled validation runner contract | `pwsh -NoProfile -File Tools\windows\Invoke-ValidationRunnerContract.ps1` (builds and runs the normal full C# entry; see [runner documentation](ValidationRunner/README.md)) | +| Jump destination observation controls (no desktop/UIA calls) | `pwsh -NoProfile -File Tools\windows\Tests\WindowsShell.Tests.ps1 -JumpDestinationOnly` (16 destination fixtures; also executed by the shell gate and compiled `ui.functional-contracts` adapter) | | Explicit fixture compiler selection (controlled files and observations; no compiler/GUI execution) | `pwsh -NoProfile -File Tools\windows\Tests\FixtureCompiler.Tests.ps1 -ScratchRoot .build\fixture-compiler-contracts` (fresh directory required; 27 cases; also run by the canonical capture adapter before fresh preparation) | | Exclusive validation fixture directories (independent filesystem and static wiring contracts) | `pwsh -NoProfile -File Tools\windows\Tests\ValidationFixtureDirectory.Tests.ps1` | | Isolated 30-key process environment boundary | `pwsh -NoProfile -File Tools\windows\Tests\IsolatedProcessEnvironment.Contracts.Tests.ps1 -Phase REGRESSION` | @@ -37,6 +38,28 @@ The Windows port must have runnable commands before implementation fleets begin. | macOS format/lint | `make check` | | DCO commit range | `git log --format=%B ..HEAD` plus trailer validation | +### Jump destination acceptance + +The finished Goal fixture is the leaf-workspace oracle; the Composite fixture +opens a group and must not stand in for leaf mounting. The destination check +requires exact owned loop-bar, project-toolbar and Show in Graph identities +with visible nonempty bounds, plus the unique selected sidebar loop row. +The 16 in-memory controls exercise the actual production observation helper, +not native navigation. The functional adapter has no compiled-UIA import: +its executed source/managed fixtures do not need a binding assembly, and the +ordinary loader registers real UIA clientside providers. Its existing 64 units +and the new exact-16 unit execute headlessly (65 distinct units, 122 counted +executions). The full registry contains 685 unique units; that is an inventory, +not a full-run result. Shell coverage remains 66 sections across 23/18/25. + +The selected canonical invocation is +`ValidationRunner.exe --repository-root $PWD.Path --artifact-root $ownedFreshRoot --suite ui.functional-contracts` +after the normal pinned build. It deliberately exits 1 with `complete=false` +and `fullInvocation=false` because the other required units did not run. +Require all 65 selected unit identities to pass; do not relabel this as the +full CI-equivalent invocation. Full shell units still include native fixtures, +and `-SkipTrayLive` is not headless admission. + ### Validation-runner fixture ownership The untracked Swift-format fixture and `OwnershipSentinel` fixture require a @@ -76,6 +99,38 @@ assert one owned-file receipt and its preserved hash, and emit distinct `UNTRACKED_OWNED_FILE_PREFLIGHT_KIND` markers. Require both markers as well as a positive test count when interpreting that coverage. +### Native accessibility runner admission + +The existing native entry now uses the compiled runner's +`OwnedChildSupervisor.RunAsync` for discovery and the compile/test command. +Both consume the same original `-TimeoutMilliseconds` clock (default 120000), +with the supervisor's separate five-second bounded cleanup reserve. Targets +start suspended under the original held handle/non-breakaway job; raw stdout +and stderr, cleanup proof, timeout and codec context stay in fresh scratch. +The runner never deletes that scratch, and UNKNOWN stops further work in a +passive original-authority hold rather than closing or retrying the lease. +A passive hold is not permission for an unbounded workload. A caller that +later removes its enclosing validation directory can still remove successful +evidence; runtime admission must separately account for that existing outer +cleanup policy and preserve protected outputs. + +The headless harness has 16 distinct safety cases, including actual runner +invocation with controlled abrupt/failing/timeout tools, partial stdout/stderr, +metadata-report failure, two actual steps sharing one clock, a missing executable, +invalid-byte refusal and a fake success-summary refusal. No real compiler or C++ +fixture executes in that harness. Its existing 25 failure-attribution checks and 14 compile-plan checks +remain separate. The compiled safety suite is `ui.native-runner-safety`. + +The console-free `cmd` channel uses the observed Windows OEM code page; +`vswhere -utf8` uses strict UTF-8. Raw bytes remain authoritative. Non-ASCII +display diagnostics use explicitly labeled JSON escaping without changing +console/OS code pages or rewriting the raw logs. The controlled non-ASCII path +roundtrip does not prove that every compiler/descendant emits that codec or +qualify all Unicode paths/MSVC diagnostics. The original UTF-8/OEM decode failure +and subsequent nested-host decode failure remain failed evidence, distinct from +the corrected passing run. Default native 280-check execution still requires +specific runtime approval; the headless switch is not a desktop grant. + ### Stable canvas card context menu The stable canvas card context menu mock contracts execute 16 managed @@ -946,10 +1001,19 @@ evidence only. Main changes product and native image/window surfaces, so a new integrated source-bound build and separately admitted functional rerun are required before claiming this head is natively qualified. -The normal default UIA gate sets synthetic fixture and -connection-failure flags and uses its identity-file/PID-reopen launcher. It -does not establish real-daemon/session restoration and is unchanged. The new -route returns before that default path and uses `ShellProcessOwnership.psm1` +The normal default UIA gate sets synthetic fixture and connection-failure +flags. Its default and secondary-shell launch entry now uses +`ShellProcessOwnership.psm1`: the original target starts suspended, joins its +scope/private jobs before release, and registers the original generation-checked +process while its native launch handle remains held. Caller-owned UTF-8 writers +tee both independently drained streams into create-new logs before launch. +UNKNOWN retains the scope and writers without retrying cleanup. Cleanup passes +the original remaining budget through scope/job/root/pipe checks; stopwatch +budgets do not guarantee kernel scheduling. The legacy multi-project launcher +is unchanged and still has a separate PID-file path, so this correction must +not be claimed for that route. Neither synthetic path establishes real-daemon +session restoration. The restored-input route returns before that default path +and uses `ShellProcessOwnership.psm1` suspended original launches and retained jobs, private profile/support/temp, explicit child environments with no inherited UIA or synthetic workspace flags, and normal production CLI commands. Since Swift's zmx locator ignores `PATH`, diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 201b7bb5..97373d3d 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -63,6 +63,55 @@ const WorkspaceTeardown = @import("WorkspaceTeardown.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; +fn loopOpensOnHumanTap(node: GraphModel.Node) bool { + if (!std.mem.eql(u8, node.state, "blocked")) return true; + if (!std.mem.eql(u8, node.loop_type, "goalBased") and !std.mem.eql(u8, node.loop_type, "timeBased")) return true; + return node.presence_exit_code == null and (std.mem.eql(u8, node.presence, "busy") or + std.mem.eql(u8, node.presence, "idle") or std.mem.eql(u8, node.presence, "awaitingInput")); +} + +fn blockedLoopMessage(allocator: std.mem.Allocator, node_id: []const u8, nodes: []const GraphModel.Node, edges: []const GraphModel.Edge) ![]u8 { + var upstream: std.ArrayList(u8) = .empty; + defer upstream.deinit(allocator); + for (nodes) |node| { + const waiting = for (edges) |edge| { + if (edge.blocks_target and !edge.fired and std.mem.eql(u8, edge.to, node_id) and + std.mem.eql(u8, edge.from, node.id)) break true; + } else false; + if (!waiting) continue; + if (upstream.items.len != 0) try upstream.appendSlice(allocator, " and "); + try upstream.writer(allocator).print("“{s}”", .{node.title}); + } + return if (upstream.items.len == 0) + allocator.dupe(u8, "It's waiting on a hand-off that hasn't fired yet — the hand-off will release it automatically.") + else + std.fmt.allocPrint(allocator, "It's waiting on {s} to finish — the hand-off will release it automatically.", .{upstream.items}); +} + +test "jump activation headless blocked admission matches macOS human taps" { + for ([_][]const u8{ "turnBased", "sketch", "goalBased", "timeBased", "proactive", "composite" }) |kind| { + for ([_][]const u8{ "idle", "running", "succeeded", "failed", "stalled", "stopped", "blocked" }) |state| { + for ([_][]const u8{ "", "absent", "unknown", "busy", "idle", "awaitingInput" }) |presence| { + for ([_]?i64{ null, 0, -1 }) |exit_code| { + const node = GraphModel.Node{ + .id = @constCast("target"), + .title = @constCast("Target"), + .loop_type = @constCast(kind), + .state = @constCast(state), + .activity = &.{}, + .presence = @constCast(presence), + .presence_exit_code = exit_code, + }; + const unattended = std.mem.eql(u8, kind, "goalBased") or std.mem.eql(u8, kind, "timeBased"); + const live = exit_code == null and (std.mem.eql(u8, presence, "busy") or + std.mem.eql(u8, presence, "idle") or std.mem.eql(u8, presence, "awaitingInput")); + try std.testing.expectEqual(!std.mem.eql(u8, state, "blocked") or live or !unattended, loopOpensOnHumanTap(node)); + } + } + } + } +} + /// Lets tests observe each production UIA publication (`syncAccessibility`); absent from product builds. var accessibility_publication_observer: if (builtin.is_test) ?*const fn (*App) void else void = if (builtin.is_test) null else {}; @@ -70,6 +119,8 @@ var accessibility_publication_observer: if (builtin.is_test) ?*const fn (*App) v /// modal dialog; absent from product builds. var update_offer_observer: if (builtin.is_test) ?*const fn (*App, []const u8, []const u8) void else void = if (builtin.is_test) null else {}; +var blocked_loop_notice_observer: if (builtin.is_test) ?*const fn ([]const u8, []const u8) void else void = if (builtin.is_test) null else {}; + const title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Windows"); const workspace_restart_message = "Workspace identity changed or could not be verified. Restart GraphCode before managing workspaces."; const WorktreeInspectRunner = *const fn ( @@ -4805,26 +4856,27 @@ pub const App = struct { return; } orelse return; defer selection.deinit(self.allocator); + _ = self.activateJumpSelection(selection.project_path, selection.node_id); + } + + fn activateJumpSelection(self: *App, project_path: []const u8, node_id: []const u8) bool { const project_index = for (self.model.graphs.items, 0..) |graph, index| { - if (std.mem.eql(u8, graph.project.path, selection.project_path)) break index; + if (std.mem.eql(u8, graph.project.path, project_path)) break index; } else { self.setStatus("Matching loop is no longer available"); - return; + return false; }; const node_index = for (self.model.graphs.items[project_index].nodes.items, 0..) |node, index| { - if (std.mem.eql(u8, node.id, selection.node_id)) break index; + if (std.mem.eql(u8, node.id, node_id)) break index; } else { self.setStatus("Matching loop is no longer available"); - return; + return false; }; - if (!self.selectProject(selection.project_path) or !self.selectNodeIndex(node_index)) return; - self.surface = .project; - self.workspace_controls.setPanelVisible(false); - self.layoutWorkspace(); - self.layoutEmptyStateControls(); - self.ensureLoopVisible(selection.project_path, node_index); + if (!self.activateLoop(project_path, node_id)) return false; + self.ensureLoopVisible(project_path, node_index); self.syncAccessibility(); _ = c.InvalidateRect(self.window.hwnd, null, 0); + return true; } fn openSelectedNode(self: *App) void { @@ -4836,6 +4888,18 @@ pub const App = struct { } fn activateLoop(self: *App, project_path: []const u8, node_id: []const u8) bool { + const target_graph = self.model.graphFor(project_path) orelse { + self.setStatus("Selected loop is no longer available"); + return false; + }; + const target_index = GraphModel.findNodeIndexByID(target_graph.nodes.items, node_id) orelse { + self.setStatus("Selected loop is no longer available"); + return false; + }; + if (!loopOpensOnHumanTap(target_graph.nodes.items[target_index])) { + self.showBlockedLoopNotice(target_graph, target_graph.nodes.items[target_index]); + return false; + } if (self.pending_session_restart) |pending| { if (std.mem.eql(u8, pending.project_path, project_path) and std.mem.eql(u8, pending.node_id, node_id)) { self.setStatus("Session restart is still waiting for daemon confirmation"); @@ -4891,6 +4955,38 @@ pub const App = struct { return true; } + fn showBlockedLoopNotice(self: *App, graph: *const GraphModel.GraphSummary, node: GraphModel.Node) void { + const notice_title = std.fmt.allocPrint(self.allocator, "“{s}” is waiting its turn", .{node.title}) catch { + self.setStatus("Unable to explain blocked loop"); + return; + }; + defer self.allocator.free(notice_title); + const message = blockedLoopMessage(self.allocator, node.id, graph.nodes.items, graph.edges.items) catch { + self.setStatus("Unable to explain blocked loop"); + return; + }; + defer self.allocator.free(message); + self.setStatus(notice_title); + if (builtin.is_test) { + if (blocked_loop_notice_observer) |observe| { + observe(notice_title, message); + return; + } + } + const wide_title = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, notice_title) catch { + self.setStatus("Unable to show blocked loop notice"); + return; + }; + defer self.allocator.free(wide_title); + const wide_message = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, message) catch { + self.setStatus("Unable to show blocked loop notice"); + return; + }; + defer self.allocator.free(wide_message); + if (c.MessageBoxW(self.window.hwnd, wide_message.ptr, wide_title.ptr, c.MB_OK | c.MB_ICONINFORMATION) == 0) + self.setStatus("Unable to show blocked loop notice"); + } + fn stopSelectedNode(self: *App) void { const path = self.currentProject() orelse return; const node = self.model.selected() orelse return; @@ -23781,8 +23877,7 @@ test "wire capture real window procedure preserves reentrant same-window SetCapt try MainWindow.registerClass(instance); app.window.hwnd = null; app.window.context = &app; - const hwnd = c.CreateWindowExW(0, app.window.class_name, app.window.class_name, - c.WS_POPUP, 0, 0, 1200, 700, null, null, instance, &app.window) orelse { + const hwnd = c.CreateWindowExW(0, app.window.class_name, app.window.class_name, c.WS_POPUP, 0, 0, 1200, 700, null, null, instance, &app.window) orelse { app.window.hwnd = original; return error.WindowCreationFailed; }; @@ -23816,9 +23911,7 @@ test "wire capture real window procedure preserves reentrant same-window SetCapt _ = c.SendMessageW(hwnd, c.WM_ACTIVATEAPP, 0, 0); try std.testing.expect(!app.canvas.edge_dragging); try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); - const foreign = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC"), - std.unicode.utf8ToUtf16LeStringLiteral("Capture peer"), c.WS_POPUP, - 0, 0, 100, 100, null, null, instance, null) orelse return error.WindowCreationFailed; + const foreign = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC"), std.unicode.utf8ToUtf16LeStringLiteral("Capture peer"), c.WS_POPUP, 0, 0, 100, 100, null, null, instance, null) orelse return error.WindowCreationFailed; defer _ = c.DestroyWindow(foreign); app.beginWireSelectedNode(0); _ = c.SetCapture(foreign); @@ -24833,6 +24926,157 @@ test "jump matching ranks exact results across projects" { try std.testing.expectEqual(@as(u8, 2), prefix.score); } +test "jump activation headless blocked notice names only unfired sequencing upstreams in node order" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"event":{"graphChanged":{"project":{"path":"A"},"nodes":[{"id":"a","title":"First"},{"id":"b","title":"Second"},{"id":"c","title":"Already fired"},{"id":"d","title":"Message only"},{"id":"target","title":"Waiting","state":"blocked"}],"edges":[{"from":"b","to":"target","blocksTarget":true},{"from":"a","to":"target","blocksTarget":true},{"from":"a","to":"target","blocksTarget":true},{"from":"c","to":"target","blocksTarget":true,"fired":true},{"from":"d","to":"target","kind":"message","blocksTarget":false}]}}} + ); + const graph = model.graph.?; + const message = try blockedLoopMessage(std.testing.allocator, "target", graph.nodes.items, graph.edges.items); + defer std.testing.allocator.free(message); + try std.testing.expectEqualStrings("It's waiting on “First” and “Second” to finish — the hand-off will release it automatically.", message); + const fallback = try blockedLoopMessage(std.testing.allocator, "missing", graph.nodes.items, graph.edges.items); + defer std.testing.allocator.free(fallback); + try std.testing.expectEqualStrings("It's waiting on a hand-off that hasn't fired yet — the hand-off will release it automatically.", fallback); +} + +test "jump activation native running and finished destinations bind the same workspace as sidebar rows" { + for ([_][]const u8{ "running", "succeeded", "failed", "stalled", "stopped" }) |state| { + var fixture: ShowGraphFixture = undefined; + try fixture.initSized(Dpi.base_dpi, 1200); + defer fixture.deinit(); + const app = &fixture.app; + try deliverSessionRestartGraph(app, reopen_fixture_path, state, 0); + try std.testing.expect(app.selectNodeIndex(0)); + const before = app.client.outbound_count; + try std.testing.expect(app.activateJumpSelection(reopen_fixture_path, "loop-b")); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + try std.testing.expectEqualStrings(reopen_fixture_path, fixture.workspace.projectPath()); + try std.testing.expectEqualStrings("loop-b", fixture.workspace.loop_id); + try std.testing.expectEqual(before + 1, app.client.outbound_count); + const command = app.client.outbound[(app.client.outbound_head + before) % app.client.outbound.len]; + try std.testing.expect(std.mem.indexOf(u8, command, "\"resumeSession\":{\"_0\":\"loop-b\"}") != null); + var sink = SidebarUiaSink{}; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 700 }); + const identity = try std.fmt.allocPrint(app.allocator, "loop:{s}:loop-b", .{reopen_fixture_path}); + defer app.allocator.free(identity); + try expectSidebarNavigationSelection(&sink, identity, true); + const project_identity = try std.fmt.allocPrint(app.allocator, "open-project:{s}", .{reopen_fixture_path}); + defer app.allocator.free(project_identity); + try expectSidebarNavigationSelection(&sink, project_identity, false); + + app.handleAction(.show_graph); + const before_sidebar = app.client.outbound_count; + try clickSidebarLoopRow(app, reopen_fixture_path, "loop-b"); + try std.testing.expectEqual(before_sidebar + 1, app.client.outbound_count); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + try std.testing.expectEqualStrings(reopen_fixture_path, fixture.workspace.projectPath()); + } +} + +test "jump activation native cross-project identity and stale results preserve the actual destination" { + var fixture: ShowGraphFixture = undefined; + try fixture.initSized(Dpi.base_dpi, 1200); + defer fixture.deinit(); + const app = &fixture.app; + const other = "C:\\jump-other"; + try deliverSessionRestartGraph(app, other, "running", 0); + try std.testing.expect(app.activateLoop(reopen_fixture_path, "loop-a")); + try std.testing.expect(app.activateJumpSelection(other, "loop-b")); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + try std.testing.expectEqualStrings(other, app.currentProject().?); + try std.testing.expectEqualStrings(other, fixture.workspace.projectPath()); + const before = app.client.outbound_count; + for ([_]struct { path: []const u8, id: []const u8 }{ + .{ .path = "C:\\deleted-project", .id = "loop-b" }, + .{ .path = reopen_fixture_path, .id = "deleted-loop" }, + .{ .path = other, .id = "deleted-loop" }, + }) |stale| { + try std.testing.expect(!app.activateJumpSelection(stale.path, stale.id)); + try std.testing.expectEqualStrings("Matching loop is no longer available", app.status()); + try std.testing.expectEqualStrings(other, app.currentProject().?); + try std.testing.expectEqualStrings(other, fixture.workspace.projectPath()); + try std.testing.expectEqualStrings("loop-b", fixture.workspace.loop_id); + try std.testing.expectEqual(before, app.client.outbound_count); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + } +} + +test "jump activation native blocked refusal preserves the previous workspace and shows the sidebar notice" { + const Probe = struct { + var count: usize = 0; + fn observe(notice_title: []const u8, message: []const u8) void { + std.testing.expectEqualStrings("“Blocked goal” is waiting its turn", notice_title) catch @panic("wrong notice title"); + std.testing.expectEqualStrings("It's waiting on “Upstream” to finish — the hand-off will release it automatically.", message) catch @panic("wrong notice message"); + count += 1; + } + }; + var fixture: ShowGraphFixture = undefined; + try fixture.initSized(Dpi.base_dpi, 1200); + defer fixture.deinit(); + const app = &fixture.app; + try std.testing.expect(app.activateLoop(reopen_fixture_path, "loop-b")); + _ = try app.model.updateFromFrame( + \\{"event":{"graphChanged":{"project":{"path":"C:\\blocked-other","name":"Blocked project"},"nodes":[{"id":"upstream","title":"Upstream","state":"running"},{"id":"blocked","title":"Blocked goal","loopType":"goalBased","state":"blocked","presence":{"presence":"busy","exitCode":0}}],"edges":[{"from":"upstream","to":"blocked","kind":"handoff","fired":false}]}}} + ); + Probe.count = 0; + blocked_loop_notice_observer = Probe.observe; + defer blocked_loop_notice_observer = null; + const before = app.client.outbound_count; + try std.testing.expect(!app.activateJumpSelection("C:\\blocked-other", "blocked")); + try std.testing.expect(!app.activateLoop("C:\\blocked-other", "blocked")); + try std.testing.expectEqual(@as(usize, 2), Probe.count); + try std.testing.expectEqual(before, app.client.outbound_count); + try std.testing.expectEqualStrings(reopen_fixture_path, app.currentProject().?); + try std.testing.expectEqualStrings(reopen_fixture_path, fixture.workspace.projectPath()); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); +} + +test "jump activation native attended and live blocked loops still open" { + for ([_]struct { kind: []const u8, presence: []const u8 }{ + .{ .kind = "sketch", .presence = "{\"presence\":\"absent\",\"exitCode\":0}" }, + .{ .kind = "turnBased", .presence = "null" }, + .{ .kind = "goalBased", .presence = "{\"presence\":\"busy\"}" }, + .{ .kind = "timeBased", .presence = "{\"presence\":\"idle\"}" }, + .{ .kind = "goalBased", .presence = "{\"presence\":\"awaitingInput\"}" }, + }) |case| { + var fixture: ShowGraphFixture = undefined; + try fixture.initSized(Dpi.base_dpi, 1200); + defer fixture.deinit(); + const app = &fixture.app; + const frame = try std.fmt.allocPrint(app.allocator, "{{\"event\":{{\"graphChanged\":{{\"project\":{{\"path\":\"B\",\"name\":\"Blocked live\"}},\"nodes\":[{{\"id\":\"live\",\"title\":\"Live\",\"loopType\":\"{s}\",\"state\":\"blocked\",\"presence\":{s}}}],\"edges\":[]}}}}}}", .{ case.kind, case.presence }); + defer app.allocator.free(frame); + _ = try app.model.updateFromFrame(frame); + const before = app.client.outbound_count; + try std.testing.expect(app.activateJumpSelection("B", "live")); + try expectLoopBarAndPane(app, &fixture.workspace, "live"); + try std.testing.expectEqualStrings("B", fixture.workspace.projectPath()); + try std.testing.expectEqual(before + 1, app.client.outbound_count); + } +} + +test "jump activation native composite targets reuse the sidebar group destination" { + for ([_][]const u8{ "composite", "proactive" }) |kind| { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + const frame = try std.fmt.allocPrint(app.allocator, "{{\"event\":{{\"graphChanged\":{{\"project\":{{\"path\":\"A\",\"name\":\"A\"}},\"nodes\":[{{\"id\":\"group\",\"title\":\"Group\",\"loopType\":\"{s}\",\"state\":\"running\",\"subGraph\":{{\"nodes\":[{{\"id\":\"child\",\"title\":\"Child\",\"state\":\"idle\"}}],\"edges\":[]}}}}],\"edges\":[]}}}}}}", .{kind}); + defer app.allocator.free(frame); + _ = try app.model.updateFromFrame(frame); + try std.testing.expect(app.activateJumpSelection("A", "group")); + try std.testing.expect(app.model.isCompositeOpen()); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expectEqualStrings("child", app.model.graph.?.nodes.items[0].id); + try std.testing.expectEqual(@as(usize, 0), app.client.outbound_count); + app.closeCompositeGroup(); + try clickSidebarLoopRow(&app, "A", "group"); + try std.testing.expect(app.model.isCompositeOpen()); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expectEqualStrings("child", app.model.graph.?.nodes.items[0].id); + try std.testing.expectEqual(@as(usize, 0), app.client.outbound_count); + } +} + test "node form validation errors keep the validation status" { try std.testing.expectEqualStrings( "Invalid node form", diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 36036a34..c80e8425 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -31,6 +31,7 @@ pub const Node = struct { state: []u8, activity: []u8, presence: []u8, + presence_exit_code: ?i64 = null, backend: []u8 = &.{}, pilot_state: []u8 = &.{}, goal_summary: []u8 = &.{}, @@ -59,6 +60,36 @@ pub const Node = struct { follows_template: bool = false, }; +test "jump activation presence exit status survives decoding cloning and replacement" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"event":{"graphChanged":{"revision":1,"project":{"path":"A","name":"A"},"nodes":[{"id":"exited","state":"blocked","presence":{"presence":"busy","exitCode":0}},{"id":"negative","presence":{"presence":"idle","exitCode":-9223372036854775808}},{"id":"large","presence":{"presence":"idle","exitCode":9223372036854775807}},{"id":"null","presence":{"presence":"idle","exitCode":null}},{"id":"missing","presence":{"presence":"busy"}},{"id":"legacy","presence":"idle"},{"id":"scoped","metadata":{"presence":{"exitCode":99}},"presence":{"presence":"busy","metadata":{"exitCode":88}}}],"edges":[]}}} + ); + const nodes = model.graph.?.nodes.items; + try std.testing.expectEqual(@as(?i64, 0), nodes[0].presence_exit_code); + try std.testing.expectEqual(@as(?i64, std.math.minInt(i64)), nodes[1].presence_exit_code); + try std.testing.expectEqual(@as(?i64, std.math.maxInt(i64)), nodes[2].presence_exit_code); + for (nodes[3..]) |node| try std.testing.expect(node.presence_exit_code == null); + const copied = try cloneNode(std.testing.allocator, nodes[0]); + defer freeNode(std.testing.allocator, copied); + try std.testing.expectEqual(@as(?i64, 0), copied.presence_exit_code); + _ = try model.updateFromFrame( + \\{"event":{"nodesChanged":{"revision":2,"projectPath":"A","nodes":[{"id":"exited","state":"blocked","presence":{"presence":"idle"}}]}}} + ); + try std.testing.expect(model.graph.?.nodes.items[0].presence_exit_code == null); +} + +test "jump activation rejects malformed presence exit status" { + for ([_][]const u8{ "\"0\"", "true", "1.5", "9223372036854775808", "-9223372036854775809", "{}", "[]" }) |value| { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = try std.fmt.allocPrint(std.testing.allocator, "{{\"event\":{{\"graphChanged\":{{\"project\":{{\"path\":\"A\"}},\"nodes\":[{{\"id\":\"a\",\"presence\":{{\"presence\":\"busy\",\"exitCode\":{s}}}}}],\"edges\":[]}}}}}}", .{value}); + defer std.testing.allocator.free(frame); + try std.testing.expectError(error.MalformedPresenceExitCode, model.updateFromFrame(frame)); + } +} + pub const ActivityEvent = struct { title: []u8, state: []u8, @@ -1644,6 +1675,7 @@ fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node { .state = &.{}, .activity = &.{}, .presence = &.{}, + .presence_exit_code = node.presence_exit_code, .heartbeat_interval_seconds = node.heartbeat_interval_seconds, .poll_interval_seconds = node.poll_interval_seconds, .stall_after_seconds = node.stall_after_seconds, @@ -2174,6 +2206,8 @@ fn decodeNodes( var reading_fields = try JsonFields.init(allocator, reading); defer reading_fields.deinit(); node.presence = try reading_fields.get("presence").duplicateString(allocator, ""); + const exit_code = reading_fields.get("exitCode"); + node.presence_exit_code = if (exit_code.isNull()) null else std.fmt.parseInt(i64, exit_code.raw, 10) catch return error.MalformedPresenceExitCode; } else { node.presence = try presence.duplicateString(allocator, ""); } diff --git a/graphcode-windows/src/JumpPalette.zig b/graphcode-windows/src/JumpPalette.zig index 1ea3828c..a2433be9 100644 --- a/graphcode-windows/src/JumpPalette.zig +++ b/graphcode-windows/src/JumpPalette.zig @@ -341,6 +341,74 @@ fn freeEntries(allocator: std.mem.Allocator, entries: []Entry) void { allocator.free(entries); } +test "jump query refresh follows the actual search buffer and preserves the selected destination" { + const allocator = std.testing.allocator; + const previous = std.process.getEnvVarOwned(allocator, "GRAPHCODE_UIA_GATE") catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + defer if (previous) |value| allocator.free(value); + const previous_wide = if (previous) |value| try utf8ToWideZ(allocator, value) else null; + defer if (previous_wide) |value| allocator.free(value); + const variable = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_UIA_GATE"); + try std.testing.expect(c.SetEnvironmentVariableW(variable.ptr, std.unicode.utf8ToUtf16LeStringLiteral("1")) != 0); + defer _ = c.SetEnvironmentVariableW(variable.ptr, if (previous_wide) |value| value.ptr else null); + + try registerClass(); + const entries = [_]Entry{ + .{ .project_path = "A", .project_name = "UIA project", .node_id = "loop-a", .title = "UIA loop A", .loop_type = "goalBased", .state = "succeeded" }, + .{ .project_path = "A", .project_name = "UIA project", .node_id = "loop-b", .title = "UIA loop B", .loop_type = "proactive", .state = "running" }, + .{ .project_path = "B", .project_name = "Jump fixture", .node_id = "loop-c", .title = "UIA loop C", .loop_type = "timeBased", .state = "awaitingInput" }, + }; + var dialog = Dialog{ .state = State.init(allocator, &entries) }; + defer dialog.state.deinit(); + defer releaseDrawingResources(&dialog); + try dialog.state.filter(""); + try std.testing.expect(active == null); + active = &dialog; + defer active = null; + const hwnd = c.CreateWindowExW(0, class_name.ptr, null, c.WS_POPUP, 0, 0, 460, 380, null, null, c.GetModuleHandleW(null), null) orelse return error.PaletteCreationFailed; + defer _ = c.DestroyWindow(hwnd); + try std.testing.expect(c.IsWindowVisible(hwnd) == 0); + for ([_]struct { query: []const u8, count: usize, node_id: ?[]const u8 }{ + .{ .query = "UIA loop A", .count = 1, .node_id = "loop-a" }, + .{ .query = "UIA loop B", .count = 1, .node_id = "loop-b" }, + .{ .query = "UIA loop C", .count = 1, .node_id = "loop-c" }, + .{ .query = "", .count = 3, .node_id = "loop-a" }, + .{ .query = "no matching loop", .count = 0, .node_id = null }, + }) |case| { + const query = try utf8ToWideZ(allocator, case.query); + defer allocator.free(query); + try std.testing.expect(c.SendMessageW(dialog.edit, c.WM_SETTEXT, 0, @intCast(@intFromPtr(query.ptr))) != 0); + try std.testing.expectEqual(case.count, dialog.state.matches.items.len); + if (case.node_id) |id| try std.testing.expectEqualStrings(id, dialog.state.selectedEntry().?.node_id); + try dialog.state.filter("UIA loop B"); + try std.testing.expect(c.PostMessageW(hwnd, c.WM_APP + 43, 0, 0) != 0); + var message: c.MSG = undefined; + var refreshes: usize = 0; + while (c.PeekMessageW(&message, hwnd, c.WM_APP + 43, c.WM_APP + 43, c.PM_REMOVE) != 0) { + _ = c.DispatchMessageW(&message); + refreshes += 1; + } + try std.testing.expectEqual(@as(usize, 1), refreshes); + try std.testing.expect(dialog.failure == null); + try std.testing.expectEqual(case.count, dialog.state.matches.items.len); + try std.testing.expectEqual(@as(c.LRESULT, @intCast(case.count)), c.SendMessageW(dialog.list, c.LB_GETCOUNT, 0, 0)); + if (case.node_id) |id| { + try std.testing.expectEqualStrings(id, dialog.state.selectedEntry().?.node_id); + try std.testing.expect(handleKey(&dialog, c.VK_RETURN)); + try std.testing.expect(dialog.accepted and dialog.closed); + try std.testing.expectEqualStrings(id, dialog.state.selectedEntry().?.node_id); + dialog.accepted = false; + dialog.closed = false; + } else { + try std.testing.expect(handleKey(&dialog, c.VK_RETURN)); + try std.testing.expect(!dialog.accepted and !dialog.closed); + try std.testing.expect(dialog.state.selectedEntry() == null); + } + } +} + test "empty palette creates native search controls and cancels without a selection" { try registerClass(); var dialog = Dialog{ .state = State.init(std.testing.allocator, &.{}) }; @@ -652,17 +720,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) const command: u16 = @truncate(wparam); const notification: u16 = @truncate(wparam >> 16); if (command == search_id and notification == c.EN_CHANGE) { - const query = readWindowText(dialog.state.allocator, dialog.edit) catch |err| { - fail(dialog, err); - return 0; - }; - defer dialog.state.allocator.free(query); - dialog.state.filter(query) catch |err| { - fail(dialog, err); - return 0; - }; - refillList(dialog) catch |err| fail(dialog, err); - _ = c.InvalidateRect(hwnd, null, 1); + refreshQuery(hwnd, dialog); return 0; } if (command == results_id and notification == c.LBN_SELCHANGE) { @@ -685,11 +743,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) }, c.WM_APP + 43 => { if (!envFlag("GRAPHCODE_UIA_GATE")) return 0; - dialog.state.filter("UIA loop B") catch |err| { - fail(dialog, err); - return 0; - }; - refillList(dialog) catch |err| fail(dialog, err); + refreshQuery(hwnd, dialog); return 0; }, c.WM_CLOSE => { @@ -701,6 +755,20 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) return c.DefWindowProcW(hwnd, message, wparam, lparam); } +fn refreshQuery(hwnd: c.HWND, dialog: *Dialog) void { + const query = readWindowText(dialog.state.allocator, dialog.edit) catch |err| { + fail(dialog, err); + return; + }; + defer dialog.state.allocator.free(query); + dialog.state.filter(query) catch |err| { + fail(dialog, err); + return; + }; + refillList(dialog) catch |err| fail(dialog, err); + _ = c.InvalidateRect(hwnd, null, 1); +} + fn refillList(dialog: *Dialog) !void { _ = c.SendMessageW(dialog.list, c.LB_RESETCONTENT, 0, 0); for (dialog.state.matches.items) |match| { diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index b3c00ebe..957ba701 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -39,6 +39,23 @@ Statuses: approved. Optional additions use enhancement tracking; an unwanted extra's removal is not a request to add an enhancement. +## 2026-11-09 Jump acceptance preparation + +The Jump palette row remains **Partial** for [#799](https://github.com/scgopi/GraphCode/issues/799). +The shared gate now queries the finished Goal fixture, not the Composite fixture, +and requires its exact owned workspace loop bar, project toolbar, Show in Graph +action and selected sidebar loop row. Selection on the project canvas alone +cannot satisfy that oracle. Sixteen headless observation controls pass, including +wrong destination/owner, duplicate identity, missing workspace and unselected-row +refusals. The canonical compiled functional adapter passes 65 distinct units +(122 counted executions); its selected report is explicitly incomplete, not +full-host or native acceptance. The shell catalog remains 66 sections. + +No current-source desktop execution was admitted. Workspace mounting, actual +daemon resume, composite group destination, blocked-loop notice, native input +and paired visual parity remain unproved. These controls are acceptance +preparation, not evidence that the pending product implementation passed. + ## 2026-10-14 reference intake The primary local macOS recording is source