diff --git a/Tools/windows/FixtureCompiler.ps1 b/Tools/windows/FixtureCompiler.ps1 new file mode 100644 index 00000000..4da01e5a --- /dev/null +++ b/Tools/windows/FixtureCompiler.ps1 @@ -0,0 +1,127 @@ +function Resolve-CapFixtureCompiler { + param([string]$InputPath, [string]$InputSha256, [string]$Directory, [string]$Role) + + if ($InputPath -or $InputSha256) { + if (-not $InputPath -or $InputSha256 -cnotmatch '\A[A-F0-9]{64}\z' -or + -not [IO.Path]::IsPathFullyQualified($InputPath) -or + -not [IO.File]::Exists($InputPath)) { + throw 'FixtureCompilerInput: an absolute existing JSON path and uppercase SHA256 are required together.' + } + $bytes = [IO.File]::ReadAllBytes($InputPath) + if ([Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -cne $InputSha256) { + throw 'FixtureCompilerInput: input digest differs.' + } + try { $inputRecord = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop } + catch { throw 'FixtureCompilerInput: invalid JSON.' } + $fields = @('schemaVersion','installationPath','msvcVersion','compilerSha256','vcvarsSha256','sdkRoot','sdkVersion') + if ($inputRecord -isnot [pscustomobject] -or + @($inputRecord.PSObject.Properties).Count -ne $fields.Count -or + @($fields | Where-Object { $_ -cnotin $inputRecord.PSObject.Properties.Name }).Count -ne 0) { + throw 'FixtureCompilerInput: exact schema fields are required.' + } + if ($inputRecord.schemaVersion -isnot [long] -and $inputRecord.schemaVersion -isnot [int]) { + throw 'FixtureCompilerInput: schemaVersion must be an integer.' + } + if ($inputRecord.schemaVersion -ne 1 -or $inputRecord.msvcVersion -cnotmatch '\A\d+\.\d+\.\d+\z' -or + $inputRecord.sdkVersion -cnotmatch '\A\d+\.\d+\.\d+\.\d+\z') { + throw 'FixtureCompilerInput: unsupported schema or tool version.' + } + foreach ($field in $fields | Where-Object { $_ -cne 'schemaVersion' }) { + if ($inputRecord.$field -isnot [string] -or [string]::IsNullOrWhiteSpace($inputRecord.$field)) { + throw "FixtureCompilerInput: $field must be a nonempty string." + } + } + foreach ($field in @('installationPath','sdkRoot')) { + $path = $inputRecord.$field + if (-not [IO.Path]::IsPathFullyQualified($path) -or $path -match '[\r\n"%!&|<>^]' -or + -not [IO.Directory]::Exists($path)) { + throw "FixtureCompilerInput: $field must be an existing batch-safe absolute directory." + } + } + $installation = [IO.Path]::GetFullPath($inputRecord.installationPath) + $version = $inputRecord.msvcVersion + $vcvars = Join-Path $installation 'VC\Auxiliary\Build\vcvars64.bat' + $compiler = Join-Path $installation "VC\Tools\MSVC\$version\bin\Hostx64\x64\cl.exe" + foreach ($pair in @(@($compiler,'compilerSha256'),@($vcvars,'vcvarsSha256'))) { + if ($inputRecord.($pair[1]) -cnotmatch '\A[A-F0-9]{64}\z' -or -not [IO.File]::Exists($pair[0]) -or + (Get-FileHash -LiteralPath $pair[0] -Algorithm SHA256).Hash -cne $inputRecord.($pair[1])) { + throw "FixtureCompilerInput: $($pair[1]) does not bind the actual selected file." + } + } + foreach ($relative in @("Include\$($inputRecord.sdkVersion)\um\Windows.h", + "Include\$($inputRecord.sdkVersion)\ucrt\stdio.h", + "Lib\$($inputRecord.sdkVersion)\um\x64\kernel32.lib", + "Lib\$($inputRecord.sdkVersion)\ucrt\x64\ucrt.lib")) { + if (-not [IO.File]::Exists((Join-Path $inputRecord.sdkRoot $relative))) { + throw "FixtureCompilerInput: required SDK input is missing: $relative" + } + } + return [pscustomobject]@{ + mode = 'explicit'; inputPath = [IO.Path]::GetFullPath($InputPath); inputSha256 = $InputSha256 + installationPath = $installation; msvcVersion = $version; sdkVersion = $inputRecord.sdkVersion + sdkRoot = [IO.Path]::GetFullPath($inputRecord.sdkRoot); vcvars = $vcvars; compiler = $compiler + compilerSha256 = $inputRecord.compilerSha256; vcvarsSha256 = $inputRecord.vcvarsSha256 + discoveryDirectory = Join-Path $installation '..\..\Installer' + } + } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) { + throw 'Existing MSVC discovery is unavailable; this offline suite does not install a compiler.' + } + $discovery = Invoke-CapBoundedChild -FilePath $vswhere -Arguments @( + '-latest', '-products', '*', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', 'installationPath' + ) -Directory $Directory -Role $Role + if ($discovery.exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($discovery.stdout)) { + throw 'Existing MSVC was not found.' + } + $installation = $discovery.stdout.Trim() + $version = [IO.File]::ReadAllText((Join-Path $installation 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt')).Trim() + return [pscustomobject]@{ + mode = 'discovery'; inputPath = $null; inputSha256 = $null + installationPath = $installation; msvcVersion = $version; sdkVersion = $null + vcvars = Join-Path $installation 'VC\Auxiliary\Build\vcvars64.bat' + compiler = Join-Path $installation "VC\Tools\MSVC\$version\bin\Hostx64\x64\cl.exe" + discoveryDirectory = Split-Path -Parent $vswhere + } +} + +function Assert-CapFixtureCompilerEnvironment { + param($Selection, [string]$Stdout) + if ($Selection.mode -cne 'explicit') { return } + $values = @{} + foreach ($line in $Stdout -split '\r?\n') { + if ($line -cmatch '\A(VCToolsVersion|VCToolsInstallDir|WindowsSdkDir|WindowsSDKVersion|INCLUDE|LIB)=(.*)\z') { + if ($values.ContainsKey($Matches[1])) { throw 'FixtureCompilerInput: duplicate compiler environment observation.' } + $values[$Matches[1]] = $Matches[2] + } + } + foreach ($name in @('VCToolsVersion','VCToolsInstallDir','WindowsSdkDir','WindowsSDKVersion','INCLUDE','LIB')) { + if (-not $values.ContainsKey($name)) { throw "FixtureCompilerInput: actual vcvars environment missing $name." } + } + if ($values.VCToolsVersion -cne $Selection.msvcVersion -or + $values.VCToolsInstallDir.TrimEnd('\') -ine (Join-Path $Selection.installationPath "VC\Tools\MSVC\$($Selection.msvcVersion)") -or + $values.WindowsSdkDir.TrimEnd('\') -ine $Selection.sdkRoot.TrimEnd('\') -or + $values.WindowsSDKVersion.TrimEnd('\') -cne $Selection.sdkVersion) { + throw 'FixtureCompilerInput: actual vcvars compiler/SDK selection differs.' + } + foreach ($pair in @(@('INCLUDE',(Join-Path $Selection.sdkRoot "Include\$($Selection.sdkVersion)\um")), + @('INCLUDE',(Join-Path $Selection.sdkRoot "Include\$($Selection.sdkVersion)\ucrt")), + @('LIB',(Join-Path $Selection.sdkRoot "Lib\$($Selection.sdkVersion)\um\x64")), + @('LIB',(Join-Path $Selection.sdkRoot "Lib\$($Selection.sdkVersion)\ucrt\x64")), + @('INCLUDE',(Join-Path $Selection.installationPath "VC\Tools\MSVC\$($Selection.msvcVersion)\include")), + @('LIB',(Join-Path $Selection.installationPath "VC\Tools\MSVC\$($Selection.msvcVersion)\lib\x64")))) { + $expected = $pair[1] + $actualPaths = @($values.($pair[0]) -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { [IO.Path]::GetFullPath($_).TrimEnd('\') }) + if ($expected -inotin $actualPaths) { + throw "FixtureCompilerInput: actual $($pair[0]) does not contain the selected compiler/SDK inputs." + } + } + foreach ($pair in @(@($Selection.compiler,'compilerSha256'),@($Selection.vcvars,'vcvarsSha256'))) { + if ((Get-FileHash -LiteralPath $pair[0] -Algorithm SHA256).Hash -cne $Selection.($pair[1])) { + throw 'FixtureCompilerInput: selected tool bytes changed during compilation.' + } + } +} diff --git a/Tools/windows/Invoke-MatchedActionSupplement.ps1 b/Tools/windows/Invoke-MatchedActionSupplement.ps1 index 905e3990..28b21120 100644 --- a/Tools/windows/Invoke-MatchedActionSupplement.ps1 +++ b/Tools/windows/Invoke-MatchedActionSupplement.ps1 @@ -3,9 +3,14 @@ param( [Parameter(Mandatory)][string]$OwnedRoot, [Parameter(Mandatory)][string]$ActionPlanPath, [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ActionPlanSha256, - [switch]$DryRun + [switch]$DryRun, + [switch]$FunctionalWireOnly, + [switch]$WireCensusDiagnostic ) $ErrorActionPreference='Stop' +if ($WireCensusDiagnostic -and (-not $FunctionalWireOnly -or $DryRun)) { + throw 'Wire census diagnostic requires FunctionalWireOnly and refuses DryRun.' +} $root=[IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') $repositoryRoot=[IO.Path]::GetFullPath($Repository).TrimEnd('\') $planPath=[IO.Path]::GetFullPath($ActionPlanPath) @@ -14,6 +19,12 @@ if (-not $planPath.StartsWith($root + '\',[StringComparison]::OrdinalIgnoreCase) throw 'Action supplement plan path or hash mismatch.' } $plan=Get-Content $planPath -Raw | ConvertFrom-Json -DateKind String +$functional=[bool]($plan.PSObject.Properties['functionalWireOnly'] -and $plan.functionalWireOnly) +if ($functional -ne $FunctionalWireOnly.IsPresent -or ($functional -and + ($plan.group -cne 'WireCompletion' -or $plan.maximumResultPngs -ne 0 -or + @($plan.imageBindings.PSObject.Properties).Count -ne 0))) { + throw 'Functional Wire execution requires the explicit W1-only no-image opt-in.' +} if ($plan.status -cne 'PREPARED_NO_AUTHORITY' -or $plan.pngArtifacts -ne 0 -or $plan.processLaunches -ne 0 -or @(Get-ChildItem $root -Recurse -File -Filter '*.png').Count -ne 0) { throw 'Action supplement plan no-authority boundary differs.' @@ -29,6 +40,8 @@ $workerPath=Join-Path $repositoryRoot 'Tools\windows\Invoke-UiCaptureWorker.ps1' $stubPath=Join-Path $repositoryRoot 'Tools\windows\Stub-Daemon.ps1' $actionRunnerPath=$PSCommandPath $actionSeedHelperPath=Join-Path $repositoryRoot 'Tools\windows\Start-MatchedActionSessions.ps1' +$sceneInput=if ($functional) {@($plan.functionalWireState.path,$plan.functionalWireState.sha256)} + else {@($plan.existingSceneManifestPath,$plan.existingSceneManifestSha256)} foreach ($binding in @( @($modulePath,$plan.moduleSha256), @($workerPath,$plan.workerSha256), @@ -37,7 +50,7 @@ foreach ($binding in @( @($actionSeedHelperPath,$plan.actionSeedHelperSha256), @($plan.jobPath,$plan.jobSha256), @($plan.preflightReceiptPath,$plan.preflightReceiptSha256), - @($plan.existingSceneManifestPath,$plan.existingSceneManifestSha256) + @($sceneInput[0],$sceneInput[1]) )) { if (-not (Test-Path $binding[0] -PathType Leaf) -or (Get-FileHash $binding[0] -Algorithm SHA256).Hash -ne [string]$binding[1]) { @@ -88,7 +101,10 @@ $authorizationHandle=Open-CapActionAuthorization -Path $authorizationPath -Sha25 $selfProcess=$null try { $job=Get-Content $plan.jobPath -Raw | ConvertFrom-Json -DateKind String -if (-not $DryRun) {$matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job} +if ((Test-CapFunctionalWireJob -Job $job -Group $plan.group) -ne $functional) { + throw 'Functional Wire job and explicit execution opt-in differ.' +} +if (-not $DryRun -and -not $functional) {$matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job} $fixturePath=Join-Path $root 'fixtures\fixture.json' $peerScript=Join-Path $root 'inputs\offline-peer-source.ps1' $runDirectory=$actionRunDirectory @@ -103,6 +119,7 @@ $leaseWindow=$authorizationHandle.Window $leaseDeadlineUtc=$leaseWindow.WorkerDeadlineUtc $focusedCapture=$true $actionSupplementMode=$true +$wireCensusDiagnosticMode=$WireCensusDiagnostic.IsPresent $rehearsal=$false $transitionRehearsal=$false $fixtureHash=(Get-FileHash $fixturePath -Algorithm SHA256).Hash @@ -128,6 +145,7 @@ $dryRunLauncher=$null $dryRunProof=$null try { Assert-CapWorkerDeadline 'action entry' + if (-not $DryRun) {Initialize-CapUiaClient} $boundWindows=@(Assert-CapActionWindowContext) $wireTargetPreflight=if ($plan.group -cne 'TerminalActions') {Get-CapWorkerWireBinding} else {$null} $windowPreflight=@(foreach ($binding in $boundWindows) { @@ -194,9 +212,9 @@ try { geometry=$null preconditionsEvaluated=$true } - if (-not (Test-CapMatchedDpiAdapter)) {throw 'Action dry run requires the production matched DPI contract.'} + if (-not (Test-CapMatchedDpiAdapter) -and -not $functional) {throw 'Action dry run requires the production matched DPI contract.'} $dryRunProof.geometry=$windowPreflight[0].geometry - } else { + } elseif (-not $functional) { $matchedFixture=Get-Content $fixturePath -Raw | ConvertFrom-Json -DateKind String Initialize-CapMatchedWorktrees -Fixture $matchedFixture } @@ -214,11 +232,20 @@ try { throw 'Terminal action supplement receipt or provider count differs.' } } elseif ($plan.group -ceq 'WireCompletion') { - $receipt=Invoke-CapWireCompletionSupplement -StubDaemonPath $stubPath - $receipt | Add-Member -NotePropertyName actionIds -NotePropertyValue @('W1') - $receipt | Add-Member -NotePropertyName imageBindingSha256 ` - -NotePropertyValue ([string]$plan.imageBindings.'s-goal-unwired-unselected') - $actionSupplementReceipts.Add($receipt) + $receipt=Invoke-CapWireCompletionSupplement -StubDaemonPath $stubPath ` + -FirstCensusDiagnostic:$WireCensusDiagnostic + if ($WireCensusDiagnostic) { + if ($null -ne $receipt -or $actionSupplementReceipts.Count -ne 0 -or $images.Count -ne 0) { + throw 'Wire census diagnostic produced an action receipt or image.' + } + } else { + $receipt | Add-Member -NotePropertyName actionIds -NotePropertyValue @('W1') + if (-not $functional) { + $receipt | Add-Member -NotePropertyName imageBindingSha256 ` + -NotePropertyValue ([string]$plan.imageBindings.'s-goal-unwired-unselected') + } + $actionSupplementReceipts.Add($receipt) + } } else { $wireReceipt=Invoke-CapWireCompletionSupplement -StubDaemonPath $stubPath $wireReceipt | Add-Member -NotePropertyName actionIds -NotePropertyValue @('W1') @@ -272,8 +299,9 @@ try { [StringComparison]::OrdinalIgnoreCase) } | Select-Object ProcessId,Name,ExecutablePath) } +$successfulStatus=if ($WireCensusDiagnostic) {'DIAGNOSTIC_NOT_W1_ACCEPTANCE'} else {'COMPLETE'} $terminalOutcome=if (-not $primaryFailure -and $cleanupFailures.Count -eq 0 -and - $independent.Count -eq 0) {'COMPLETE'} else {'FAILED'} + $independent.Count -eq 0) {$successfulStatus} else {'FAILED'} try { Write-CapWorkerJournalSnapshot -Kind 'Terminal' -Outcome $terminalOutcome } catch { @@ -283,13 +311,15 @@ try { $manifest=[ordered]@{ schemaVersion=1L status=$(if (-not $primaryFailure -and $cleanupFailures.Count -eq 0 -and - $independent.Count -eq 0) {'COMPLETE'} else {'FAILED'}) + $independent.Count -eq 0) {$successfulStatus} else {'FAILED'}) group=$plan.group sourceHead=$head sourceTree=$tree actionPlanSha256=$ActionPlanSha256.ToUpperInvariant() authorizationSha256=$authorizationHandle.Sha256 - executionMode=$(if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} else {'MATCHED_ACTION_SUPPLEMENT'}) + executionMode=$(if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} + elseif ($WireCensusDiagnostic) {'DIAGNOSTIC_NOT_W1_ACCEPTANCE'} + elseif ($functional) {'FUNCTIONAL_W1_STATE_ONLY_NOT_VISUAL_PARITY'} else {'MATCHED_ACTION_SUPPLEMENT'}) dryRun=$dryRunProof startedUtc=$startedUtc.ToString('o') finishedUtc=[DateTime]::UtcNow.ToString('o') @@ -316,7 +346,7 @@ $manifestPath=Join-Path $output 'action-manifest.json' [Text.UTF8Encoding]::new($false)) $release=[ordered]@{ schemaVersion=1L - status=$(if ($manifest.status -ceq 'COMPLETE') {'RELEASED_ZERO_SURVIVORS'} + status=$(if ($manifest.status -ceq $successfulStatus) {'RELEASED_ZERO_SURVIVORS'} elseif ($independent.Count -eq 0) {'FAILED_ZERO_SURVIVORS'} else {'CLEANUP_UNPROVEN'}) cleanupState=$(if ($runs.Count -eq 0 -and $independent.Count -eq 0 -and $cleanupFailures.Count -eq 0) { 'NO_PROCESS_LAUNCHES' @@ -335,7 +365,7 @@ $release=[ordered]@{ $releasePath=Join-Path $output 'action-release.json' [IO.File]::WriteAllText($releasePath,($release|ConvertTo-Json -Depth 20), [Text.UTF8Encoding]::new($false)) -if ($manifest.status -cne 'COMPLETE' -or $release.status -cne 'RELEASED_ZERO_SURVIVORS') { +if ($manifest.status -cne $successfulStatus -or $release.status -cne 'RELEASED_ZERO_SURVIVORS') { throw "Action supplement failed: $($manifest.failures -join '; ') cleanup=$($cleanupFailures -join '; ') survivors=$($independent.Count)" } if ($manifest.pngCount -ne $(if ($DryRun) {0L} else {[long]$plan.maximumResultPngs})) { diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index 7557aae6..04cc6c8b 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -1420,11 +1420,15 @@ try { $workerLaunchAttempted = $true [CapProcessJob]::ClearStandardHandleInheritance() $workerJob = [CapProcessJob]::Create() + $workerEnvironment=@{} + if ([Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY')) { + $workerEnvironment['GRAPHCODE_VALIDATION_ASSEMBLY']=$env:GRAPHCODE_VALIDATION_ASSEMBLY + } $workerRun = Start-CapManagedProcess -FilePath $pwshPath -ArgumentList @( '-NoProfile', '-File', $workerPath, '-AuthorizationPath', $authorizationPath, '-AuthorizationSha256', $authorizationHash, '-OwnedRoot', $root, '-AwaitSupervisorGate' - ) -WorkingDirectory $runDirectory -Role 'b-capture-worker' -HoldStdin + ) -WorkingDirectory $runDirectory -Role 'b-capture-worker' -HoldStdin -Environment $workerEnvironment [CapProcessJob]::Assign($workerJob, $workerRun.Identity.Process.Handle) $journalBinding = New-CapJournalBinding -RunId ([IO.Path]::GetFileName($runDirectory)) ` -LeaseId $authorityId -JobSha256 $jobHash -ModuleSha256 $moduleHash ` diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 3dd9dc37..4deae16e 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -14,6 +14,7 @@ $rehearsal = $false $transitionRehearsal = $false $focusedCapture = $false $actionSupplementMode = $false +$wireCensusDiagnosticMode = $false $matchedCapturePlan = $null $rehearsalLaunches = [Collections.Generic.List[object]]::new() $rehearsalActions = [Collections.Generic.List[object]]::new() @@ -145,6 +146,19 @@ function Assert-CapActionWindowContext { throw 'Action window binding in-memory plan/job differs from bound bytes.' } } + if (Test-CapFunctionalWireJob -Job $job -Group $plan.group) { + $repositoryRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) + $contract=$job.functionalWireContract + if ($contract.windowSourcePath -cne (Join-Path $repositoryRoot 'graphcode-windows\src\MainWindow.zig')) { + throw 'Functional Wire requires the current owned repository window source.' + } + foreach ($revision in @('HEAD',$job.binaries.after.binaryBuiltAtSource)) { + $appTree=(& git -C $repositoryRoot rev-parse ($revision+':graphcode-windows')).Trim() + if ($LASTEXITCODE -ne 0 -or $appTree -cne $contract.applicationSourceTree) { + throw 'Functional Wire binary/application input equivalence changed.' + } + } + } return @(Assert-CapActionWindowBindings -Job $job -Plan $plan) } finally {$held.Pin.Dispose()} } @@ -152,8 +166,19 @@ function Assert-CapActionWindowContext { function Get-CapWorkerWireBinding { $null=Assert-CapActionWindowContext $binding=if ($plan.PSObject.Properties['wireTargetBinding']) {$plan.wireTargetBinding} else {$null} - return Assert-CapWireTargetBinding -Binding $binding ` + $bound=Assert-CapWireTargetBinding -Binding $binding ` -Repository ([IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))) -OwnedRoot $root + if (Test-CapFunctionalWireJob -Job $job -Group $plan.group) { + $state=Read-CapBoundJson -Root $root -Reference $plan.functionalWireState + $expected=Get-CapFunctionalWireStatePlan -WireBinding $bound -SourceHead $plan.sourceHead ` + -WorkerSha256 $plan.workerSha256 -StubSha256 $plan.stubDaemonSha256 + if (-not [Text.Json.Nodes.JsonNode]::DeepEquals( + [Text.Json.Nodes.JsonNode]::Parse(($state|ConvertTo-Json -Depth 30 -Compress)), + [Text.Json.Nodes.JsonNode]::Parse(($expected|ConvertTo-Json -Depth 30 -Compress)))) { + throw 'Functional Wire fresh state provenance differs.' + } + } + return $bound } function Get-CapWorkerWindowIdentity { @@ -177,6 +202,14 @@ function Get-CapMatchedWindowGeometry { param([Parameter(Mandatory)][string]$Phase,[string]$SceneName='') $identity=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName $area=Get-CapPrimaryPhysicalWorkArea + if (Test-CapFunctionalWireJob -Job $job) { + if ((Get-FileHash -LiteralPath $identity.sourcePath).Hash -cne $identity.sourceSha256) { + throw 'Functional Wire window source changed.' + } + return [pscustomobject]@{kind='OWNED_WINDOW_MEASUREMENT_REQUIRED' + WorkArea=@($area.Left,$area.Top,$area.Right,$area.Bottom) + actualDpi=$null;runtimeWindowEvaluated=$false;visualParityClaimed=$false} + } $contract = $job.captureContract return Get-CapDpiGeometry -LogicalClientWH @($contract.logicalClientWH) ` -ExpectedDpi ([uint32]$contract.expectedWindowDpi) ` @@ -290,8 +323,8 @@ function Write-CapWorkerJournalSnapshot { $progressManifest=if ($actionSupplementMode) { [pscustomobject][ordered]@{ schemaVersion=1L - executionMode='MATCHED_ACTION_SUPPLEMENT' - evidenceClassification='BOUNDED_ACTION_EVIDENCE_NOT_STATIC_CAPTURE' + executionMode=$(if ($wireCensusDiagnosticMode) {'DIAGNOSTIC_NOT_W1_ACCEPTANCE'} else {'MATCHED_ACTION_SUPPLEMENT'}) + evidenceClassification=$(if ($wireCensusDiagnosticMode) {'DIAGNOSTIC_NOT_W1_ACCEPTANCE'} else {'BOUNDED_ACTION_EVIDENCE_NOT_STATIC_CAPTURE'}) status=$Outcome actionReceiptCount=[long]$actionSupplementReceipts.Count providerReceiptCount=[long]$zmxSeedReceipts.Count @@ -532,7 +565,7 @@ function Get-CapBaseEnvironment { ) Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters - if (Test-CapMatchedAdapter) { + if ((Test-CapMatchedAdapter) -or (Test-CapFunctionalWireJob -Job $job)) { $project=Assert-CapOwnedPath (Join-Path $root 'fixtures\projects\Matched') $null=New-Item -ItemType Directory -Path $project -Force return @{ @@ -1030,7 +1063,7 @@ function Send-CapMatchedEscape { $foreground=if ($ForegroundProbe) { [bool](& $ForegroundProbe $AppRun.Window) } else { - [GraphCodeUiaGateState]::IsForegroundWindow($AppRun.Window) + [CapUiaGateState]::IsForegroundWindow($AppRun.Window) } if (-not $foreground) { throw 'Wire cancellation requires the owned GraphCode shell foreground.' @@ -1038,7 +1071,7 @@ function Send-CapMatchedEscape { $dialog=if ($DialogProbe) { [IntPtr](& $DialogProbe $AppRun.Run.Identity.Pid) } else { - [GraphCodeUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$AppRun.Run.Identity.Pid) + [CapUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$AppRun.Run.Identity.Pid) } if ($dialog -ne [IntPtr]::Zero) { throw 'Wire cancellation refuses to inject Escape while an owned native dialog is present.' @@ -1046,7 +1079,7 @@ function Send-CapMatchedEscape { $sent=if ($KeyboardSender) { [bool](& $KeyboardSender $AppRun.Window 0x1B) } else { - [GraphCodeUiaGateState]::PostKeyboard($AppRun.Window,0x1B) + [CapUiaGateState]::PostKeyboard($AppRun.Window,0x1B) } if (-not $sent) { throw 'Wire cancellation Escape injection was rejected.' @@ -1170,6 +1203,7 @@ function Save-CapSceneImage { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters Assert-CapWorkerDeadline "before image $Phase/$SceneName" + if (Test-CapFunctionalWireJob -Job $job) {throw 'Functional Wire does not authorize screenshots.'} $metrics = Get-CapWindowMetrics -Window $AppRun.Window -ProcessIdentity $AppRun.Run.Identity $expectedOuter = @(30, 30, 1486, 989) $isD=$job.PSObject.Properties['sceneAdapter'] -and $job.sceneAdapter -ceq 'D_QUICK_CHAT_V1' @@ -1413,7 +1447,7 @@ function Start-CapGraphCodeApp { $arguments = if ($rehearsal) { [string[]]@($job.rehearsalTarget.arguments) } else { [string[]]@() } if ($rehearsal) { $role = 'rehearsal-target' } $isD=$job.PSObject.Properties['sceneAdapter'] -and $job.sceneAdapter -ceq 'D_QUICK_CHAT_V1' - $isS=Test-CapMatchedAdapter + $isS=(Test-CapMatchedAdapter) -or (Test-CapFunctionalWireJob -Job $job) $workingDirectory = if ($rehearsal) {$Roots.Peer} elseif ($isS) {Join-Path $root 'fixtures\projects\Matched'} elseif ($isD) {Join-Path $root 'fixtures\projects\D Offline Capture Fixture'} else {Join-Path $root 'fixtures\projects\Alpha'} if (-not $rehearsal) { $shellSession = 'graphcode-' + $SessionPrefix + '-' + [guid]::Empty.ToString('D') @@ -1463,6 +1497,18 @@ function Start-CapGraphCodeApp { Set-CapWindowPosition -Window $window -ProcessIdentity $run.Identity -X $placement.x -Y $placement.y -Width $placement.width -Height $placement.height $appRun | Add-Member -NotePropertyName Placement -NotePropertyValue $placement $appRun | Add-Member -NotePropertyName Roots -NotePropertyValue $Roots + } elseif (Test-CapFunctionalWireJob -Job $job) { + $beforeMetrics=Get-CapWindowMetrics -Window $window -ProcessIdentity $run.Identity + $identity=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName + $area=Get-CapPrimaryPhysicalWorkArea + $placement=Get-CapFunctionalWirePlacement -Identity $identity -Metrics $beforeMetrics ` + -WorkArea @($area.Left,$area.Top,$area.Right,$area.Bottom) + $geometry=$placement.Geometry + Set-CapWindowPosition -Window $window -ProcessIdentity $run.Identity ` + -X $geometry.OuterBounds[0] -Y $geometry.OuterBounds[1] ` + -Width $geometry.OuterWH[0] -Height $geometry.OuterWH[1] + $appRun | Add-Member -NotePropertyName FunctionalPlacement -NotePropertyValue $placement + $appRun | Add-Member -NotePropertyName Roots -NotePropertyValue $Roots } elseif (Test-CapMatchedDpiAdapter) { $beforeMetrics=Get-CapWindowMetrics -Window $window -ProcessIdentity $run.Identity $contract=$job.captureContract @@ -1477,7 +1523,9 @@ function Start-CapGraphCodeApp { } else {Set-CapWindowPosition -Window $window -ProcessIdentity $run.Identity} Start-Sleep -Milliseconds 500 $metrics = Get-CapWindowMetrics -Window $window -ProcessIdentity $run.Identity - if (Test-CapMatchedDpiAdapter) { + if (Test-CapFunctionalWireJob -Job $job) { + $null=Assert-CapFunctionalWireWindow -AppRun $appRun + } elseif (Test-CapMatchedDpiAdapter) { $geometry=$appRun.Placement $identity=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName $null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $identity ` @@ -1623,7 +1671,15 @@ function Close-CapGraphCodeApp { try { $null = Get-CapTreeForApp -AppRun $AppRun } catch { - $cleanupFailures.Add("Post-exit descendant discovery failed for PID $($run.Identity.Pid): $($_.Exception.Message)") + $discoveryFailure=$_ + $AppRun | Add-Member -NotePropertyName PostExitDiscoveryFailure -NotePropertyValue $discoveryFailure -Force + try { + Write-CapTrace -Command 'PostExitDescendantDiscoveryFailure' -Parameters @{ + pid=$run.Identity.Pid;creationFileTimeUtc=$run.Identity.CreationFileTimeUtc;role=$run.Identity.Role + error=$discoveryFailure.Exception.ToString();scriptStack=$discoveryFailure.ScriptStackTrace + nativeContext='HELD_IDENTITY_ALREADY_OBSERVED_NO_ADDITIONAL_QUERY'} + } catch {$discoveryFailure.Exception.Data['PostExitDiscoveryTraceFailure']=$_} + $cleanupFailures.Add("Post-exit descendant discovery failed for PID $($run.Identity.Pid): $($discoveryFailure.Exception.Message)") } foreach ($identity in @($AppRun.Descendants | Sort-Object CreationFileTimeUtc -Descending)) { try { @@ -2274,7 +2330,7 @@ function Invoke-CapMatchedTerminalInputChallenge { $pane=$panes[$FocusedIndex] $pane.SetFocus() Start-Sleep -Milliseconds 100 - if (-not [GraphCodeUiaGateState]::IsForegroundWindow($AppRun.Window) -or + if (-not [CapUiaGateState]::IsForegroundWindow($AppRun.Window) -or -not $pane.Current.HasKeyboardFocus) { throw 'Terminal input challenge requires the owned shell and pane focus.' } @@ -2285,8 +2341,8 @@ function Invoke-CapMatchedTerminalInputChallenge { $prefix="GC_${nonce}_" $expectedArithmetic=323L $command="powershell.exe -NoProfile -NonInteractive -Command `"`$v=17*19;Write-Output ('$prefix'+`$PID+'_'+`$v)`"" - $characters=[GraphCodeUiaGateState]::SendUnicodeText($command) - $enter=[GraphCodeUiaGateState]::SendKeyInput(0x0D,1) + $characters=[CapUiaGateState]::SendUnicodeText($command) + $enter=[CapUiaGateState]::SendKeyInput(0x0D,1) if ($characters -ne $command.Length -or $enter -ne 1) { throw 'Terminal input challenge native SendInput count differs.' } @@ -2393,7 +2449,7 @@ function Invoke-CapMatchedTerminalSceneSupplement { $bounds=@($alternate[0].closeBounds) $x=[int](($bounds[0]+$bounds[2])/2) $y=[int](($bounds[1]+$bounds[3])/2) - $pointer=@([GraphCodeUiaGateState]::MovePointerScreen($x,$y)) + $pointer=@([CapUiaGateState]::MovePointerScreen($x,$y)) Start-Sleep -Milliseconds 250 if ($pointer.Count -ne 4 -or $pointer[0] -ne $x -or $pointer[1] -ne $y -or $pointer[3] -ne $AppRun.Run.Identity.Pid) { @@ -2422,7 +2478,7 @@ function Invoke-CapMatchedTerminalSceneSupplement { $before=& $observeTabs 1 0 $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-close-tab' -SubjectId '0' Start-Sleep -Milliseconds 250 - $form=[GraphCodeUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$AppRun.Run.Identity.Pid) + $form=[CapUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$AppRun.Run.Identity.Pid) $result='REFUSED_TAB_RETAINED' $confirmation=$null if ($form -ne [IntPtr]::Zero) { @@ -2461,6 +2517,22 @@ function Invoke-CapMatchedTerminalSceneSupplement { return [pscustomobject]$receipt } +function Assert-CapFunctionalWireWindow { + param([Parameter(Mandatory)]$AppRun) + $descriptor=Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected' + if ((Get-FileHash -LiteralPath $descriptor.sourcePath).Hash -cne $descriptor.sourceSha256) { + throw 'Functional Wire source changed after startup.' + } + $area=Get-CapPrimaryPhysicalWorkArea + $placement=$AppRun.FunctionalPlacement + if ((@($area.Left,$area.Top,$area.Right,$area.Bottom) -join ',') -cne ($placement.WorkArea -join ',')) { + throw 'Functional Wire measured work area changed.' + } + $metrics=Get-CapWindowMetrics -Window $AppRun.Window -ProcessIdentity $AppRun.Run.Identity + return Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $placement.Identity ` + -ExpectedOuterBounds $placement.Geometry.OuterBounds +} + function Read-CapWireTargetObservation { param([Parameter(Mandatory)]$AppRun,[Parameter(Mandatory)]$Binding) $null=Assert-CapRootProcess -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity @@ -2476,9 +2548,13 @@ function Read-CapWireTargetObservation { RuntimeId=@($_.GetRuntimeId());Bounds=@($b.Left,$b.Top,$b.Right,$b.Bottom)} }) $metrics=Get-CapWindowMetrics -Window $AppRun.Window -ProcessIdentity $AppRun.Run.Identity - $identity=Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName $Binding.scene - $geometry=Get-CapMatchedWindowGeometry -Phase 'wire-completion' -SceneName $Binding.scene - $null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $identity -ExpectedOuterBounds $geometry.OuterBounds + if (Test-CapFunctionalWireJob -Job $job) { + $metrics=Assert-CapFunctionalWireWindow -AppRun $AppRun + } else { + $identity=Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName $Binding.scene + $geometry=Get-CapMatchedWindowGeometry -Phase 'wire-completion' -SceneName $Binding.scene + $null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $identity -ExpectedOuterBounds $geometry.OuterBounds + } $observation=Get-CapWireTargetObservation -Binding $Binding -Rows $rows ` -ExpectedProcessId $AppRun.Run.Identity.Pid -Dpi $metrics.Dpi Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters @{ @@ -2505,8 +2581,79 @@ function Read-CapWireWindowObservation { return $hierarchy } +function Read-CapReadyWireWindowObservation { + param([Parameter(Mandatory)]$AppRun,[Parameter(Mandatory)][DateTime]$DeadlineUtc, + [scriptblock]$NowProvider,[scriptblock]$SleepAction) + $now={if ($NowProvider) {return & $NowProvider};return [DateTime]::UtcNow} + while ((& $now) -lt $DeadlineUtc) { + Assert-CapWorkerDeadline 'Wire completion native form' + $structure=@(Get-CapOwnedWindowStructure -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity) + $ready=Resolve-CapOwnedSheet -Rows $structure -RootHandle $AppRun.Window ` + -ExpectedProcessId $AppRun.Run.Identity.Pid + if ((& $now) -ge $DeadlineUtc) {break} + if ($null -ne $ready) { + Assert-CapWorkerDeadline 'Wire completion native form' + $hierarchy=@(Read-CapWireWindowObservation -AppRun $AppRun -Stage 'after-posted-target') + Assert-CapWorkerDeadline 'Wire completion native form' + if ((& $now) -ge $DeadlineUtc) {break} + $sheet=Resolve-CapOwnedSheet -Rows $hierarchy -RootHandle $AppRun.Window ` + -ExpectedProcessId $AppRun.Run.Identity.Pid + if ($null -eq $sheet -or $sheet.host.Handle -ne $ready.host.Handle -or + $sheet.content.Handle -ne $ready.content.Handle) { + throw 'Owned wire sheet changed between structural readiness and full observation.' + } + return [pscustomobject]@{sheet=$sheet;hierarchy=$hierarchy} + } + $remaining=($DeadlineUtc-(& $now)).TotalMilliseconds + if ($remaining -le 0) {break} + $milliseconds=[int][Math]::Min(100,[Math]::Ceiling($remaining)) + if ($SleepAction) {& $SleepAction $milliseconds} + else {Start-Sleep -Milliseconds $milliseconds} + } + throw 'Wire supplement target did not expose an owned sheet and native edge child before its action deadline.' +} + +function Assert-CapWireCreateAction { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Creates) + $observedUtc=[DateTime]::UtcNow.ToString('o') + $candidates=[Collections.Generic.List[object]]::new() + $enabled='UNAVAILABLE_NOT_READ' + $offscreen='UNAVAILABLE_NOT_READ' + try { + foreach ($element in $Creates) { + $current=$element.get_Current() + $type=$current.get_ControlType() + $candidates.Add([pscustomobject]@{name=$current.get_Name();processId=$current.get_ProcessId() + controlTypeId=$type.Id;controlTypeName=$type.ProgrammaticName + nativeHandle=$current.get_NativeWindowHandle();runtimeId=@($element.GetRuntimeId())}) + } + if ($Creates.Count -eq 1) { + $enabled=$Creates[0].get_Current().get_IsEnabled() + if ($enabled) {$offscreen=$Creates[0].get_Current().get_IsOffscreen()} + } + Write-CapTrace -Command 'WireCreatePredicateObservation' -Parameters @{ + stage='FILTERED_SECOND_READ';observedUtc=$observedUtc;count=$Creates.Count + candidates=($candidates|ConvertTo-Json -Depth 5 -Compress);enabled=$enabled;offscreen=$offscreen} + if ($Creates.Count -ne 1 -or -not $enabled -or $offscreen) { + throw 'Wire supplement native edge form omitted its enabled Create action.' + } + } catch { + $primary=$_ + try { + Write-CapTrace -Command 'WireCreatePredicateFailure' -Parameters @{ + stage='FILTERED_SECOND_READ';observedUtc=$observedUtc;count=$Creates.Count + candidates=($candidates|ConvertTo-Json -Depth 5 -Compress);enabled=$enabled;offscreen=$offscreen + propertyEvidence='INCOMPLETE_ON_ERROR_NOT_DEFAULTED';error=$primary.Exception.ToString()} + } catch {$primary.Exception.Data['WireCreateTraceFailure']=$_} + throw $primary + } +} + function Invoke-CapWireCompletionSupplement { - param([Parameter(Mandatory)][string]$StubDaemonPath) + param([Parameter(Mandatory)][string]$StubDaemonPath,[switch]$FirstCensusDiagnostic) + if ($FirstCensusDiagnostic -and -not (Test-CapFunctionalWireJob -Job $job -Group $plan.group)) { + throw 'First census diagnostic requires the functional Wire job.' + } $phase='wire-completion' $null=Get-CapWorkerWindowIdentity -Phase $phase -SceneName 's-goal-unwired-unselected' $binding=Get-CapWorkerWireBinding @@ -2555,33 +2702,50 @@ function Invoke-CapWireCompletionSupplement { -ObservedProcessIds @($heldRootElement.Current.ProcessId) $null=Assert-CapRootProcess -RootHandle $app.Window -ProcessIdentity $app.Run.Identity $targetX=$reacquired.screenPoint[0];$targetY=$reacquired.screenPoint[1] + $sourceX=$reacquired.sourceScreenPoint[0];$sourceY=$reacquired.sourceScreenPoint[1] Write-CapTrace -Command 'WirePointerDispatchIntent' -Parameters @{ root=[long]$app.Window;process=$app.Run.Identity.Pid;generation=$app.Run.Identity.CreationFileTimeUtc target=($reacquired|ConvertTo-Json -Depth 20 -Compress) beforeHierarchy=($beforeHierarchy|ConvertTo-Json -Depth 10 -Compress) rootRuntimeId=($rootRuntime|ConvertTo-Json -Compress)} - $dispatch=[CapNative]::PostPointerClickScreen($app.Window,$targetX,$targetY) + $dispatch=[CapNative]::PostPointerDragScreen($app.Window,$sourceX,$sourceY,$targetX,$targetY) Write-CapTrace -Command 'WirePointerDispatchResult' -Parameters @{ receipt=($dispatch|ConvertTo-Json -Depth 10 -Compress)} $sheet=$null $deadline=Get-CapActionDeadline -WorkerDeadlineUtc $leaseDeadlineUtc -MaximumWaitSeconds 5 - do { + if ($FirstCensusDiagnostic) { $hierarchy=@(Read-CapWireWindowObservation -AppRun $app -Stage 'after-posted-target') - $sheet=Resolve-CapOwnedSheet -Rows $hierarchy -RootHandle $app.Window ` - -ExpectedProcessId $app.Run.Identity.Pid - if ($null -ne $sheet) {break} - Assert-CapWorkerDeadline 'Wire completion native form';Start-Sleep -Milliseconds 100 - } while ([DateTime]::UtcNow -lt $deadline) - if ($null -eq $sheet) {throw 'Wire supplement target did not expose an owned sheet and native edge child; see hierarchy census.'} + Write-CapTrace -Command 'WireCensusDiagnostic' -Parameters @{ + status='DIAGNOSTIC_NOT_W1_ACCEPTANCE';rows=($hierarchy|ConvertTo-Json -Depth 10 -Compress)} + return + } + $observation=Read-CapReadyWireWindowObservation -AppRun $app -DeadlineUtc $deadline + $hierarchy=@($observation.hierarchy) + $sheet=$observation.sheet $formElement=[Windows.Automation.AutomationElement]::FromHandle([IntPtr]$sheet.host.Handle) $modalRuntime=@($formElement.GetRuntimeId()) + $modalObservedUtc=[DateTime]::UtcNow.ToString('o') $modalControls=@($formElement.FindAll([Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition)|ForEach-Object { - $c=$_.Current - [pscustomobject]@{name=$c.Name;id=$c.AutomationId;processId=$c.ProcessId - class=$c.ClassName;enabled=$c.IsEnabled;offscreen=$c.IsOffscreen} + try { + $c=$_.get_Current() + $type=$c.get_ControlType() + [pscustomobject]@{name=$c.get_Name();id=$c.get_AutomationId();processId=$c.get_ProcessId() + class=$c.get_ClassName();enabled=$c.get_IsEnabled();offscreen=$c.get_IsOffscreen() + controlTypeId=$type.Id;controlTypeName=$type.ProgrammaticName + nativeHandle=$c.get_NativeWindowHandle();runtimeId=@($_.GetRuntimeId())} + } catch { + $primary=$_ + try { + Write-CapTrace -Command 'WireOwnedModalPropertyFailure' -Parameters @{ + stage='UNFILTERED_FIRST_READ';observedUtc=$modalObservedUtc + propertyEvidence='UNAVAILABLE_ON_ERROR';error=$primary.Exception.ToString()} + } catch {$primary.Exception.Data['WireCreateTraceFailure']=$_} + throw $primary + } }) Write-CapTrace -Command 'WireOwnedModalControls' -Parameters @{ + stage='UNFILTERED_FIRST_READ';observedUtc=$modalObservedUtc sheet=($sheet|ConvertTo-Json -Depth 10 -Compress) controls=($modalControls|ConvertTo-Json -Depth 10 -Compress)} $null=Assert-CapUiaProcessIds -ExpectedProcessId $app.Run.Identity.Pid ` @@ -2593,9 +2757,7 @@ function Invoke-CapWireCompletionSupplement { [Windows.Automation.PropertyCondition]::new( [Windows.Automation.AutomationElement]::NameProperty,'Create')) $creates=@($formElement.FindAll([Windows.Automation.TreeScope]::Descendants,$buttonCondition)) - if ($creates.Count -ne 1 -or -not $creates[0].Current.IsEnabled -or $creates[0].Current.IsOffscreen) { - throw 'Wire supplement native edge form omitted its enabled Create action.' - } + Assert-CapWireCreateAction -Creates $creates $latest=Resolve-CapOwnedSheet -Rows @( Get-CapOwnedWindowHierarchy -RootHandle $app.Window -ProcessIdentity $app.Run.Identity) ` -RootHandle $app.Window -ExpectedProcessId $app.Run.Identity.Pid @@ -2604,6 +2766,11 @@ function Invoke-CapWireCompletionSupplement { (@([Windows.Automation.AutomationElement]::FromHandle([IntPtr]$latest.host.Handle).GetRuntimeId()) -join ',')) { throw 'Owned wire sheet was replaced before Create.' } + $endpoints=Read-UiaReadonlyEdgeEndpoints -Dialog $formElement -ProcessId $app.Run.Identity.Pid ` + -Owner $app.Window -Title 'Create or edit edge' -ExpectedSource $sourceId -ExpectedTarget $targetId + if ($endpoints.source -cne $sourceId -or $endpoints.target -cne $targetId) { + throw 'Owned wire endpoint values differ from the bound source and target before Create.' + } $creates[0].GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke() $stubResult=$null $deadline=[DateTime]::UtcNow.AddSeconds(5) @@ -2651,11 +2818,13 @@ function Invoke-CapWireCompletionSupplement { wireAutomationId=$wireId armedStatus=$reacquired.mode targetScreenPoint=@($targetX,$targetY) + sourceConnectorScreenPoint=@($sourceX,$sourceY) targetObservation=$reacquired pointerDispatch=$dispatch windowHierarchyBefore=$beforeHierarchy windowHierarchyAfter=$hierarchy modalControls=$modalControls + readonlyEndpoints=$endpoints.snapshot edgeId=$edge.id edgeSpec=[pscustomobject]@{ kind=$edge.kind;condition=$edge.condition @@ -2673,10 +2842,24 @@ function Invoke-CapWireCompletionSupplement { requestIds=@($stubResult.appliedEdgeCreateRequests) correlated=$stubResult.unansweredRequests.Count -eq 0 } + if (Test-CapFunctionalWireJob -Job $job) { + $receipt | Add-Member -NotePropertyName sceneState -NotePropertyValue ([pscustomobject]@{ + evidenceMode='FRESH_OWNED_UIA_STATE_AND_CORRELATED_EDGE_NOT_VISUAL_PARITY' + plan=$plan.functionalWireState + processId=$app.Run.Identity.Pid;creationFileTimeUtc=$app.Run.Identity.CreationFileTimeUtc + stubProcessId=$stub.Identity.Pid;stubCreationFileTimeUtc=$stub.Identity.CreationFileTimeUtc + rootRuntimeId=$rootRuntime;beforeArmHierarchy=$beforeHierarchy;armedCards=$reacquired + placement=$app.FunctionalPlacement;finalMetrics=(Assert-CapFunctionalWireWindow -AppRun $app) + imageEvidence=$false;visualParityClaimed=$false + }) + } return $receipt } catch { $primary=$_ - Write-CapTrace -Command 'WireSupplementPrimaryFailure' -Parameters @{error=$_.Exception.ToString()} + $cause=$_.Exception + while ($cause.InnerException) {$cause=$cause.InnerException} + Write-CapTrace -Command 'WireSupplementPrimaryFailure' -Parameters @{error=$_.Exception.ToString() + pointerDragReceipt=($cause.Data['PointerDragReceipt']|ConvertTo-Json -Depth 10 -Compress)} throw } finally { $wireCleanupErrors=[Collections.Generic.List[object]]::new() @@ -3246,7 +3429,7 @@ function Set-CapMatchedTabHover { $x=[int](($Bounds[0]+$Bounds[2])/2-$metrics.OuterBounds[0]-$border) $y=[int](($Bounds[1]+$Bounds[3])/2-$metrics.OuterBounds[1]-$topFrame) if ($x -lt 0 -or $y -lt 0 -or $x -ge $metrics.ClientBounds[2] -or $y -ge $metrics.ClientBounds[3]) {throw 'Matched hover escaped the owned client area.'} - if (-not [GraphCodeUiaGateState]::PostHover($AppRun.Window,$x,$y)) {throw 'Posting owned tab hover failed.'} + if (-not [CapUiaGateState]::PostHover($AppRun.Window,$x,$y)) {throw 'Posting owned tab hover failed.'} Start-Sleep -Milliseconds 150 } @@ -3450,6 +3633,7 @@ try { } $fixtureHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $fixturePath).Hash } + if (-not $rehearsal) {Initialize-CapUiaClient} if (Test-CapMatchedAdapter) { $matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job } diff --git a/Tools/windows/Invoke-ValidationRunnerContract.ps1 b/Tools/windows/Invoke-ValidationRunnerContract.ps1 index eadbf6a6..a0b1b47e 100644 --- a/Tools/windows/Invoke-ValidationRunnerContract.ps1 +++ b/Tools/windows/Invoke-ValidationRunnerContract.ps1 @@ -1,5 +1,8 @@ [CmdletBinding()] -param() +param( + [string] $FixtureCompilerInput = "", + [string] $FixtureCompilerInputSha256 = "" +) function Invoke-WithValidationRunnerRuntime([scriptblock] $Action) { $selected = [Environment]::GetEnvironmentVariable("GRAPHCODE_DOTNET10403", "Process") @@ -40,8 +43,17 @@ $output = [Collections.Generic.List[string]]::new() $passMarkers = 0 $executedMarkers = 0 $exitCode = $null +$compilerArguments = @() +if ($FixtureCompilerInput -or $FixtureCompilerInputSha256) { + if (-not $FixtureCompilerInput -or $FixtureCompilerInputSha256 -cnotmatch '\A[A-F0-9]{64}\z' -or + -not [IO.Path]::IsPathFullyQualified($FixtureCompilerInput)) { + throw "Fixture compiler input requires an absolute JSON path and uppercase SHA256" + } + $compilerArguments = @("--fixture-compiler-input", $FixtureCompilerInput, + "--fixture-compiler-input-sha256", $FixtureCompilerInputSha256) +} Invoke-WithValidationRunnerRuntime { - & $runner --repository-root $repoRoot --artifact-root $artifactRoot 2>&1 | ForEach-Object { + & $runner --repository-root $repoRoot --artifact-root $artifactRoot @compilerArguments 2>&1 | ForEach-Object { $line = "$_" $output.Add($line) Write-Host $line diff --git a/Tools/windows/Prepare-FocusedUiCapture.ps1 b/Tools/windows/Prepare-FocusedUiCapture.ps1 index a2409c49..f6e57373 100644 --- a/Tools/windows/Prepare-FocusedUiCapture.ps1 +++ b/Tools/windows/Prepare-FocusedUiCapture.ps1 @@ -9,9 +9,14 @@ param( [Parameter(Mandatory)][string]$PreflightReport, [ValidateSet('TerminalChrome','PostFixCards','PostFixCardsUnselected')][string]$FocusedProfile = 'TerminalChrome', [switch]$PlanOnly, + [switch]$FunctionalWireOnly, + [string]$GraphCodeBuildSource = '', [ValidateRange(3,30)][long]$AuthorizationMinutes = 20 ) $ErrorActionPreference='Stop' +if ($FunctionalWireOnly -and (-not $PlanOnly -or $FocusedProfile -cne 'PostFixCards')) { + throw 'Functional Wire preparation requires PostFixCards -PlanOnly; it cannot authorize capture.' +} $root=[IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') $repositoryRoot=[IO.Path]::GetFullPath($Repository).TrimEnd('\') if (Test-Path -LiteralPath $root) {throw "Focused capture root already exists: $root"} @@ -25,6 +30,17 @@ if ($head -cnotmatch '^[a-f0-9]{40}$' -or $tree -cnotmatch '^[a-f0-9]{40}$' -or throw 'Focused capture preparation requires a clean exact Git head and tree.' } +if ($FunctionalWireOnly) { + if ($GraphCodeBuildSource -cnotmatch '^[a-f0-9]{40}$') { + throw 'Functional Wire requires the actual GraphCode binary build-source commit.' + } + $builtAppTree=(& git -C $repositoryRoot rev-parse ($GraphCodeBuildSource+':graphcode-windows')).Trim() + if ($LASTEXITCODE -ne 0) {throw 'Functional Wire binary build-source tree is unavailable.'} + $currentAppTree=(& git -C $repositoryRoot rev-parse 'HEAD:graphcode-windows').Trim() + if ($LASTEXITCODE -ne 0 -or $currentAppTree -cne $builtAppTree) { + throw 'Functional Wire cannot reuse a binary with changed Windows application inputs.' + } +} $modulePath=Join-Path $repositoryRoot 'Tools\windows\UiCaptureDriver.psm1' $workerPath=Join-Path $repositoryRoot 'Tools\windows\Invoke-UiCaptureWorker.ps1' $supervisorPath=Join-Path $repositoryRoot 'Tools\windows\Invoke-UiCapture.ps1' @@ -183,6 +199,30 @@ $job=[ordered]@{ sourceSha256=$backendSourceHash;networkAllowed=$false} } } +if ($FunctionalWireOnly) { + $focusedScenes=@('s-goal-unwired-unselected') + $focusedClassification='FUNCTIONAL_W1_STATE_ONLY_NOT_VISUAL_PARITY' + $job.executionMode='FUNCTIONAL_W1_ONLY' + $job.desktopAuthorized=$false + $job.evidenceClassification=$focusedClassification + $job.sceneAdapter='S_FUNCTIONAL_W1_V1' + $job.Remove('focusedCapture') + $job.Remove('captureContract') + $job.scenes=@([ordered]@{name='s-goal-unwired-unselected'}) + $windowSource=Join-Path $repositoryRoot 'graphcode-windows\src\MainWindow.zig' + $job.functionalWireOnly=$true + $job.binaries.before.binaryBuiltAtSource=$GraphCodeBuildSource + $job.binaries.after.binaryBuiltAtSource=$GraphCodeBuildSource + $job.binaries.daemon.source=$null + $job.binaries.daemon.binaryBuiltAtSource=$null + $job.binaries.daemon.evidenceMode='COPIED_RUNTIME_NOT_EXECUTED_BY_FUNCTIONAL_W1' + $job.functionalWireContract=[ordered]@{ + schemaVersion=1L;scene='s-goal-unwired-unselected' + windowSourcePath=$windowSource;windowSourceSha256=(Get-FileHash -LiteralPath $windowSource).Hash + placement='MEASURED_OWNED_WINDOW_FIT_WORKAREA';preferredLogicalClientWH=@(1280L,720L) + pngAllowed=$false;visualParityClaimed=$false;applicationSourceTree=$currentAppTree + } +} $jobPath=Join-Path $root 'job.json' [IO.File]::WriteAllText($jobPath,($job | ConvertTo-Json -Depth 40),[Text.UTF8Encoding]::new($false)) $jobHash=(Get-FileHash -LiteralPath $jobPath -Algorithm SHA256).Hash @@ -234,7 +274,7 @@ $plan=[ordered]@{ backendFixturePath=(Join-Path $runtime 'copilot.exe');backendFixtureSha256=$backendHash offlinePeerPath=(Join-Path $inputs 'offline-peer-source.ps1');offlinePeerSha256=$peerHash } - geometry=$job.captureContract + geometry=$(if ($FunctionalWireOnly) {$job.functionalWireContract} else {$job.captureContract}) authorizationBounds=[ordered]@{ status='NOT_GRANTED' requestedMaxMinutes=$AuthorizationMinutes @@ -251,6 +291,7 @@ $plan=[ordered]@{ pngArtifacts=0L processLaunches=0L } +if ($FunctionalWireOnly) {$plan.functionalWireOnly=$true} [IO.File]::WriteAllText($planPath,($plan | ConvertTo-Json -Depth 40),[Text.UTF8Encoding]::new($false)) $planHash=(Get-FileHash -LiteralPath $planPath -Algorithm SHA256).Hash if (@(Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.png').Count -ne 0) { diff --git a/Tools/windows/Prepare-MatchedActionSupplement.ps1 b/Tools/windows/Prepare-MatchedActionSupplement.ps1 index e0748a7c..0ce90131 100644 --- a/Tools/windows/Prepare-MatchedActionSupplement.ps1 +++ b/Tools/windows/Prepare-MatchedActionSupplement.ps1 @@ -4,8 +4,9 @@ param( [Parameter(Mandatory)][string]$FocusedPlanPath, [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$FocusedPlanSha256, [Parameter(Mandatory)][ValidateSet('WireCompletion','TerminalActions','FinalBoundedActions')][string]$Group, - [Parameter(Mandatory)][string]$ExistingSceneManifestPath, - [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ExistingSceneManifestSha256, + [string]$ExistingSceneManifestPath = '', + [ValidatePattern('^([A-Fa-f0-9]{64})?$')][string]$ExistingSceneManifestSha256 = '', + [switch]$FunctionalWireOnly, [string]$QualifiedCaptureRoot = '', [string]$QualifiedRunId = '', [ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$QualifiedManifestSha256 = ('0' * 64), @@ -17,17 +18,27 @@ $ErrorActionPreference='Stop' $root=[IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') $repositoryRoot=[IO.Path]::GetFullPath($Repository).TrimEnd('\') $focusedPath=[IO.Path]::GetFullPath($FocusedPlanPath) -$sceneManifestPath=[IO.Path]::GetFullPath($ExistingSceneManifestPath) +$functional=$FunctionalWireOnly.IsPresent +if ($functional -and ($Group -cne 'WireCompletion' -or $ExistingSceneManifestPath -or $ExistingSceneManifestSha256)) { + throw 'Functional Wire permits only W1 and fresh scene state, never an image manifest.' +} +if (-not $functional -and (-not $ExistingSceneManifestPath -or -not $ExistingSceneManifestSha256)) { + throw 'Visual action supplement requires the existing scene image manifest and hash.' +} +$sceneManifestPath=if ($functional) {$null} else {[IO.Path]::GetFullPath($ExistingSceneManifestPath)} if (-not $focusedPath.StartsWith($root + '\',[StringComparison]::OrdinalIgnoreCase) -or (Get-FileHash $focusedPath -Algorithm SHA256).Hash -ne $FocusedPlanSha256.ToUpperInvariant()) { throw 'Action supplement requires the exact focused no-authority plan below its owned root.' } -if ((Get-FileHash $sceneManifestPath -Algorithm SHA256).Hash -ne +if (-not $functional -and (Get-FileHash $sceneManifestPath -Algorithm SHA256).Hash -ne $ExistingSceneManifestSha256.ToUpperInvariant()) { throw 'Action supplement existing scene manifest hash mismatch.' } $focused=Get-Content $focusedPath -Raw | ConvertFrom-Json -DateKind String -$scenes=Get-Content $sceneManifestPath -Raw | ConvertFrom-Json -DateKind String +$scenes=if ($functional) {$null} else {Get-Content $sceneManifestPath -Raw | ConvertFrom-Json -DateKind String} +if ($functional -ne [bool]($focused.PSObject.Properties['functionalWireOnly'] -and $focused.functionalWireOnly)) { + throw 'Functional Wire opt-in differs from the focused plan.' +} $expectedProfile=if ($Group -ceq 'WireCompletion') {'PostFixCards'} else {'TerminalChrome'} if ($focused.status -cne 'PREPARED_NO_AUTHORITY' -or $focused.profile -cne $expectedProfile -or $focused.pngArtifacts -ne 0 -or $focused.processLaunches -ne 0 -or @@ -152,7 +163,7 @@ if ($Group -ceq 'FinalBoundedActions') { } } $bindings=[ordered]@{} -foreach ($scene in $expectedScenes) { +foreach ($scene in $(if ($functional) {@()} else {$expectedScenes})) { $rows=@($scenes.scenes | Where-Object canonicalScene -CEQ $scene) if ($rows.Count -ne 1 -or $rows[0].sha256 -cnotmatch '^[A-Fa-f0-9]{64}$') { throw "Action supplement existing image binding missing or duplicated: $scene" @@ -166,6 +177,9 @@ if ((Get-FileHash -LiteralPath $focused.jobPath -Algorithm SHA256).Hash -cne $fo throw 'Action window binding job hash mismatch.' } $actionJob=Get-Content -LiteralPath $focused.jobPath -Raw | ConvertFrom-Json -DateKind String +if ((Test-CapFunctionalWireJob -Job $actionJob -Group $Group) -ne $functional) { + throw 'Functional Wire opt-in differs from the job.' +} $actionWindowBindings=@(Get-CapActionWindowBindings -Job $actionJob -Group $Group) $plan=[ordered]@{ schemaVersion=1L @@ -193,7 +207,7 @@ $plan=[ordered]@{ stubDaemonPath=$stubPath stubDaemonSha256=(Get-FileHash $stubPath -Algorithm SHA256).Hash existingSceneManifestPath=$sceneManifestPath - existingSceneManifestSha256=$ExistingSceneManifestSha256.ToUpperInvariant() + existingSceneManifestSha256=$(if ($functional) {$null} else {$ExistingSceneManifestSha256.ToUpperInvariant()}) expectedScenes=$expectedScenes actionWindowBindings=$actionWindowBindings wireTargetBinding=$(if ($Group -cne 'TerminalActions') { @@ -240,6 +254,15 @@ $plan=[ordered]@{ maximumResultPngs=$(if ($Group -ceq 'FinalBoundedActions') {1L} else {0L}) processLaunches=0L } +if ($functional) { + $statePath=Join-Path $root 'inputs\functional-wire-state.json' + if (Test-Path -LiteralPath $statePath) {throw 'Fresh functional Wire state plan already exists.'} + $state=Get-CapFunctionalWireStatePlan -WireBinding $plan.wireTargetBinding -SourceHead $head ` + -WorkerSha256 $plan.workerSha256 -StubSha256 $plan.stubDaemonSha256 + [IO.File]::WriteAllText($statePath,($state|ConvertTo-Json -Depth 30),[Text.UTF8Encoding]::new($false)) + $plan.functionalWireOnly=$true + $plan.functionalWireState=[ordered]@{path=$statePath;sha256=(Get-FileHash -LiteralPath $statePath).Hash} +} $planPath=Join-Path $root 'action-plan.json' if (Test-Path $planPath) {throw 'Action supplement plan already exists.'} [IO.File]::WriteAllText($planPath,($plan|ConvertTo-Json -Depth 40),[Text.UTF8Encoding]::new($false)) diff --git a/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 b/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 index 26253313..ca39180b 100644 --- a/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 +++ b/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 @@ -1,5 +1,6 @@ param( - [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('ab' + [guid]::NewGuid().ToString('N').Substring(0,8))) + [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('ab' + [guid]::NewGuid().ToString('N').Substring(0,8))), + [switch]$FunctionalOnly ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' @@ -10,6 +11,147 @@ $null=New-Item -ItemType Directory -Path $caseRoot . (Join-Path $driverDirectory 'Invoke-UiCaptureWorker.ps1') -OwnedRoot $caseRoot -DefinitionsOnly Import-Module $modulePath -Force -WarningAction SilentlyContinue Set-CapTracePath -Path (Join-Path $caseRoot 'calls.jsonl') +function Invoke-FunctionalWireContracts { + $source=Join-Path $PSScriptRoot 'fixtures\c8r-window-after.zig' + $contract=[pscustomobject]@{ + schemaVersion=1L;scene='s-goal-unwired-unselected' + windowSourcePath=$source;windowSourceSha256=(Get-FileHash -LiteralPath $source).Hash + placement='MEASURED_OWNED_WINDOW_FIT_WORKAREA';preferredLogicalClientWH=@(1280L,720L) + pngAllowed=$false;visualParityClaimed=$false;applicationSourceTree=('b'*40) + } + $functional=[pscustomobject]@{ + head=('a'*40);sceneAdapter='S_FUNCTIONAL_W1_V1';functionalWireOnly=$true + functionalWireContract=$contract + binaries=[pscustomobject]@{after=[pscustomobject]@{source=('a'*40);binaryBuiltAtSource=('a'*40)}} + } + $binding=@(Get-CapActionWindowBindings -Job $functional -Group WireCompletion) + if ($binding.Count -ne 1 -or $binding[0].actionPhase -cne 'wire-completion' -or + ($binding[0].actionIds -join ',') -cne 'W1' -or + $binding[0].identity.measurement -cne 'REQUIRED_AT_OWNED_WINDOW_STARTUP') { + throw 'Functional W1 must resolve through the actual action binding without invented DPI/image evidence.' + } + $results.Add(@{name='functional W1 actual action binding requires owned runtime measurement';passed=$true}) + function Reject-FunctionalCase([string]$Name,[scriptblock]$Action) { + $failure=$null + try {& $Action|Out-Null} catch {$failure=$_} + if ($null -eq $failure) {throw "Functional Wire accepted $Name."} + $results.Add(@{name="functional W1 refuses $Name";passed=$true;refusal=$failure.Exception.Message}) + } + foreach ($group in @('TerminalActions','FinalBoundedActions')) { + Reject-FunctionalCase $group {Get-CapActionWindowBindings -Job $functional -Group $group} + } + foreach ($fault in @('opt-in','adapter','source','png','parity','build-source','app-tree')) { + $changed=$functional|ConvertTo-Json -Depth 20|ConvertFrom-Json + switch ($fault) { + 'opt-in' {$changed.functionalWireOnly=$false} + 'adapter' {$changed.sceneAdapter='S_MATCHED_V12_DPI_V1'} + 'source' {$changed.binaries.after.source='f'*40} + 'png' {$changed.functionalWireContract.pngAllowed=$true} + 'parity' {$changed.functionalWireContract.visualParityClaimed=$true} + 'build-source' {$changed.binaries.after.binaryBuiltAtSource='foreign'} + 'app-tree' {$changed.functionalWireContract.applicationSourceTree='foreign'} + } + Reject-FunctionalCase $fault {Get-CapActionWindowBindings -Job $changed -Group WireCompletion} + } + foreach ($dpi in @(96,144,192)) { + $initial=Get-CapGraphCodeWindowIdentity -SourceText ([IO.File]::ReadAllText($source)) ` + -SourceCommit $functional.head -ExpectedDpi $dpi -LogicalClientWH @(96,96) + $metrics=[pscustomobject]@{ + Dpi=[long]$dpi;Style=$initial.expectedRuntimeWindowStyle;ExStyle=$initial.expectedRuntimeWindowExStyle + HasMenu=$true;OuterBounds=@(0L,0L,1280L,820L) + ClientBounds=@(0L,0L,$initial.expectedInitialClientWH[0],$initial.expectedInitialClientWH[1]) + } + $placement=Get-CapFunctionalWirePlacement -Identity $binding[0].identity -Metrics $metrics ` + -WorkArea @(0,0,1920,885) + $bounds=$placement.Geometry.OuterBounds + if ($placement.Geometry.Dpi -ne $dpi -or $bounds[0] -lt 0 -or $bounds[1] -lt 0 -or + $bounds[2] -gt 1920 -or $bounds[3] -gt 885) {throw 'Functional measured-fit model escaped work area.'} + $metrics.OuterBounds=$bounds + $metrics.ClientBounds=@(0L,0L,$placement.Geometry.PhysicalClientWH[0],$placement.Geometry.PhysicalClientWH[1]) + $null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $placement.Identity -ExpectedOuterBounds $bounds + $results.Add(@{name="functional W1 fits injected DPI$dpi metrics without a192 visual contract";passed=$true + evidence='INJECTED_COMPONENT_METRICS_NOT_LIVE_WINDOW_OR_DPI_CAPTURE'}) + } + Reject-FunctionalCase 'invalid work area' { + Get-CapFunctionalWirePlacement -Identity $binding[0].identity -Metrics $metrics -WorkArea @(0,0,0,0) + } + $wrongIdentity=$binding[0].identity|ConvertTo-Json|ConvertFrom-Json + $wrongIdentity.sourceSha256='0'*64 + Reject-FunctionalCase 'changed window source' { + Get-CapFunctionalWirePlacement -Identity $wrongIdentity -Metrics $metrics -WorkArea @(0,0,1920,885) + } + $root=$caseRoot + $job=$functional + $wire=Get-CapWireTargetBinding -Repository ([IO.Path]::GetFullPath((Join-Path $driverDirectory '..\..'))) -OwnedRoot $root + $statePath=Join-Path $root 'functional-wire-state.json' + $plan=[pscustomobject]@{group='WireCompletion';sourceHead=$functional.head;wireTargetBinding=$wire + workerSha256=(Get-FileHash -LiteralPath (Join-Path $driverDirectory 'Invoke-UiCaptureWorker.ps1')).Hash + stubDaemonSha256=(Get-FileHash -LiteralPath (Join-Path $driverDirectory 'Stub-Daemon.ps1')).Hash + functionalWireState=[pscustomobject]@{path=$statePath;sha256=''}} + $state=Get-CapFunctionalWireStatePlan -WireBinding $wire -SourceHead $plan.sourceHead ` + -WorkerSha256 $plan.workerSha256 -StubSha256 $plan.stubDaemonSha256 + # Only the outer authorization observation is modeled; execute the actual worker state reader. + function Assert-CapActionWindowContext {} + [IO.File]::WriteAllText($statePath,($state|ConvertTo-Json -Depth 30),[Text.UTF8Encoding]::new($false)) + $plan.functionalWireState.sha256=(Get-FileHash -LiteralPath $statePath).Hash + $read=Get-CapWorkerWireBinding + if ($read.targetNodeId -cne $wire.targetNodeId) {throw 'Actual worker state reader changed target.'} + $results.Add(@{name='functional W1 actual worker accepts exact fresh planned state without image';passed=$true}) + foreach ($fault in @('sourceHead','workerSha256','stubDaemonSha256','target','initial-edge')) { + $changed=$state|ConvertTo-Json -Depth 30|ConvertFrom-Json + switch ($fault) { + 'target' {$changed.wireBinding.targetNodeId='foreign'} + 'initial-edge' {$changed.expectedBefore.edges=1L} + default {$changed.$fault='foreign'} + } + [IO.File]::WriteAllText($statePath,($changed|ConvertTo-Json -Depth 30),[Text.UTF8Encoding]::new($false)) + $plan.functionalWireState.sha256=(Get-FileHash -LiteralPath $statePath).Hash + Reject-FunctionalCase "rehashed state $fault" {Get-CapWorkerWireBinding} + } + & { + $actionSupplementMode=$false + $plan=$null + $visualIdentity=Get-CapGraphCodeWindowIdentity -SourceText ([IO.File]::ReadAllText($source)) ` + -SourceCommit $functional.head -ExpectedDpi 96 -LogicalClientWH @(320,240) + $job=[pscustomobject]@{sceneAdapter='S_MATCHED_V12_DPI_V1' + captureContract=[pscustomobject]@{logicalClientWH=@(320L,240L);expectedWindowDpi=96L + windowIdentities=[pscustomobject]@{after=$visualIdentity}}} + $geometry=Get-CapMatchedWindowGeometry -Phase after + if (($geometry.PhysicalClientWH -join ',') -cne '320,240') {throw 'Default visual geometry changed.'} + } + $results.Add(@{name='visual worker geometry remains independent of an action plan';passed=$true}) + & { + $tokens=$null;$errors=$null + $prepare=[Management.Automation.Language.Parser]::ParseFile( + (Join-Path $driverDirectory 'Prepare-FocusedUiCapture.ps1'),[ref]$tokens,[ref]$errors) + if ($errors.Count -ne 0) {throw 'Focused preparation did not parse.'} + $assignments=@($prepare.FindAll({param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -cin @('$job.binaries.before.binaryBuiltAtSource', + '$job.binaries.after.binaryBuiltAtSource','$job.binaries.daemon.source', + '$job.binaries.daemon.binaryBuiltAtSource','$job.binaries.daemon.evidenceMode') + },$false)) + if ($assignments.Count -ne 5) {throw 'Expected actual functional binary-provenance assignments.'} + $GraphCodeBuildSource='a'*40 + $job=@{binaries=@{before=@{};after=@{};daemon=@{source='not-built';binaryBuiltAtSource='not-built'}}} + foreach ($assignment in $assignments) {. ([scriptblock]::Create($assignment.Extent.Text))} + if ($job.binaries.after.binaryBuiltAtSource -cne $GraphCodeBuildSource -or + $null -ne $job.binaries.daemon.source -or $null -ne $job.binaries.daemon.binaryBuiltAtSource -or + $job.binaries.daemon.evidenceMode -cne 'COPIED_RUNTIME_NOT_EXECUTED_BY_FUNCTIONAL_W1') { + throw 'Functional preparation invents a daemon build or loses the actual app build source.' + } + } + $results.Add(@{name='actual functional preparation records unused daemon without invented build provenance';passed=$true}) +} +if ($FunctionalOnly) { + $results=[Collections.Generic.List[object]]::new() + Invoke-FunctionalWireContracts + $report=[pscustomobject]@{suite='ActionWindowIdentityFunctional';executed=$results.Count + passed=$results.Count;failed=0;skipped=0;runtimeWindowEvidence=$false;cases=@($results)} + $report|ConvertTo-Json -Depth 15|Set-Content -LiteralPath (Join-Path $caseRoot 'report.json') + $report|Select-Object suite,executed,passed,failed,skipped|ConvertTo-Json -Compress + return +} $sourceHead='a'*40 $identity=Get-CapGraphCodeWindowIdentity ` -SourceText ([IO.File]::ReadAllText((Join-Path $PSScriptRoot 'fixtures\c8r-window-after.zig'))) ` @@ -214,8 +356,9 @@ $reachedLaunch=$false } Assert-BindingCase $reachedLaunch 'Valid production startup did not reach the intercepted launcher.' $results.Add(@{name='actual production startup resolves binding before intercepted launcher';passed=$true}) +Invoke-FunctionalWireContracts $report=[ordered]@{suite='ActionWindowIdentity';executed=$results.Count;passed=$results.Count;failed=0;skipped=0 productLaunches=0;providerLaunches=0;runtimeWindowEvidence=$false;cases=@($results)} $null=Write-BindingJson (Join-Path $caseRoot 'report.json') $report [pscustomobject]$report|Select-Object suite,executed,passed,failed,skipped|ConvertTo-Json -Compress -if ($results.Count -ne 62) {throw "Unexpected binding contract count: $($results.Count)"} +if ($results.Count -ne 85) {throw "Unexpected binding contract count: $($results.Count)"} diff --git a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 index 10c7d9e8..7103c4b4 100644 --- a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 +++ b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 @@ -102,12 +102,12 @@ $fixtureRepository=Join-Path $caseRoot 'repository' $fixtureTools=Join-Path $fixtureRepository 'Tools\windows' $null=New-Item -ItemType Directory -Path $fixtureTools foreach ($name in @('Invoke-MatchedActionSupplement.ps1','Invoke-UiCaptureWorker.ps1','UiCaptureDriver.psm1', - 'Stub-Daemon.ps1','Start-MatchedActionSessions.ps1')) { + 'Stub-Daemon.ps1','Start-MatchedActionSessions.ps1','Prepare-MatchedActionSupplement.ps1','Invoke-UiCapture.ps1')) { Copy-Item -LiteralPath (Join-Path $driverDirectory $name) -Destination (Join-Path $fixtureTools $name) } $fixtureSource=Join-Path $fixtureRepository 'graphcode-windows\src' $null=New-Item -ItemType Directory -Path $fixtureSource -Force -foreach ($name in @('GraphCanvas.zig','DesignTokens.zig','NativeForms.zig','SheetFramework.zig')) { +foreach ($name in @('GraphCanvas.zig','DesignTokens.zig','NativeForms.zig','SheetFramework.zig','MainWindow.zig')) { Copy-Item -LiteralPath (Join-Path $driverDirectory "..\..\graphcode-windows\src\$name") ` -Destination (Join-Path $fixtureSource $name) } @@ -119,7 +119,7 @@ foreach ($name in @('GraphCanvas.zig','DesignTokens.zig','NativeForms.zig','Shee if ($LASTEXITCODE -ne 0) {throw 'Could not commit production entry fixture.'} $fixtureHead=(& git -C $fixtureRepository rev-parse HEAD).Trim() $fixtureTree=(& git -C $fixtureRepository rev-parse 'HEAD^{tree}').Trim() -foreach ($scenario in @('accepted','setup-refusal')) { +foreach ($scenario in @('accepted','setup-refusal','functional')) { $owned=Join-Path $caseRoot $scenario $null=New-Item -ItemType Directory -Path (Join-Path $owned 'fixtures'),(Join-Path $owned 'inputs') $fixtureHashValue=Write-ImportJson (Join-Path $owned 'fixtures\fixture.json') @{} @@ -134,8 +134,20 @@ foreach ($scenario in @('accepted','setup-refusal')) { logicalClientWH=@(320L,240L);expectedWindowDpi=96L windowIdentities=@{before=$windowIdentity;after=$windowIdentity} }} + $actionGroup=if ($scenario -ceq 'functional') {'WireCompletion'} else {'FinalBoundedActions'} + if ($scenario -ceq 'functional') { + $windowSource=Join-Path $fixtureSource 'MainWindow.zig' + $jobValue.sceneAdapter='S_FUNCTIONAL_W1_V1' + $jobValue.Remove('captureContract') + $jobValue.functionalWireOnly=$true + $jobValue.functionalWireContract=@{schemaVersion=1L;scene='s-goal-unwired-unselected' + windowSourcePath=$windowSource;windowSourceSha256=(Get-FileHash -LiteralPath $windowSource).Hash + placement='MEASURED_OWNED_WINDOW_FIT_WORKAREA';preferredLogicalClientWH=@(1280L,720L) + pngAllowed=$false;visualParityClaimed=$false + applicationSourceTree=(& git -C $fixtureRepository rev-parse 'HEAD:graphcode-windows').Trim()} + } $jobValue=$jobValue|ConvertTo-Json -Depth 30|ConvertFrom-Json -DateKind String - $windowBindings=@(Get-CapActionWindowBindings -Job $jobValue -Group FinalBoundedActions) + $windowBindings=@(Get-CapActionWindowBindings -Job $jobValue -Group $actionGroup) $jobDigest=Write-ImportJson $jobFile $jobValue $preflightFile=Join-Path $owned 'preflight.json' $preflightDigest=Write-ImportJson $preflightFile @{status='FILE_ONLY_TEST_NOT_LIVE_PREFLIGHT'} @@ -143,7 +155,7 @@ foreach ($scenario in @('accepted','setup-refusal')) { $sceneDigest=Write-ImportJson $sceneFile @{} $actionPlan=[ordered]@{ status='PREPARED_NO_AUTHORITY';pngArtifacts=0L;processLaunches=0L - sourceHead=$fixtureHead;sourceTree=$fixtureTree;group='FinalBoundedActions' + sourceHead=$fixtureHead;sourceTree=$fixtureTree;group=$actionGroup expectedScenes=@($windowBindings|ForEach-Object {$_.sceneNames}) actionWindowBindings=$windowBindings wireTargetBinding=(Get-CapWireTargetBinding -Repository $fixtureRepository -OwnedRoot $owned) @@ -180,10 +192,45 @@ foreach ($scenario in @('accepted','setup-refusal')) { }) $actionPlan.qualifiedAuthority=@{topologies=$topologies} $planFile=Join-Path $owned 'action-plan.json' - $planDigest=Write-ImportJson $planFile $actionPlan + $functionalArguments=@() + if ($scenario -ceq 'functional') { + $focusedFile=Join-Path $owned 'focused-plan.json' + $focusedDigest=Write-ImportJson $focusedFile @{ + status='PREPARED_NO_AUTHORITY';profile='PostFixCards';pngArtifacts=0L;processLaunches=0L + functionalWireOnly=$true;sourceHead=$fixtureHead;sourceTree=$fixtureTree + jobPath=$jobFile;jobSha256=$jobDigest;moduleSha256=$actionPlan.moduleSha256 + workerSha256=$actionPlan.workerSha256 + supervisorSha256=(Get-FileHash -LiteralPath (Join-Path $fixtureTools 'Invoke-UiCapture.ps1')).Hash + preflightReceiptPath=$preflightFile;preflightReceiptSha256=$preflightDigest + fixture=$actionPlan.fixture;binaries=@{} + } + $prepareOutput=@(& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File ` + (Join-Path $fixtureTools 'Prepare-MatchedActionSupplement.ps1') ` + -Repository $fixtureRepository -OwnedRoot $owned -FocusedPlanPath $focusedFile ` + -FocusedPlanSha256 $focusedDigest -Group WireCompletion -FunctionalWireOnly 2>&1) + $prepareOutput|Set-Content -LiteralPath (Join-Path $owned 'prepare.log') + Assert-ImportCase ($LASTEXITCODE -eq 0) "Actual functional preparation failed: $prepareOutput" + $actionPlan=Get-Content -LiteralPath $planFile -Raw|ConvertFrom-Json -DateKind String + $planDigest=(Get-FileHash -LiteralPath $planFile).Hash + Assert-ImportCase ($actionPlan.functionalWireOnly -and + @($actionPlan.imageBindings.PSObject.Properties).Count -eq 0 -and + $null -eq $actionPlan.existingSceneManifestPath -and $actionPlan.maximumResultPngs -eq 0) ` + 'Functional preparation fabricated image evidence or permits PNGs.' + $missingOptIn=@(& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File ` + (Join-Path $fixtureTools 'Invoke-MatchedActionSupplement.ps1') ` + -Repository $fixtureRepository -OwnedRoot $owned -ActionPlanPath $planFile -ActionPlanSha256 $planDigest -DryRun 2>&1) + $missingOptIn|Set-Content -LiteralPath (Join-Path $owned 'missing-opt-in.log') + Assert-ImportCase ($LASTEXITCODE -ne 0 -and + ($missingOptIn -join "`n").Contains('explicit W1-only no-image opt-in') -and + -not (Test-Path -LiteralPath (Join-Path $owned 'dry-run-binding.json'))) 'Missing opt-in was not refused before authority/launch.' + $results.Add(@{name='functional production entry refuses missing opt-in before launch';passed=$true}) + $functionalArguments=@('-FunctionalWireOnly') + } else { + $planDigest=Write-ImportJson $planFile $actionPlan + } $entryOutput=@(& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File ` (Join-Path $fixtureTools 'Invoke-MatchedActionSupplement.ps1') ` - -Repository $fixtureRepository -OwnedRoot $owned -ActionPlanPath $planFile -ActionPlanSha256 $planDigest -DryRun 2>&1) + -Repository $fixtureRepository -OwnedRoot $owned -ActionPlanPath $planFile -ActionPlanSha256 $planDigest -DryRun @functionalArguments 2>&1) $entryExit=$LASTEXITCODE $entryOutput | Set-Content -LiteralPath (Join-Path $owned 'entry.log') -Encoding utf8NoBOM $manifestFile=Join-Path $owned 'dry-run-output\action-manifest.json' @@ -191,6 +238,21 @@ foreach ($scenario in @('accepted','setup-refusal')) { $manifest=Get-Content $manifestFile -Raw | ConvertFrom-Json -DateKind String $release=Get-Content (Join-Path $owned 'dry-run-output\action-release.json') -Raw | ConvertFrom-Json -DateKind String $launchSpecs=@(Get-ChildItem (Join-Path $owned 'dry-run-output') -File -Filter '*-launch.json') + if ($scenario -ceq 'functional') { + Assert-ImportCase ($entryExit -eq 0 -and $manifest.status -ceq 'COMPLETE' -and + $release.status -ceq 'RELEASED_ZERO_SURVIVORS' -and $launchSpecs.Count -eq 1 -and + $manifest.dryRun.geometry.kind -ceq 'OWNED_WINDOW_MEASUREMENT_REQUIRED' -and + $null -eq $manifest.dryRun.geometry.actualDpi -and + $manifest.dryRun.windowBindings.Count -eq 1 -and $manifest.receiptCount -eq 0 -and + $manifest.providerReceipts.Count -eq 0 -and $manifest.pngCount -eq 0 -and + $manifest.cleanup.Count -eq 1 -and $manifest.cleanup[0].jobActive0 -and + $manifest.cleanup[0].launcherStdoutEof -and $manifest.cleanup[0].launcherStderrEof -and + $manifest.cleanupFailures.Count -eq 0 -and $manifest.independentSurvivors.Count -eq 0) ` + "Functional actual production dry run/cleanup failed: $entryOutput" + $results.Add(@{name='functional actual preparation and production dry run bind fresh state not image';passed=$true + manifestPath=$manifestFile;manifestSha256=(Get-FileHash -LiteralPath $manifestFile).Hash}) + continue + } if ($scenario -ceq 'setup-refusal') { Assert-ImportCase ($entryExit -ne 0 -and $manifest.status -ceq 'FAILED' -and $release.status -ceq 'FAILED_ZERO_SURVIVORS' -and $manifest.failures.Count -eq 1 -and @@ -271,4 +333,4 @@ $report=[ordered]@{suite='ActionWorkerImport';executed=$results.Count;passed=$re productLaunches=0;providerLaunches=0;cases=@($results)} $null=Write-ImportJson (Join-Path $caseRoot 'report.json') $report [pscustomobject]$report | Select-Object suite,executed,passed,failed,skipped | ConvertTo-Json -Compress -if ($results.Count -ne 18) {throw "Unexpected action import contract count: $($results.Count)"} +if ($results.Count -ne 20) {throw "Unexpected action import contract count: $($results.Count)"} diff --git a/Tools/windows/Tests/FixtureCompiler.Tests.ps1 b/Tools/windows/Tests/FixtureCompiler.Tests.ps1 new file mode 100644 index 00000000..9d3d61a2 --- /dev/null +++ b/Tools/windows/Tests/FixtureCompiler.Tests.ps1 @@ -0,0 +1,145 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ScratchRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '..\FixtureCompiler.ps1') +if (Test-Path -LiteralPath $ScratchRoot) { throw 'Fresh scratch directory required.' } +$null = New-Item -ItemType Directory -Path $ScratchRoot +$installation = Join-Path $ScratchRoot 'VS' +$vcvars = Join-Path $installation 'VC\Auxiliary\Build\vcvars64.bat' +$versionFile = Join-Path $installation 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt' +$compiler = Join-Path $installation 'VC\Tools\MSVC\14.51.36231\bin\Hostx64\x64\cl.exe' +$sdk = Join-Path $ScratchRoot 'SDK' +foreach ($path in @($vcvars,$versionFile,$compiler, + (Join-Path $sdk 'Include\10.0.26100.0\um\Windows.h'), + (Join-Path $sdk 'Include\10.0.26100.0\ucrt\stdio.h'), + (Join-Path $sdk 'Lib\10.0.26100.0\um\x64\kernel32.lib'), + (Join-Path $sdk 'Lib\10.0.26100.0\ucrt\x64\ucrt.lib'))) { + $null = [IO.Directory]::CreateDirectory((Split-Path -Parent $path)) + [IO.File]::WriteAllText($path, 'controlled fixture, never executed') +} +[IO.File]::WriteAllText($versionFile, '14.51.36231') +$inputFile = Join-Path $ScratchRoot 'compiler.json' +$inputRecord = [ordered]@{ + schemaVersion = 1; installationPath = $installation; msvcVersion = '14.51.36231' + compilerSha256 = (Get-FileHash $compiler).Hash; vcvarsSha256 = (Get-FileHash $vcvars).Hash + sdkRoot = $sdk; sdkVersion = '10.0.26100.0' +} +function Write-Input { [IO.File]::WriteAllText($inputFile, ($inputRecord | ConvertTo-Json)); return (Get-FileHash $inputFile).Hash } +function Invoke-CapBoundedChild { throw 'Discovery must not execute for an explicit input.' } +$results = [Collections.Generic.List[object]]::new() +function Test-Case([string]$CaseName, [scriptblock]$Action) { + try { & $Action; $results.Add(@{ name=$CaseName; passed=$true }) } + catch { $results.Add(@{ name=$CaseName; passed=$false; error=$_.Exception.Message }); Write-Host "FAIL $CaseName : $($_.Exception.Message)" } +} +function Assert-True([bool]$Value) { if (-not $Value) { throw 'Assertion failed.' } } +function Assert-Refused([scriptblock]$Action) { + $refused = $false + try { & $Action } catch { if ($_.Exception.Message -notlike 'FixtureCompilerInput:*') { throw }; $refused = $true } + Assert-True $refused +} +Test-Case 'explicit input selects verified tools without discovery' { + $selection = Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' + Assert-True ($selection.mode -ceq 'explicit' -and $selection.compiler -ceq $compiler -and + $selection.vcvars -ceq $vcvars -and $selection.sdkVersion -ceq '10.0.26100.0') +} +Test-Case 'path requires hash' { Assert-Refused { Resolve-CapFixtureCompiler $inputFile '' $ScratchRoot 'unused' } } +Test-Case 'hash requires path' { Assert-Refused { Resolve-CapFixtureCompiler '' ('A'*64) $ScratchRoot 'unused' } } +Test-Case 'wrong input digest refused' { Assert-Refused { Resolve-CapFixtureCompiler $inputFile ('A'*64) $ScratchRoot 'unused' } } +Test-Case 'changed compiler refused' { + $hash = Write-Input + [IO.File]::AppendAllText($compiler, 'changed') + try { Assert-Refused { Resolve-CapFixtureCompiler $inputFile $hash $ScratchRoot 'unused' } } + finally { [IO.File]::WriteAllText($compiler, 'controlled fixture, never executed') } +} +Test-Case 'changed vcvars refused' { + $hash = Write-Input + [IO.File]::AppendAllText($vcvars, 'changed') + try { Assert-Refused { Resolve-CapFixtureCompiler $inputFile $hash $ScratchRoot 'unused' } } + finally { [IO.File]::WriteAllText($vcvars, 'controlled fixture, never executed') } +} +foreach ($field in @('msvcVersion','sdkVersion','installationPath','sdkRoot','schemaVersion')) { + Test-Case "invalid $field refused" { + $original = $inputRecord[$field] + $inputRecord[$field] = if ($field -ceq 'schemaVersion') { 2 } else { 'bad%input' } + try { Assert-Refused { Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' } } + finally { $inputRecord[$field] = $original } + } +} +Test-Case 'missing SDK library refused' { + $path = Join-Path $sdk 'Lib\10.0.26100.0\um\x64\kernel32.lib' + [IO.File]::Move($path, "$path.saved") + try { Assert-Refused { Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' } } + finally { [IO.File]::Move("$path.saved", $path) } +} +Test-Case 'default discovery retains bounded child and role' { + function Invoke-CapBoundedChild { + param($FilePath,$Arguments,$Directory,$Role) + Assert-True ($FilePath -like '*\vswhere.exe' -and $Arguments.Count -eq 7 -and $Role -ceq 'default-role') + return @{ exitCode=0; stdout=$installation } + } + $selection = Resolve-CapFixtureCompiler '' '' $ScratchRoot 'default-role' + Assert-True ($selection.mode -ceq 'discovery' -and $selection.compiler -ceq $compiler) +} +function New-EnvironmentText { + return @( + 'VCToolsVersion=14.51.36231' + "VCToolsInstallDir=$installation\VC\Tools\MSVC\14.51.36231\" + "WindowsSdkDir=$sdk\" + 'WindowsSDKVersion=10.0.26100.0\' + "INCLUDE=$installation\VC\Tools\MSVC\14.51.36231\include;$sdk\Include\10.0.26100.0\um;$sdk\Include\10.0.26100.0\ucrt" + "LIB=$installation\VC\Tools\MSVC\14.51.36231\lib\x64;$sdk\Lib\10.0.26100.0\um\x64;$sdk\Lib\10.0.26100.0\ucrt\x64" + ) -join "`r`n" +} +Test-Case 'actual compiler SDK include and library observations accepted' { + $selection = Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' + Assert-CapFixtureCompilerEnvironment $selection (New-EnvironmentText) +} +Test-Case 'vcvars equivalent repeated path separators accepted' { + $selection = Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' + $text = (New-EnvironmentText).Replace("$sdk\Include", "$sdk\\Include").Replace("$sdk\Lib", "$sdk\\Lib") + Assert-CapFixtureCompilerEnvironment $selection $text +} +foreach ($name in @('VCToolsVersion','VCToolsInstallDir','WindowsSdkDir','WindowsSDKVersion','INCLUDE','LIB')) { + Test-Case "actual $name mismatch refused" { + $selection = Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' + $text = (New-EnvironmentText) -replace "(?m)^$name=.*$", "$name=wrong" + Assert-Refused { Assert-CapFixtureCompilerEnvironment $selection $text } + } +} +Test-Case 'missing environment observation refused' { + $selection = Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' + Assert-Refused { Assert-CapFixtureCompilerEnvironment $selection '' } +} +Test-Case 'duplicate environment observation refused' { + $selection = Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' + Assert-Refused { Assert-CapFixtureCompilerEnvironment $selection ((New-EnvironmentText) + "`nLIB=wrong") } +} +foreach ($name in @('INCLUDE','LIB')) { + Test-Case "SDK-only $name without selected MSVC inputs refused" { + $selection = Resolve-CapFixtureCompiler $inputFile (Write-Input) $ScratchRoot 'unused' + $toolPath = if ($name -ceq 'INCLUDE') { "$installation\VC\Tools\MSVC\14.51.36231\include;" } + else { "$installation\VC\Tools\MSVC\14.51.36231\lib\x64;" } + Assert-Refused { Assert-CapFixtureCompilerEnvironment $selection ((New-EnvironmentText).Replace($toolPath,'')) } + } +} +Test-Case 'default environment route unchanged' { + Assert-CapFixtureCompilerEnvironment ([pscustomobject]@{mode='discovery'}) '' +} +Test-Case 'canonical adapter forwards input only to fresh preparation' { + $adapter = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\ValidationRunner\Adapters\StandaloneSuites.cs')) + Assert-True ($adapter.Contains('context.FixtureCompilerInput') -and + $adapter.Contains('["-PrepareFixturesOnly", "-ScratchRoot", prebuilt, .. compilerArguments]') -and + $adapter.Contains('["-ScratchRoot", suiteRoot, "-PrebuiltFixtureManifest", manifest, "-PrebuiltFixtureManifestSha256", hash]')) + $entry = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\ValidationRunner\Program.cs')) + Assert-True ($entry.Contains('case "--fixture-compiler-input":') -and + $entry.Contains('case "--fixture-compiler-input-sha256":') -and + $entry.Contains('new ValidationContext(repositoryRoot, artifactRoot, fixtureCompilerInput, fixtureCompilerInputSha256)')) + $wrapper = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\Invoke-ValidationRunnerContract.ps1')) + Assert-True ($wrapper.Contains('--artifact-root $artifactRoot @compilerArguments')) +} +$passed = @($results | Where-Object passed -EQ $true).Count +$report = @{ suite='FixtureCompiler'; executed=$results.Count; passed=$passed; failed=$results.Count-$passed; skipped=0; cases=@($results); nativeExecution=0 } +$report | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $ScratchRoot 'report.json') -Encoding utf8NoBOM +$report | Select-Object suite,executed,passed,failed,skipped | ConvertTo-Json -Compress +if ($results.Count -ne 27 -or $passed -ne $results.Count) { exit 1 } diff --git a/Tools/windows/Tests/NativeImageBuffer.Tests.ps1 b/Tools/windows/Tests/NativeImageBuffer.Tests.ps1 new file mode 100644 index 00000000..facc1cb0 --- /dev/null +++ b/Tools/windows/Tests/NativeImageBuffer.Tests.ps1 @@ -0,0 +1,97 @@ +param([Parameter(Mandatory)][string]$ScratchRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if(Test-Path $ScratchRoot){throw 'Fresh scratch required.'} +$null=[IO.Directory]::CreateDirectory($ScratchRoot) +$path=Join-Path $PSScriptRoot '..\UiCaptureDriver.psm1' +$ast=[Management.Automation.Language.Parser]::ParseFile($path,[ref]$null,[ref]$null) +$assignment=$ast.Find({param($n)$n-is[Management.Automation.Language.AssignmentStatementAst]-and$n.Left.Extent.Text-ceq'$nativeSource'},$false) +$literal=$assignment.Right.Find({param($n)$n-is[Management.Automation.Language.StringConstantExpressionAst]},$true) +$source=$literal.Value +$start=$source.IndexOf('public static string DecodeImagePath(') +$end=$source.IndexOf('public static string ImagePath(') +$body=if($start-ge0-and$end-gt$start){$source.Substring($start,$end-$start)}else{''} +Add-Type -TypeDefinition ("using System;using System.Runtime.InteropServices;using System.ComponentModel;public static class ImageDecoderFixture {"+$body+"}") +$results=[Collections.Generic.List[object]]::new() +function Check([string]$Name,[scriptblock]$Action){ + try{&$Action;$results.Add(@{name=$Name;passed=$true})} + catch{$results.Add(@{name=$Name;passed=$false;error=$_.Exception.Message})} +} +function Assert([bool]$Value){if(-not$Value){throw 'Assertion failed.'}} +function Buffer([string]$Text,[int]$Capacity,[bool]$Terminated=$true){ + $pointer=[Runtime.InteropServices.Marshal]::AllocHGlobal($Capacity*2) + for($i=0;$i-lt$Capacity;$i++){[Runtime.InteropServices.Marshal]::WriteInt16($pointer,$i*2,[int16]-1)} + for($i=0;$i-lt$Text.Length;$i++){ + $unit=[int][char]$Text[$i] + if($unit-gt32767){$unit-=65536} + [Runtime.InteropServices.Marshal]::WriteInt16($pointer,$i*2,[int16]$unit) + } + if($Terminated){[Runtime.InteropServices.Marshal]::WriteInt16($pointer,$Text.Length*2,[int16]0)} + return $pointer +} +function Decode([bool]$Success,[int]$Error,[IntPtr]$Pointer,[int]$Capacity,[uint32]$Length){ + return [ImageDecoderFixture]::DecodeImagePath($Success,$Error,$Pointer,$Capacity,$Length) +} +function Reject([scriptblock]$Action,[type]$Type){ + $caught=$null + try{&$Action|Out-Null}catch{$caught=$_.Exception.GetBaseException()} + Assert ($null-ne$caught-and$Type.IsInstanceOfType($caught)) + return $caught +} +Check 'successful Unicode image decodes exact reported length' { + $text='C:\owned\'+[char]0x96EA+'.exe' + $pointer=Buffer $text 32 + try{Assert ((Decode $true 0 $pointer 32 $text.Length)-ceq$text)} + finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($pointer)} + $text='x'*32767 + $pointer=Buffer $text 32768 + try{Assert ((Decode $true 0 $pointer 32768 32767).Length-eq32767)} + finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($pointer)} +} +Check 'failed query preserves real native error without reading poisoned buffer or length' { + $error=Reject {Decode $false 5 ([IntPtr]1) 0 ([uint32]::MaxValue)} ([ComponentModel.Win32Exception]) + Assert ($error.NativeErrorCode-eq5) +} +Check 'failed query with native error zero remains failure not a successful default' { + $error=Reject {Decode $false 0 ([IntPtr]::Zero) 0 0} ([ComponentModel.Win32Exception]) + Assert ($error.NativeErrorCode-eq0) +} +Check 'success zero or overflowing length refuses before buffer read' { + foreach($length in @(0,32,33)){ + $null=Reject {Decode $true 0 ([IntPtr]1) 32 $length} ([InvalidOperationException]) + } +} +Check 'missing output terminator refuses' { + $pointer=Buffer 'abc' 8 $false + try{$null=Reject {Decode $true 0 $pointer 8 3} ([InvalidOperationException])} + finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($pointer)} +} +Check 'embedded null cannot shorten the reported image' { + $pointer=Buffer "a`0b" 8 + try{$null=Reject {Decode $true 0 $pointer 8 3} ([InvalidOperationException])} + finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($pointer)} +} +Check 'successful null buffer refuses' { + $null=Reject {Decode $true 0 ([IntPtr]::Zero) 8 3} ([InvalidOperationException]) +} +Check 'uninitialized sentinel buffer cannot become image success' { + $pointer=Buffer '' 8 $false + try{$null=Reject {Decode $true 0 $pointer 8 3} ([InvalidOperationException])} + finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($pointer)} +} +Check 'source uses fixed native buffer and preserves immediate error and finally free' { + $image=$source.Substring($end,$source.IndexOf('public static CapProcessSnapshot Snapshot(',$end)-$end) + Assert ($source.Contains('IntPtr image, ref uint size)')-and + $image-match'bool success = QueryFullProcessImageNameW\(handle, 0, buffer, ref size\);\s*int nativeError = Marshal.GetLastWin32Error\(\);'-and + $image.Contains('Array.Fill(initialized, (short)-1);')-and + $image.Contains('finally { Marshal.FreeHGlobal(buffer); }')-and + $image.Contains('GetProcessId(handle) != (uint)expectedPid')-and + $image.Contains('const int capacity = 32768;')-and + -not($image-match'StringBuilder|catch|Thread.Sleep')) +} +$passed=@($results|Where-Object passed -EQ $true).Count +@{suite='NativeImageBuffer';executed=$results.Count;passed=$passed;failed=$results.Count-$passed;skipped=0;nativeQueries=0; + evidence='Actual extracted decoder with controlled managed-owned buffers; not historical native-query cause proof';cases=@($results)}| + ConvertTo-Json -Depth 5|Set-Content (Join-Path $ScratchRoot 'report.json') -Encoding utf8NoBOM +@{suite='NativeImageBuffer';executed=$results.Count;passed=$passed;failed=$results.Count-$passed;skipped=0;nativeQueries=0}|ConvertTo-Json -Compress +if($results.Count-ne9-or$passed-ne9){exit 1} diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 9b92ad3f..96334598 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -20,6 +20,7 @@ param( [switch]$DescendantMetadataContractOnly, [switch]$DescendantLifetimeContractOnly, [switch]$AdvisoryOlderContractOnly, + [switch]$AdvisoryDiagnosticContractOnly, [switch]$DAdapterContractOnly, [switch]$MatchedScenarioContractOnly, [switch]$LiveTransitionRehearsalContractOnly, @@ -30,6 +31,8 @@ param( [ValidateSet('All','SceneContract','ActualAdmission','FixtureDirectory')][string]$DAdapterCase = 'All', [string]$PrebuiltFixtureManifest = '', [string]$PrebuiltFixtureManifestSha256 = '', + [string]$FixtureCompilerInput = '', + [string]$FixtureCompilerInputSha256 = '', [switch]$RequireRealSeed, [switch]$RealSeedContractOnly, [string]$RealSeedZmxPath = '', @@ -45,6 +48,10 @@ $supervisorPath = Join-Path $driverDirectory 'Invoke-UiCapture.ps1' $workerPath = Join-Path $driverDirectory 'Invoke-UiCaptureWorker.ps1' $baselineWindowSourcePath = Join-Path $PSScriptRoot 'fixtures\c8r-window-before.zig' $currentWindowSourcePath = Join-Path $PSScriptRoot 'fixtures\c8r-window-after.zig' +$fixtureCompilerPath = Join-Path $driverDirectory 'FixtureCompiler.ps1' +if (($FixtureCompilerInput -or $FixtureCompilerInputSha256) -and -not $PrepareFixturesOnly) { + throw 'FixtureCompilerInput: explicit compiler selection is preparation-only.' +} function Write-JournalContractJson { param([string]$Path, $Value) @@ -193,9 +200,10 @@ if (Test-Path -LiteralPath $scratch) { throw "Refusing to replace capture test e $null = New-Item -ItemType Directory -Path $scratch $sourceHashesBefore = [ordered]@{} foreach ($path in @($modulePath, $supervisorPath, $workerPath, $PSCommandPath, - $baselineWindowSourcePath, $currentWindowSourcePath)) { + $baselineWindowSourcePath, $currentWindowSourcePath, $fixtureCompilerPath)) { $sourceHashesBefore[$path] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash } +. $fixtureCompilerPath Import-Module $modulePath -Force -WarningAction SilentlyContinue Set-CapTracePath -Path (Join-Path $scratch 'test-calls.jsonl') @@ -747,16 +755,16 @@ function Assert-CapRejects { } Invoke-CapTest 'journal native bindings match the exact job-ownership declaration contract' { $expected = @( - 'GraphCodeUiaGateState|PostMessage|user32.dll|PostMessageA', - 'GraphCodeUiaGateState|ScreenToClient|user32.dll|ScreenToClient', - 'GraphCodeUiaGateState|SetCursorPos|user32.dll|SetCursorPos', - 'GraphCodeUiaGateState|GetCursorPos|user32.dll|GetCursorPos', - 'GraphCodeUiaGateState|WindowFromPoint|user32.dll|WindowFromPoint', - 'GraphCodeUiaGateState|SendKeyInputs|user32.dll|SendInput', - 'GraphCodeUiaGateState|EnumWindows|user32.dll|EnumWindows', - 'GraphCodeUiaGateState|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', - 'GraphCodeUiaGateState|GetForegroundWindow|user32.dll|GetForegroundWindow', - 'GraphCodeUiaGateState|GetClassName|user32.dll|GetClassNameW', + 'CapUiaGateState|PostMessage|user32.dll|PostMessageA', + 'CapUiaGateState|ScreenToClient|user32.dll|ScreenToClient', + 'CapUiaGateState|SetCursorPos|user32.dll|SetCursorPos', + 'CapUiaGateState|GetCursorPos|user32.dll|GetCursorPos', + 'CapUiaGateState|WindowFromPoint|user32.dll|WindowFromPoint', + 'CapUiaGateState|SendKeyInputs|user32.dll|SendInput', + 'CapUiaGateState|EnumWindows|user32.dll|EnumWindows', + 'CapUiaGateState|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', + 'CapUiaGateState|GetForegroundWindow|user32.dll|GetForegroundWindow', + 'CapUiaGateState|GetClassName|user32.dll|GetClassNameW', 'CapNative|NtQueryInformationProcess|ntdll.dll|NtQueryInformationProcess', 'CapNative|NtQueryInformationProcess|ntdll.dll|NtQueryInformationProcess', 'CapNative|CommandLineToArgvW|shell32.dll|CommandLineToArgvW', @@ -1004,19 +1012,10 @@ int wmain(int argc, wchar_t **argv) { '@ $sourcePath = Join-Path $fixture 'capture-target.c' [IO.File]::WriteAllText($sourcePath, $source, [Text.UTF8Encoding]::new($false)) - $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' - if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) { - throw 'Existing MSVC discovery is unavailable; this offline suite does not install a compiler.' - } - $discovery = Invoke-CapBoundedChild -FilePath $vswhere -Arguments @( - '-latest', '-products', '*', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', - '-property', 'installationPath' - ) -Directory $fixture -Role 'fixture-compiler-discovery' - Assert-CapTest ($discovery.exitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($discovery.stdout)) 'Existing MSVC was not found.' - $installation = $discovery.stdout.Trim() - $vcvars = Join-Path $installation 'VC\Auxiliary\Build\vcvars64.bat' - $version = [IO.File]::ReadAllText((Join-Path $installation 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt')).Trim() - $compiler = Join-Path $installation "VC\Tools\MSVC\$version\bin\Hostx64\x64\cl.exe" + $selection = Resolve-CapFixtureCompiler $FixtureCompilerInput $FixtureCompilerInputSha256 $fixture 'fixture-compiler-discovery' + $vcvars = $selection.vcvars + $version = $selection.msvcVersion + $compiler = $selection.compiler $exe = Join-Path $fixture 'capture-target.exe' $object = Join-Path $fixture 'capture-target.obj' $compileStdout = Join-Path $fixture 'compile-stdout.log' @@ -1024,6 +1023,9 @@ int wmain(int argc, wchar_t **argv) { $compileStdoutRaw = Join-Path $fixture 'compile-stdout.raw.log' $compileStderrRaw = Join-Path $fixture 'compile-stderr.raw.log' $command = 'call "{0}" -vcvars_ver={1} >nul && "{2}" /nologo /W4 /WX /O2 /utf-8 /Fe:"{3}" /Fo:"{5}" "{4}" /link /SUBSYSTEM:CONSOLE' -f $vcvars, $version, $compiler, $exe, $sourcePath, $object + if ($selection.mode -ceq 'explicit') { + $command = $command.Replace(" -vcvars_ver=$version >nul &&", " $($selection.sdkVersion) -vcvars_ver=$version >nul && set VCToolsVersion && set VCToolsInstallDir && set WindowsSdkDir && set WindowsSDKVersion && set INCLUDE && set LIB &&") + } # Redirect the whole compile group's stdout/stderr to owned files so the real # compiler and any toolchain telemetry helpers it spawns inherit file handles, # never the module's redirected pipe. Only the short-lived cmd.exe then holds @@ -1031,7 +1033,7 @@ int wmain(int argc, wchar_t **argv) { # robustness fix; it does not touch any runtime/kernel/EOF/release guard. $redirectedCommand = '({0}) > "{1}" 2> "{2}"' -f $command, $compileStdoutRaw, $compileStderrRaw [IO.File]::WriteAllText((Join-Path $fixture 'compile.cmd'), "@echo off`r`n$redirectedCommand`r`nexit /b %errorlevel%`r`n", [Text.UTF8Encoding]::new($false)) - $compilerEnvironment = @{ PATH = (Split-Path -Parent $vswhere) + ';' + $env:PATH; VSCMD_SKIP_SENDTELEMETRY = '1' } + $compilerEnvironment = @{ PATH = $selection.discoveryDirectory + ';' + $env:PATH; VSCMD_SKIP_SENDTELEMETRY = '1' } foreach ($key in @('VSCMD_VER', '__VSCMD_PREINIT_PATH', 'VSINSTALLDIR', 'VCINSTALLDIR', 'VCToolsInstallDir', 'VCToolsVersion', 'WindowsSdkDir', 'WindowsSDKVersion', 'INCLUDE', 'LIB', 'LIBPATH')) { $compilerEnvironment[$key] = '' @@ -1048,7 +1050,9 @@ int wmain(int argc, wchar_t **argv) { $compileStderrText = $compileStderrSnapshot.Text Assert-CapTest ($compilerExitCode -eq 0 -and (Test-Path -LiteralPath $exe -PathType Leaf)) ` "Harmless target compilation failed (not launch evidence): $compileStdoutText $compileStderrText" + Assert-CapFixtureCompilerEnvironment $selection $compileStdoutText $provenance = [ordered]@{ + compilerSelection = $selection compiler = $compiler; compilerVersion = (Get-Item -LiteralPath $compiler).VersionInfo.FileVersion compilerSha256 = (Get-FileHash -LiteralPath $compiler -Algorithm SHA256).Hash compilerCommand = $command; compilerExitCode = $compilerExitCode @@ -1110,19 +1114,10 @@ int wmain(void) { '@ $sourcePath = Join-Path $fixture 'cim-race-target.c' [IO.File]::WriteAllText($sourcePath, $source, [Text.UTF8Encoding]::new($false)) - $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' - if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) { - throw 'Existing MSVC discovery is unavailable; this offline suite does not install a compiler.' - } - $discovery = Invoke-CapBoundedChild -FilePath $vswhere -Arguments @( - '-latest', '-products', '*', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', - '-property', 'installationPath' - ) -Directory $fixture -Role 'cim-race-compiler-discovery' - Assert-CapTest ($discovery.exitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($discovery.stdout)) 'Existing MSVC was not found.' - $installation = $discovery.stdout.Trim() - $vcvars = Join-Path $installation 'VC\Auxiliary\Build\vcvars64.bat' - $version = [IO.File]::ReadAllText((Join-Path $installation 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt')).Trim() - $compiler = Join-Path $installation "VC\Tools\MSVC\$version\bin\Hostx64\x64\cl.exe" + $selection = Resolve-CapFixtureCompiler $FixtureCompilerInput $FixtureCompilerInputSha256 $fixture 'cim-race-compiler-discovery' + $vcvars = $selection.vcvars + $version = $selection.msvcVersion + $compiler = $selection.compiler $exe = Join-Path $fixture 'cim-race-target.exe' $object = Join-Path $fixture 'cim-race-target.obj' $compileStdout = Join-Path $fixture 'compile-stdout.log' @@ -1130,12 +1125,15 @@ int wmain(void) { $compileStdoutRaw = Join-Path $fixture 'compile-stdout.raw.log' $compileStderrRaw = Join-Path $fixture 'compile-stderr.raw.log' $command = 'call "{0}" -vcvars_ver={1} >nul && "{2}" /nologo /W3 /O2 /utf-8 /Fe:"{3}" /Fo:"{5}" "{4}" /link /SUBSYSTEM:CONSOLE' -f $vcvars, $version, $compiler, $exe, $sourcePath, $object + if ($selection.mode -ceq 'explicit') { + $command = $command.Replace(" -vcvars_ver=$version >nul &&", " $($selection.sdkVersion) -vcvars_ver=$version >nul && set VCToolsVersion && set VCToolsInstallDir && set WindowsSdkDir && set WindowsSDKVersion && set INCLUDE && set LIB &&") + } # Same owned-file redirection as New-CapHarmlessTarget: the compiler and any # telemetry helpers inherit file handles, so only the short-lived cmd.exe holds # the module's redirected pipe and it reaches bounded EOF on exit. $redirectedCommand = '({0}) > "{1}" 2> "{2}"' -f $command, $compileStdoutRaw, $compileStderrRaw [IO.File]::WriteAllText((Join-Path $fixture 'compile.cmd'), "@echo off`r`n$redirectedCommand`r`nexit /b %errorlevel%`r`n", [Text.UTF8Encoding]::new($false)) - $compilerEnvironment = @{ PATH = (Split-Path -Parent $vswhere) + ';' + $env:PATH; VSCMD_SKIP_SENDTELEMETRY = '1' } + $compilerEnvironment = @{ PATH = $selection.discoveryDirectory + ';' + $env:PATH; VSCMD_SKIP_SENDTELEMETRY = '1' } foreach ($key in @('VSCMD_VER', '__VSCMD_PREINIT_PATH', 'VSINSTALLDIR', 'VCINSTALLDIR', 'VCToolsInstallDir', 'VCToolsVersion', 'WindowsSdkDir', 'WindowsSDKVersion', 'INCLUDE', 'LIB', 'LIBPATH')) { $compilerEnvironment[$key] = '' @@ -1152,7 +1150,9 @@ int wmain(void) { $compileStderrText = $compileStderrSnapshot.Text Assert-CapTest ($compilerExitCode -eq 0 -and (Test-Path -LiteralPath $exe -PathType Leaf)) ` "CIM race target compilation failed (not launch evidence): $compileStdoutText $compileStderrText" + Assert-CapFixtureCompilerEnvironment $selection $compileStdoutText $provenance = [pscustomobject]@{ + compilerSelection = $selection executable = $exe source = $sourcePath sourceSha256 = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash @@ -1629,6 +1629,57 @@ function Invoke-CapDescendantLifetimeContract { } } +function Invoke-CapAdvisoryDiagnosticContract { + $tokens=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($modulePath,[ref]$tokens,[ref]$errors) + Assert-CapTest ($errors.Count -eq 0) 'Diagnostic source must parse.' + $owner=$ast.Find({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Get-CapOwnedDescendants' + },$false) + $boundary=@($owner.Body.FindAll({param($node) + $node -is [Management.Automation.Language.PipelineAst] -and ( + $node.Extent.Text -ceq '$exclusion | ConvertTo-Json -Depth 12 -Compress' -or + $node.Extent.Text -match '^Get-CapAdvisoryExclusionDiagnostic -Exclusion \$exclusion \| ConvertTo-Json') + },$true)) + Assert-CapTest ($boundary.Count -eq 1) 'Expected the actual exclusion diagnostic serialization boundary.' + $script:CapDiagnosticGetterVisits=0 + $target=[pscustomobject]@{} + $target|Add-Member ScriptProperty LiveObjectGetter { + $script:CapDiagnosticGetterVisits++ + 'SYNTHETIC_GETTER_NOT_ORIGINAL_LIVE_RECEIVER' + } + $errorRecord=[Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new('SYNTHETIC diagnostic failure'),'SyntheticDiagnostic', + [Management.Automation.ErrorCategory]::InvalidOperation,$target) + $exclusion=[pscustomobject]@{ + Pid=123;ParentPid=456;ParentCreationFileTimeUtc=200L;ParentExitFileTimeUtc=0L + CandidateCreationFileTimeUtc=100L;CandidateCreationSource='ADVISORY_CIM_NOT_NATIVE' + ParentCreationSource='HELD_NATIVE_SNAPSHOT';NativeWin32Error=5 + NativeFailure=('n'*5000);Reason='predates held parent generation (advisory, native unavailable)' + Disposition='EXCLUDED_ADVISORY_PREDATES_NATIVE_PARENT_NATIVE_UNAVAILABLE' + RawCimRow=[pscustomobject]@{ProcessId=123;ParentProcessId=456;SessionId=0 + CreationDate=[DateTimeOffset]::Parse('2026-11-06T00:00:00Z');ExecutablePath=$null;CommandLine=('c'*5000)} + OriginalErrorRecord=$errorRecord + } + $json=& ([scriptblock]::Create($boundary[0].Extent.Text)) + Assert-CapTest ($script:CapDiagnosticGetterVisits -eq 0) ` + "Diagnostic serialization traversed a retained live-object graph: getters=$script:CapDiagnosticGetterVisits" + $diagnostic=$json|ConvertFrom-Json + Assert-CapTest ([object]::ReferenceEquals($exclusion.OriginalErrorRecord,$errorRecord) -and + $exclusion.Disposition -ceq 'EXCLUDED_ADVISORY_PREDATES_NATIVE_PARENT_NATIVE_UNAVAILABLE' -and + $diagnostic.errorType -ceq 'System.InvalidOperationException' -and + $diagnostic.fullyQualifiedErrorId -ceq 'SyntheticDiagnostic' -and + $diagnostic.message -ceq 'SYNTHETIC diagnostic failure' -and + $diagnostic.NativeWin32Error -eq 5 -and $diagnostic.CimSessionId -eq 0 -and + $diagnostic.NativeFailure.Length -eq 2048 -and $diagnostic.CimCommandLine.Length -eq 2048 -and + $diagnostic.truncatedFields.Count -eq 2 -and + $diagnostic.omittedGraphs -ccontains 'OriginalErrorRecord.TargetObject' -and + -not $diagnostic.PSObject.Properties['OriginalErrorRecord']) ` + 'Bounded diagnostic lost useful scalar facts, explicit omissions/truncation, or original in-memory error.' + return [pscustomobject]@{getterVisits=0;originalErrorRetained=$true;scalarDiagnostic=$diagnostic + evidenceMode='SYNTHETIC_GETTER_QUALIFICATION_NOT_ORIGINAL_BLOCKED_STACK'} +} + function Invoke-CapAdvisoryOlderContract { $caseRoot = Join-Path $scratch 'advisory-older' $null = New-Item -ItemType Directory -Path $caseRoot @@ -1637,7 +1688,8 @@ function Invoke-CapAdvisoryOlderContract { $run = $null $jobHandle = [CapProcessJob]::Create() $originalAccess = & $module { (Get-Item Function:Read-CapProcessAccess).ScriptBlock } - $detail = [ordered]@{ subcases = @(); jobEmpty = $false; stdoutEof = $false; stderrEof = $false } + $detail = [ordered]@{ subcases = @(); jobEmpty = $false; stdoutEof = $false; stderrEof = $false + diagnosticQualification=(Invoke-CapAdvisoryDiagnosticContract) } try { $parent = Get-CapProcessIdentity -Process $self -ExpectedImagePath $self.MainModule.FileName ` -Role 'graphcode-app-before-normal' @@ -1720,7 +1772,19 @@ function Invoke-CapAdvisoryOlderContract { $record.OriginalErrorRecord -is [Management.Automation.ErrorRecord] -and [object]::ReferenceEquals($record.OriginalErrorRecord.Exception, $ownFailures[0])) ` 'Advisory exclusion lost raw row, original native failure, timestamp sources or actual held parent proof.' - $detail.subcases += @{ name = 'strictly older advisory time and native getter access-denied excluded'; evidence = $record } + $diagnostics=@(Get-Content -LiteralPath (Join-Path $scratch 'test-calls.jsonl') | + ForEach-Object {$_|ConvertFrom-Json} | + Where-Object command -CEQ 'CapDescendantAdvisoryExcluded' | + ForEach-Object {$_.parameters.diagnosticJson|ConvertFrom-Json} | + Where-Object {$_.Pid -eq $run.Identity.Pid -and $_.ParentPid -eq $parent.Pid -and + $_.ParentCreationFileTimeUtc -eq $parent.CreationFileTimeUtc}) + Assert-CapTest ($diagnostics.Count -eq 1 -and $diagnostics[0].NativeWin32Error -eq 5 -and + $diagnostics[0].CimSessionId -eq 0 -and $diagnostics[0].fullyQualifiedErrorId -and + $diagnostics[0].omittedGraphs -ccontains 'OriginalErrorRecord.TargetObject' -and + -not $diagnostics[0].PSObject.Properties['OriginalErrorRecord']) ` + 'Actual exclusion did not emit useful bounded native/CIM/error diagnostics.' + $detail.subcases += @{ name = 'strictly older advisory time and native getter access-denied excluded' + evidence = (Get-CapAdvisoryExclusionDiagnostic -Exclusion $record) } $null = Get-CapOwnedDescendants -Roots @($parent) @arguments Assert-CapTest (@(Get-CapExcludedDescendants | Where-Object $ownExclusion).Count -eq 1) ` 'Repeated advisory-older observation duplicated exclusion evidence.' @@ -3095,7 +3159,7 @@ function Invoke-CapMatchedContracts { $workerSource.Contains('function Get-CapWorkerExpectedImageCount') -and $workerSource.Contains('$images.Count -eq (Get-CapWorkerExpectedImageCount)') -and $workerSource.Contains('function Wait-CapMatchedWireMode') -and - $workerSource.Contains('[GraphCodeUiaGateState]::PostKeyboard') -and + $workerSource.Contains('[CapUiaGateState]::PostKeyboard') -and $workerSource.Contains('function Invoke-CapMatchedTerminalSceneSupplement') -and $workerSource.Contains('function Invoke-CapWireCompletionSupplement') -and $workerSource.Contains('NATIVE_SENDINPUT_TO_FOCUSED_LOCAL_CONSOLE_WITH_SHELL_COMPUTED_NONCE_PID_ARITHMETIC') -and @@ -5357,7 +5421,7 @@ function Invoke-CapReceiptReadControl { [IO.File]::WriteAllText($temporary, (ConvertTo-Json $PublishValue -Compress), [Text.UTF8Encoding]::new($false)) $publishDeadline=[DateTime]::UtcNow.AddSeconds(2) while ($true) { - try {[IO.File]::Move($temporary,$Path,$true);break} + try {[IO.File]::Replace($temporary,$Path,[System.Management.Automation.Language.NullString]::Value);break} catch [UnauthorizedAccessException] { if ([DateTime]::UtcNow -ge $publishDeadline) {throw} Start-Sleep -Milliseconds 25 @@ -5425,7 +5489,13 @@ function Invoke-CapReceiptIoCase { } 'partial-json' { [IO.File]::WriteAllText($path, '{', [Text.UTF8Encoding]::new($false)) - $read = Invoke-CapReceiptReadControl $run $path $deadline -PublishValue $value + $snapshot = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, + ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + try { + $read = Invoke-CapReceiptReadControl $run $path $deadline -PublishValue $value + Assert-CapTest ($snapshot.ReadByte() -eq 123 -and $snapshot.ReadByte() -eq -1) ` + 'Atomic publication did not preserve the held partial receipt snapshot.' + } finally { $snapshot.Dispose() } if (-not $read.Outcome.Succeeded) { throw $read.Outcome.Failure } Assert-CapTest (-not $read.CompletedBeforeRelease -and $read.Outcome.Record.creationFileTimeUtc -eq 100000000L) ` 'A partial JSON receipt was accepted before complete atomic publication.' @@ -7752,6 +7822,17 @@ if ($DAdapterContractOnly -and $DAdapterCase -cin @('SceneContract','FixtureDire Write-CapDContractReport } +if ($AdvisoryDiagnosticContractOnly) { + Invoke-CapTest 'advisory diagnostic preserves original errors without traversing live graphs' { Invoke-CapAdvisoryDiagnosticContract } + $passed=@($results|Where-Object passed -EQ $true).Count + $failed=@($results|Where-Object passed -EQ $false).Count + [pscustomobject]@{suite='UiCaptureAdvisoryDiagnosticContract';executed=$results.Count + passed=$passed;failed=$failed;skipped=0;cases=@($results);gui='NotExecuted';images=0}| + ConvertTo-Json -Depth 20|Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM + [ordered]@{suite='UiCaptureAdvisoryDiagnosticContract';executed=$results.Count;passed=$passed;failed=$failed}|ConvertTo-Json -Compress + if ($results.Count -ne 1 -or $passed -ne 1 -or $failed -ne 0) {exit 1} + exit 0 +} if ($AdvisoryOlderContractOnly) { Invoke-CapTest 'advisory older candidate excludes only with unavailable native access and proved native parent' { Invoke-CapAdvisoryOlderContract } $passed = @($results | Where-Object passed -EQ $true).Count @@ -7796,6 +7877,10 @@ if ($PrepareFixturesOnly) { } $null = New-CapHarmlessTarget $null = New-CapCimRaceTarget + foreach ($path in $sourceHashesBefore.Keys) { + Assert-CapTest ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ceq $sourceHashesBefore[$path]) ` + "Source changed during fixture preparation: $path" + } $reference = Write-CapPrebuiltFixtureManifest $null = Read-CapPrebuiltFixtures $reference.path $reference.sha256 $reference | ConvertTo-Json -Depth 8 -Compress diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 02898608..d8f24448 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -6059,9 +6059,9 @@ $actionImportSummaries = @($actionImportOutput | Where-Object { }) if ($actionImportSummaries.Count -ne 1) { throw "RED: action import structured count missing or duplicated" } $actionImportSummary = ConvertFrom-Json $actionImportSummaries[0] -if ($actionImportSummary.executed -ne 18 -or $actionImportSummary.passed -ne 18 -or +if ($actionImportSummary.executed -ne 20 -or $actionImportSummary.passed -ne 20 -or $actionImportSummary.failed -ne 0 -or $actionImportSummary.skipped -ne 0) { - throw "RED: action import requires all 18 contracts without skips" + throw "RED: action import requires all 20 contracts without skips" } $actionBindingOutput = @(& $pwsh -NoProfile -File (Join-Path $PSScriptRoot "ActionWindowIdentity.Tests.ps1")) $actionBindingOutput | ForEach-Object { Write-Host $_ } @@ -6071,9 +6071,9 @@ $actionBindingSummaries=@($actionBindingOutput | Where-Object { }) if ($actionBindingSummaries.Count -ne 1) {throw "RED: action binding structured count missing or duplicated"} $actionBindingSummary=ConvertFrom-Json $actionBindingSummaries[0] -if ($actionBindingSummary.executed -ne 62 -or $actionBindingSummary.passed -ne 62 -or +if ($actionBindingSummary.executed -ne 85 -or $actionBindingSummary.passed -ne 85 -or $actionBindingSummary.failed -ne 0 -or $actionBindingSummary.skipped -ne 0) { - throw "RED: action window binding requires all 62 contracts without skips" + throw "RED: action window binding requires all 85 contracts without skips" } $actionTopologyOutput=@(& $pwsh -NoProfile -File (Join-Path $PSScriptRoot "ActionTopologyImport.Tests.ps1")) $actionTopologyOutput | ForEach-Object {Write-Host $_} diff --git a/Tools/windows/Tests/WindowHierarchyDiagnostic.Tests.ps1 b/Tools/windows/Tests/WindowHierarchyDiagnostic.Tests.ps1 new file mode 100644 index 00000000..04382203 --- /dev/null +++ b/Tools/windows/Tests/WindowHierarchyDiagnostic.Tests.ps1 @@ -0,0 +1,387 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ScratchRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if(Test-Path -LiteralPath $ScratchRoot){throw 'Fresh diagnostic test scratch is required.'} +$null=[IO.Directory]::CreateDirectory($ScratchRoot) +$path=Join-Path $PSScriptRoot '..\UiCaptureDriver.psm1' +$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($path,[ref]$null,[ref]$errors) +if($errors.Count-ne0){throw 'Actual module source must parse.'} +$assignment=@($ast.FindAll({param($node)$node-is[Management.Automation.Language.AssignmentStatementAst]-and + $node.Left.Extent.Text-ceq'$nativeSource'},$false)) +if($assignment.Count-ne1){throw 'Actual native source assignment missing/ambiguous.'} +$literal=@($assignment[0].Right.FindAll({param($node)$node-is[Management.Automation.Language.StringConstantExpressionAst]},$true)) +if($literal.Count-ne1){throw 'Native source must be one actual literal.'} +$source=$literal[0].Value +$start=$source.IndexOf('if (SendMessageTimeoutW(window, 0x000D,') +$end=$source.IndexOf(' }',$source.IndexOf('throw error;',$start))+' }'.Length +if($start-lt0-or$end-le$start){throw 'Actual window text failure region missing.'} +$region=$source.Substring($start,$end-$start) +$condition='== IntPtr.Zero)' +$body=$region.Substring($region.IndexOf($condition)+$condition.Length).Trim() +if($body.StartsWith('{')){$body=$body.Substring(1,$body.Length-2).Trim()} +$body=$body.Replace('int nativeError = Marshal.GetLastWin32Error();','') +# Native observations are controlled; the actual production throw/data block runs unchanged. +$fixture=@" +using System; +using System.Text; +public static class WindowCensusFailureFixture { + private struct NativeRect { public int Left, Top, Right, Bottom; } + public static void Throw(long handle, uint pid, uint thread, string className, int nativeError) { + var window = new IntPtr(handle); + var cls = new StringBuilder(className); + long parent = 555, owner = 666; + bool visible = false, enabled = true; + var bounds = new NativeRect { Left=-10, Top=20, Right=300, Bottom=400 }; + _ = parent; _ = owner; _ = visible; _ = enabled; _ = bounds; + $body + } +} +"@ +Add-Type -TypeDefinition $fixture +$nodeStart=$source.IndexOf('public sealed class CapWindowNode {') +$nodeEnd=$source.IndexOf('public sealed class CapPointerReceipt {',$nodeStart) +$selectionStart=$source.IndexOf('public static CapWindowNode[] SelectWindowFamily(') +$ancestryStart=$source.IndexOf('public static void AssertWindowAncestry(') +if($ancestryStart-ge0){$selectionStart=$ancestryStart} +$selectionEnd=$source.IndexOf('private static void CheckWindowCensusDeadline(') +if($selectionEnd-lt0){$selectionEnd=$source.IndexOf('public static CapWindowNode[] WindowHierarchy(')} +$selection=if($selectionStart-ge0){$source.Substring($selectionStart,$selectionEnd-$selectionStart)}else{''} +Add-Type -TypeDefinition ("using System;`n"+$source.Substring($nodeStart,$nodeEnd-$nodeStart)+ + "`npublic static class WindowFamilySelectionFixture {`n"+$selection+"`n}") +$results=[Collections.Generic.List[object]]::new() +function Check([string]$CaseName,[scriptblock]$Action){ + try{& $Action;$results.Add(@{name=$CaseName;passed=$true})} + catch{$results.Add(@{name=$CaseName;passed=$false;error=$_.Exception.Message});Write-Host "FAIL $CaseName : $($_.Exception.Message)"} +} +function Assert-True([bool]$Value){if(-not$Value){throw 'Diagnostic assertion failed.'}} +Check 'actual throw construction preserves existing message and scalar native facts' { + $caught=$null + try{[WindowCensusFailureFixture]::Throw(71960082,17088,1234,'GraphCodeSheetHost',1460)}catch{$caught=$_.Exception.GetBaseException()} + Assert-True ($caught-is[InvalidOperationException]-and$caught.Message-ceq'Window census text query failed/timed out for HWND 71960082') + $facts=$caught.Data['WindowTextQueryFailure'] + Assert-True ($null-ne$facts-and$facts.Handle-eq71960082-and$facts.ProcessId-eq17088-and + $facts.ThreadId-eq1234-and$facts.ClassName-ceq'GraphCodeSheetHost'-and$facts.Win32Error-eq1460-and + $facts.Parent-eq555-and$facts.Owner-eq666-and$facts.Visible-eq$false-and$facts.Enabled-eq$true-and + $facts.Left-eq-10-and$facts.Top-eq20-and$facts.Right-eq300-and$facts.Bottom-eq400) + Assert-True ((@($facts.PSObject.Properties.Name|Sort-Object)-join',')-ceq'Bottom,ClassName,Enabled,Handle,Left,Owner,Parent,ProcessId,Right,ThreadId,Top,Visible,Win32Error') +} +Check 'zero native error retained without inventing timeout cause' { + $caught=$null + try{[WindowCensusFailureFixture]::Throw(1,2,3,'STATIC',0)}catch{$caught=$_.Exception.GetBaseException()} + Assert-True ($null-ne$caught.Data['WindowTextQueryFailure']-and$caught.Data['WindowTextQueryFailure'].Win32Error-eq0) +} +Check 'source keeps one original50ms query and PID filtering before text' { + Assert-True ($region.Contains('name, 2, 50, out textResult)')-and + [regex]::Matches($region,'SendMessageTimeoutW\(').Count-eq1) + $method=$source.Substring($source.IndexOf('public static CapWindowNode[] WindowHierarchy(')) + $method=$method.Substring(0,$method.IndexOf('public static int[] ConvertPointerScreen')) + Assert-True ($method.Contains('WindowHierarchy(IntPtr rootWindow, int expectedPid)')-and + $method.IndexOf('CollectWindowFamily(')-lt$method.IndexOf('SendMessageTimeoutW(')-and + $method.IndexOf('foreach (var node in family)')-lt$method.IndexOf('SendMessageTimeoutW(')-and + $method.Contains('if (pid != expectedPid) throw')-and + $source.Contains('if (rootWindow == IntPtr.Zero || expectedPid <= 0)')) + $topologyStart=$source.IndexOf('private static CapWindowNode ReadTopology(') + $topologyEnd=$source.IndexOf('private static CapWindowNode[] CollectWindowFamily(') + $querySource=$source.Substring($topologyStart,$topologyEnd-$topologyStart)+$method + foreach($query in @('GetWindowRect(window, out bounds)','GetAncestor(window, 1)','GetWindow(window, 4)', + 'IsWindowVisible(window)','IsWindowEnabled(window)')){ + Assert-True ([regex]::Matches($querySource,[regex]::Escape($query)).Count-eq1-and + $querySource.IndexOf($query)-lt$querySource.IndexOf('SendMessageTimeoutW(')) + } + Assert-True ($method.Contains('Parent=parent, Owner=owner')-and + $method.Contains('Visible=visible, Enabled=enabled')-and + $method-match'(?s)var liveTopology = ReadTopology\(window, pid\);\s*AssertWindowAncestry\(node, liveTopology\);\s*long parent = liveTopology.Parent, owner = liveTopology.Owner;\s*UIntPtr textResult;\s*if \(SendMessageTimeoutW') + Assert-True (-not($body-match'GetWindowText|GetAncestor|GetWindow\(|GetDlgCtrlID|GetCapture|Thread.Sleep|Sleep\(')) +} +Check 'source retains received thread return and immediately captures existing last-error cache' { + Assert-True ($source.Contains('uint thread = GetWindowThreadProcessId(window, out pid);')-and + $region-match'(?s)== IntPtr.Zero\)\s*\{\s*int nativeError = Marshal.GetLastWin32Error\(\);') +} +Check 'source forwards only closed scalar failure facts without changing rethrow' { + $text=[IO.File]::ReadAllText($path) + $begin=$text.IndexOf('function Get-CapOwnedWindowHierarchy {') + $finish=$text.IndexOf('function Get-CapDirectChildren {',$begin) + $helper=$text.Substring($begin,$finish-$begin) + Assert-True ($helper.Contains("failedWindow=(`$cause.Data['WindowTextQueryFailure']|ConvertTo-Json -Depth 3 -Compress)")-and + $helper.Contains("ancestryChange=(`$cause.Data['WindowAncestryFailure']|ConvertTo-Json -Depth 3 -Compress)")-and + $helper.Contains("observedRows=(`$cause.Data['ObservedRows']|ConvertTo-Json -Depth 10 -Compress)")-and + $helper.Contains('[CapNative]::WindowHierarchy($RootHandle,$ProcessIdentity.Pid)')-and + $helper-match'(?s)OwnedWindowCensusFailure.*?\}\s*throw') + $directory=Split-Path -Parent $path + $entryText=[IO.File]::ReadAllText((Join-Path $directory 'Invoke-MatchedActionSupplement.ps1')) + Assert-True ($entryText.Contains('[switch]$WireCensusDiagnostic')-and + $entryText.Contains('-FirstCensusDiagnostic:$WireCensusDiagnostic')-and + $entryText.Contains("'DIAGNOSTIC_NOT_W1_ACCEPTANCE'")-and + $entryText.Contains('$wireCensusDiagnosticMode=$WireCensusDiagnostic.IsPresent')-and + $entryText.Contains('$null -ne $receipt -or $actionSupplementReceipts.Count -ne 0 -or $images.Count -ne 0')) + $entryAst=[Management.Automation.Language.Parser]::ParseInput($entryText,[ref]$null,[ref]$null) + $refusal=$entryAst.Find({param($n)$n-is[Management.Automation.Language.IfStatementAst]-and + $n.Clauses[0].Item1.Extent.Text-ceq'$WireCensusDiagnostic -and (-not $FunctionalWireOnly -or $DryRun)'},$true) + Assert-True ($null-ne$refusal) + foreach($options in @(@($false,$false),@($true,$true))){ + $WireCensusDiagnostic=$true;$FunctionalWireOnly=$options[0];$DryRun=$options[1] + $refused=$false + try{& ([scriptblock]::Create($refusal.Extent.Text))}catch{$refused=$_.Exception.Message-ceq'Wire census diagnostic requires FunctionalWireOnly and refuses DryRun.'} + Assert-True $refused + } + $WireCensusDiagnostic=$true;$FunctionalWireOnly=$true;$DryRun=$false + & ([scriptblock]::Create($refusal.Extent.Text)) + $statusStatements=@($entryAst.FindAll({param($n)$n-is[Management.Automation.Language.AssignmentStatementAst]-and + $n.Left.Extent.Text-cin@('$successfulStatus','$terminalOutcome')},$true)) + Assert-True ($statusStatements.Count-eq2) + foreach($diagnostic in @($false,$true)){ + $WireCensusDiagnostic=$diagnostic;$primaryFailure=$null;$cleanupFailures=@();$independent=@() + foreach($statement in $statusStatements){. ([scriptblock]::Create($statement.Extent.Text))} + Assert-True ($terminalOutcome-ceq$(if($diagnostic){'DIAGNOSTIC_NOT_W1_ACCEPTANCE'}else{'COMPLETE'})) + $primaryFailure='Controlled failure' + . ([scriptblock]::Create($statusStatements[1].Extent.Text)) + Assert-True ($terminalOutcome-ceq'FAILED') + $primaryFailure=$null;$cleanupFailures=@('Controlled cleanup failure') + . ([scriptblock]::Create($statusStatements[1].Extent.Text)) + Assert-True ($terminalOutcome-ceq'FAILED') + } + $workerText=[IO.File]::ReadAllText((Join-Path $directory 'Invoke-UiCaptureWorker.ps1')) + $workerAst=[Management.Automation.Language.Parser]::ParseInput($workerText,[ref]$null,[ref]$null) + $wire=$workerAst.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and + $n.Name-ceq'Invoke-CapWireCompletionSupplement'},$true) + $branch=$wire.Find({param($n)$n-is[Management.Automation.Language.IfStatementAst]-and + $n.Clauses[0].Item1.Extent.Text-ceq'$FirstCensusDiagnostic'},$true) + $normalIndex=$workerText.IndexOf('$observation=Read-CapReadyWireWindowObservation') + if($normalIndex-lt0){$normalIndex=$workerText.IndexOf('$sheet=Resolve-CapOwnedSheet -Rows $hierarchy')} + Assert-True ($null-ne$branch-and$branch.Extent.StartOffset-lt$normalIndex) + Assert-True ([regex]::Matches($branch.Extent.Text,'Read-CapWireWindowObservation').Count-eq1-and + -not($branch.Extent.Text-match'Resolve-CapOwnedSheet|Read-UiaReadonlyEdgeEndpoints|Start-Sleep|GetCurrentPattern')) + $wireTry=@($wire.Body.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.TryStatementAst]}) + Assert-True ($wireTry.Count-eq1-and$wireTry[0].Body.Extent.StartOffset-lt$branch.Extent.StartOffset-and + $wireTry[0].Finally.Extent.Text.Contains('Close-CapGraphCodeApp -AppRun $app')-and + $wireTry[0].Finally.Extent.Text.Contains('Stop-CapManagedProcess -Identity $stub.Identity')-and + $wireTry[0].Finally.Extent.Text.Contains('if (-not $primary -and $wireCleanupErrors.Count -gt 0)')) + function Read-CapWireWindowObservation {param($AppRun,$Stage) + $script:diagnosticReads++ + if($script:diagnosticError){throw $script:diagnosticError} + return [pscustomobject]@{Handle=123} + } + function Write-CapTrace {param($Command,$Parameters)} + $app=[pscustomobject]@{Window=123};$FirstCensusDiagnostic=$true + $script:diagnosticReads=0;$script:diagnosticError=$null;$cleanup=0 + $transaction=[scriptblock]::Create($branch.Extent.Text+"`nthrow 'Diagnostic crossed first-census boundary.'") + try{& $transaction}finally{$cleanup++} + Assert-True ($script:diagnosticReads-eq1-and$cleanup-eq1) + try{[WindowCensusFailureFixture]::Throw(71960082,17088,1234,'ComboLBox',1460)} + catch{$script:diagnosticError=$_.Exception.GetBaseException()} + $caught=$null + try{& $transaction}catch{$caught=$_.Exception}finally{$cleanup++} + Assert-True ([object]::ReferenceEquals($caught,$script:diagnosticError)-and + $script:diagnosticReads-eq2-and$cleanup-eq2-and$caught.Data['WindowTextQueryFailure'].Owner-eq666-and + @($caught.Data['WindowTextQueryFailure'].PSObject.Properties).Count-eq13) + $FirstCensusDiagnostic=$false;$script:diagnosticError=$null + & ([scriptblock]::Create($branch.Extent.Text+"`n`$script:defaultContinued=`$true")) + Assert-True ($script:defaultContinued-and$script:diagnosticReads-eq2) +} +function Scope-Node([long]$Handle,[long]$Parent=0,[long]$Owner=0,[int]$ProcessId=7,[bool]$Visible=$true){ + $node=[CapWindowNode]::new() + $node.Handle=$Handle;$node.Parent=$Parent;$node.Owner=$Owner;$node.ProcessId=$ProcessId + $node.Visible=$Visible;$node.Enabled=$false;$node.ClassName='ArbitraryClass' + return $node +} +function Select-Scope($Rows,[long[]]$Tops,[long]$Root=1){ + return ,@([WindowFamilySelectionFixture]::SelectWindowFamily([CapWindowNode[]]$Rows,$Tops,$Root,7)) +} +Check 'scope keeps exact root without inspecting its external parent' { + $actual=Select-Scope @((Scope-Node 1 65548)) @(1) + Assert-True ($actual.Count-eq1-and$actual[0].Handle-eq1) +} +Check 'scope keeps hidden disabled child and grandchild' { + $actual=Select-Scope @((Scope-Node 1),(Scope-Node 2 1 0 7 $false),(Scope-Node 3 2 0 7 $false)) @(1,2) + Assert-True (($actual.Handle-join',')-ceq'1,2,3') +} +Check 'scope retains owned host viewport native form and scrim' { + $actual=Select-Scope @((Scope-Node 1),(Scope-Node 2 0 1),(Scope-Node 3 2),(Scope-Node 4 3), + (Scope-Node 5 0 1 7 $false)) @(1,2,5) + Assert-True (($actual.Handle-join',')-ceq'1,2,3,4,5') +} +Check 'scope retains nested owned modal and its hidden child' { + $actual=Select-Scope @((Scope-Node 1),(Scope-Node 2 0 1),(Scope-Node 3 0 2), + (Scope-Node 4 3 0 7 $false)) @(1,2,3) + Assert-True (($actual.Handle-join',')-ceq'1,2,3,4') +} +Check 'scope classification does not depend on enumeration order' { + $actual=Select-Scope @((Scope-Node 4 3),(Scope-Node 3 0 2),(Scope-Node 2 0 1),(Scope-Node 1)) @(3,1,2) + Assert-True ((@($actual.Handle|Sort-Object)-join',')-ceq'1,2,3,4') +} +Check 'scope excludes positively unrelated families regardless of visibility or class' { + $related=Scope-Node 2 1 0 7 $false;$related.ClassName='ComboLBox' + $other=Scope-Node 8 65548 0 7 $true;$other.ClassName='ComboLBox' + $actual=Select-Scope @((Scope-Node 1),$related,$other,(Scope-Node 9 0 8 7 $false),(Scope-Node 10 9)) @(1,8,9) + Assert-True (($actual.Handle-join',')-ceq'1,2') +} +foreach($fault in @('zero-root','missing-root','duplicate-root','conflicting-duplicate','owner-cycle', + 'parent-cycle','missing-owner','missing-parent','foreign-descendant','foreign-root')){ + Check "scope refuses $fault" { + $rows=@((Scope-Node 1),(Scope-Node 2 1));$tops=@(1L);$root=1L + switch($fault){ + 'zero-root' {$root=0L} + 'missing-root' {$rows=@((Scope-Node 2));$tops=@(2L)} + 'duplicate-root' {$rows+=$rows[0]} + 'conflicting-duplicate' {$rows+=(Scope-Node 2 99)} + 'owner-cycle' {$rows+=@((Scope-Node 3 0 4),(Scope-Node 4 0 3));$tops+=@(3L,4L)} + 'parent-cycle' {$rows[1].Parent=3;$rows+=(Scope-Node 3 2)} + 'missing-owner' {$rows+=(Scope-Node 3 0 99);$tops+=3L} + 'missing-parent' {$rows[1].Parent=99} + 'foreign-descendant' {$rows[1].ProcessId=8} + 'foreign-root' {$rows[0].ProcessId=8} + } + $caught=$null + try{Select-Scope $rows $tops $root|Out-Null}catch{$caught=$_.Exception.GetBaseException()} + Assert-True ($caught-is[InvalidOperationException]-and$caught.Message.StartsWith('Window family:')) + if($fault-ceq'owner-cycle'){ + $rows=@((Scope-Node 1 0 2),(Scope-Node 2 0 1));$caught=$null + try{Select-Scope $rows @(1,2)|Out-Null}catch{$caught=$_.Exception.GetBaseException()} + Assert-True ($caught-is[InvalidOperationException]-and$caught.Message-ceq'Window family: cyclic ownership.') + $rows=@((Scope-Node 1 0 2),(Scope-Node 2 1));$caught=$null + try{Select-Scope $rows @(1,2)|Out-Null}catch{$caught=$_.Exception.GetBaseException()} + Assert-True ($caught-is[InvalidOperationException]-and$caught.Message-ceq'Window family: cyclic ancestry.') + } + } +} +Check 'live ancestry accepts stable identity and relationships' { + $expected=Scope-Node 2 1 + $observed=Scope-Node 2 1 + [WindowFamilySelectionFixture]::AssertWindowAncestry($expected,$observed) + Assert-True ($observed.Parent-eq1-and$observed.Owner-eq0) +} +foreach($field in @('Parent','Owner','Handle','ProcessId')){ + Check "live ancestry refuses changed $field with observed scalar evidence" { + $expected=Scope-Node 2 1 + $observed=Scope-Node 2 1 + $observed.$field=99 + $caught=$null + try{[WindowFamilySelectionFixture]::AssertWindowAncestry($expected,$observed)} + catch{$caught=$_.Exception.GetBaseException()} + Assert-True ($caught-is[InvalidOperationException]-and$caught.Message-ceq'Window family: selected ancestry changed.') + $facts=$caught.Data['WindowAncestryFailure'] + Assert-True ($facts.$field-eq99-and$facts.ExpectedParent-eq1-and$facts.ExpectedOwner-eq0-and + $facts.ExpectedHandle-eq2-and$facts.ExpectedProcessId-eq7-and + (@($facts.PSObject.Properties.Name|Sort-Object)-join',')-ceq'ExpectedHandle,ExpectedOwner,ExpectedParent,ExpectedProcessId,Handle,Owner,Parent,ProcessId') + } +} +$readinessAst=[Management.Automation.Language.Parser]::ParseFile( + (Join-Path (Split-Path -Parent $path) 'Invoke-UiCaptureWorker.ps1'),[ref]$null,[ref]$null) +$readiness=$readinessAst.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and + $n.Name-ceq'Read-CapReadyWireWindowObservation'},$true) +if($readiness){. ([scriptblock]::Create($readiness.Extent.Text))} +foreach($name in @('Assert-CapUiaProcessIds','Resolve-CapOwnedSheet')){ + $definition=$ast.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and$n.Name-ceq$name},$true) + . ([scriptblock]::Create($definition.Extent.Text)) +} +function Write-CapTrace {param($Command,$Parameters)} +function Assert-CapWorkerDeadline {param($Stage)} +function Get-CapOwnedWindowStructure {param($RootHandle,$ProcessIdentity) + $script:structureReads++;$rows=& $script:structureProbe;foreach($row in $rows){$row} +} +function Read-CapWireWindowObservation {param($AppRun,$Stage) + $script:fullReads++;$rows=& $script:fullProbe;foreach($row in $rows){$row} +} +function Readiness-Rows([bool]$Visible=$true){ + $rows=@(foreach($spec in @(@(1,65548,0,'root'),@(2,65548,1,'GraphCodeSheetHost'), + @(3,2,0,'viewport'),@(4,3,0,'GraphCodeNativeForm'))){ + $row=[CapWindowStructure]::new();$row.Handle=$spec[0];$row.Parent=$spec[1];$row.Owner=$spec[2] + $row.ClassName=$spec[3];$row.ProcessId=7;$row.Visible=$Visible;$row.Enabled=$true;$row + }) + return ,$rows +} +function Reset-Readiness { + $script:readyClock=[DateTime]::SpecifyKind([DateTime]'2026-11-06T12:00:00',[DateTimeKind]::Utc) + $script:structureReads=0;$script:fullReads=0;$script:sleeps=[Collections.Generic.List[int]]::new() + $script:structureProbe={Readiness-Rows};$script:fullProbe={Readiness-Rows} +} +function Invoke-Readiness([DateTime]$Deadline){ + if(-not$readiness){throw 'Actual production structural-readiness helper missing.'} + $app=[pscustomobject]@{Window=[IntPtr]1;Run=@{Identity=@{Pid=7}}} + return Read-CapReadyWireWindowObservation -AppRun $app -DeadlineUtc $Deadline ` + -NowProvider {$script:readyClock} -SleepAction {param($ms) + $script:sleeps.Add($ms);$script:readyClock=$script:readyClock.AddMilliseconds($ms)} +} +Check 'structure observation has no caption and shares family and live ancestry guards' { + Assert-True (-not([CapWindowStructure].GetFields().Name -contains 'Name')) + $start=$source.IndexOf('public static CapWindowStructure[] WindowStructure(') + $finish=$source.IndexOf('public static CapWindowNode[] WindowHierarchy(') + $body=$source.Substring($start,$finish-$start) + Assert-True ($body.Contains('CollectWindowFamily(rootWindow, expectedPid, clock)')-and + $body.Contains('AssertWindowAncestry(node, liveTopology)')-and + -not($body-match'SendMessage|Automation|\bName\s*=')-and + -not($body-match'if\s*\(\s*!?(visible|enabled)|ClassName\s*(==|!=)')) +} +Check 'pending hidden structure uses no full text or UIA then ready uses one full observation' { + Reset-Readiness + $script:structureProbe={Readiness-Rows ($script:structureReads-gt1)} + $script:fullProbe={if($script:structureReads-lt2){throw 'Full observation preceded visible structure.'};Readiness-Rows} + $result=Invoke-Readiness $script:readyClock.AddSeconds(5) + Assert-True ($script:structureReads-eq2-and$script:fullReads-eq1-and + $script:sleeps.Count-eq1-and$script:sleeps[0]-eq100-and$result.sheet.host.Handle-eq2) + Assert-True (-not($readiness.Extent.Text-match'Read-UiaReadonlyEdgeEndpoints|GetCurrentPattern|FromHandle|Get-CapActionDeadline')) +} +Check 'readiness respects original deadline before reads and after structural transition' { + Assert-True ($null-ne$readiness) + foreach($stage in @('expired','structure','full')){ + Reset-Readiness + $deadline=$script:readyClock.AddMilliseconds(10) + switch($stage){ + 'structure'{$script:structureProbe={$script:readyClock=$script:readyClock.AddMilliseconds(10);Readiness-Rows}} + 'full'{$script:fullProbe={$script:readyClock=$script:readyClock.AddMilliseconds(10);Readiness-Rows}} + 'expired'{$script:readyClock=$deadline} + } + $caught=$null + try{Invoke-Readiness $deadline|Out-Null}catch{$caught=$_} + Assert-True ($null-ne$caught-and$script:fullReads-eq$(if($stage-ceq'full'){1}else{0})-and$script:sleeps.Count-eq0) + } +} +Check 'structural ambiguity malformed ancestry and foreign identity refuse without full text' { + Assert-True ($null-ne$readiness) + foreach($fault in @('ambiguous','missing-parent','foreign')){ + Reset-Readiness + $script:structureProbe={ + $rows=Readiness-Rows + switch($fault){'ambiguous'{$rows+=$rows[1]};'missing-parent'{$rows[3].Parent=99};'foreign'{$rows[3].ProcessId=8}} + return $rows + } + $caught=$null + try{Invoke-Readiness $script:readyClock.AddSeconds(5)|Out-Null}catch{$caught=$_} + Assert-True ($null-ne$caught-and$script:fullReads-eq0-and$script:sleeps.Count-eq0) + } +} +Check 'structural and full failure preserve primary error and never re-poll' { + Assert-True ($null-ne$readiness) + foreach($stage in @('structure','full')){ + Reset-Readiness + if($stage-ceq'structure'){ + try{[WindowFamilySelectionFixture]::AssertWindowAncestry((Scope-Node 2 1),(Scope-Node 2 99))} + catch{$script:probeError=$_.Exception.GetBaseException()} + }else{ + try{[WindowCensusFailureFixture]::Throw(22807762,7,9,'GraphCodeSheetHost',1460)} + catch{$script:probeError=$_.Exception.GetBaseException()} + } + if($stage-ceq'structure'){$script:structureProbe={throw $script:probeError}} + else{$script:fullProbe={throw $script:probeError}} + $caught=$null + try{Invoke-Readiness $script:readyClock.AddSeconds(5)|Out-Null}catch{$caught=$_.Exception.GetBaseException()} + Assert-True ([object]::ReferenceEquals($caught,$script:probeError)-and$script:sleeps.Count-eq0-and + $script:fullReads-eq$(if($stage-ceq'full'){1}else{0})-and$caught.Data.Count-eq1) + } +} +Check 'ready to full identity change refuses rather than re-resolving readiness' { + Assert-True ($null-ne$readiness) + Reset-Readiness + $script:fullProbe={$rows=Readiness-Rows;$rows[1].Handle=9;$rows[2].Parent=9;return $rows} + $caught=$null + try{Invoke-Readiness $script:readyClock.AddSeconds(5)|Out-Null}catch{$caught=$_} + Assert-True ($null-ne$caught-and$script:fullReads-eq1-and$script:structureReads-eq1-and$script:sleeps.Count-eq0) +} +$passed=@($results|Where-Object passed -EQ $true).Count +@{suite='WindowHierarchyDiagnostic';executed=$results.Count;passed=$passed;failed=$results.Count-$passed;skipped=0 + nativeCalls=0;evidence='Actual extracted managed throw/data construction plus source contracts; not native fault behavior';cases=@($results)}| + ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $ScratchRoot 'report.json') -Encoding utf8NoBOM +@{suite='WindowHierarchyDiagnostic';executed=$results.Count;passed=$passed;failed=$results.Count-$passed;skipped=0;nativeCalls=0}|ConvertTo-Json -Compress +if($results.Count-ne32-or$passed-ne32){exit 1} diff --git a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 index 9eab560e..0b055ae5 100644 --- a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 +++ b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 @@ -69,6 +69,18 @@ function Assert-CompleteProof([string] $Text) { "normal execution must require exactly one full PASS marker and one positive execution marker" } +function Test-CompiledCompatibilityWrapper([string] $Text) { + $withoutCompilerFlags = $Text.Replace('"--fixture-compiler-input"', '""'). + Replace('"--fixture-compiler-input-sha256"', '""') + return $Text.Contains('Build-CompiledValidationRunner.ps1') -and + $Text.Contains('ValidationRunner.exe') -and + $Text -match '(?m)^\s*& \$runner --repository-root \$repoRoot --artifact-root \$artifactRoot @compilerArguments 2>&1 \| ForEach-Object \{' -and + $Text -match '(?m)^\$compilerArguments = @\(\)\s*$' -and + $Text.Contains('if ($FixtureCompilerInput -or $FixtureCompilerInputSha256)') -and + $Text -match '\$compilerArguments = @\("--fixture-compiler-input", \$FixtureCompilerInput,\s*"--fixture-compiler-input-sha256", \$FixtureCompilerInputSha256\)' -and + $withoutCompilerFlags -notmatch 'ValidationRunner\.Tests\.ps1|--suite\b|--self-test\b|--fixture\b' +} + $portJobs = Get-WorkflowJobs $portWorkflow $hardeningJobs = Get-WorkflowJobs $hardeningWorkflow @@ -140,11 +152,19 @@ Assert-Contract ($runnerJob.Contains('path: .ci-validation-runner') -and $wrapper = Get-Content ` (Join-Path $repoRoot "Tools\windows\Invoke-ValidationRunnerContract.ps1") -Raw -Assert-Contract ($wrapper.Contains('Build-CompiledValidationRunner.ps1') -and - $wrapper.Contains('ValidationRunner.exe') -and - $wrapper -match '(?m)^\s*& \$runner --repository-root \$repoRoot --artifact-root \$artifactRoot 2>&1 \| ForEach-Object \{' -and - $wrapper -notmatch 'ValidationRunner\.Tests\.ps1|--suite\b|--self-test\b|--fixture\b') ` +Assert-Contract (Test-CompiledCompatibilityWrapper $wrapper) ` "the compatibility wrapper must build and execute only the normal compiled host" +foreach ($mutation in @( + $wrapper.Replace(' @compilerArguments 2>&1', ' 2>&1'), + $wrapper.Replace('"--fixture-compiler-input"', '"--fixture"'), + $wrapper.Replace('"--fixture-compiler-input"', '"--suite"'), + $wrapper.Replace('"--fixture-compiler-input"', '"--fixture-compiler-input-extra"'), + $wrapper.Replace('"--fixture-compiler-input-sha256"', '"--other-sha256"'), + $wrapper.Replace('if ($FixtureCompilerInput -or $FixtureCompilerInputSha256)', 'if ($true)') + )) { + Assert-Contract ($mutation -cne $wrapper -and -not (Test-CompiledCompatibilityWrapper $mutation)) ` + "the wrapper must reject missing forwarding, selectors, legacy/foreign fixture flags or unguarded compiler arguments" +} Assert-CompleteProof $wrapper $privateSdkArtifacts = Join-Path $repoRoot (".build\private-sdk-caller-" + [guid]::NewGuid().ToString("N")) diff --git a/Tools/windows/Tests/WireCreateObservation.Tests.ps1 b/Tools/windows/Tests/WireCreateObservation.Tests.ps1 new file mode 100644 index 00000000..15560311 --- /dev/null +++ b/Tools/windows/Tests/WireCreateObservation.Tests.ps1 @@ -0,0 +1,200 @@ +param([Parameter(Mandatory)][string]$ScratchRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if(Test-Path $ScratchRoot){throw 'Fresh scratch required.'} +$null=[IO.Directory]::CreateDirectory($ScratchRoot) +$path=Join-Path $PSScriptRoot '..\Invoke-UiCaptureWorker.ps1' +$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($path,[ref]$null,[ref]$errors) +if($errors.Count-ne0){throw 'Worker parse failed.'} +$definition=$ast.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and + $n.Name-ceq'Assert-CapWireCreateAction'},$true) +if(-not$definition){throw 'Actual guard definition missing.'} +. ([scriptblock]::Create($definition.Extent.Text)) +$results=[Collections.Generic.List[object]]::new() +function Write-CapTrace {param($Command,$Parameters) + if($script:traceFailure){throw 'CONTROLLED_TRACE_FAILURE'} + $script:trace.Add(@{command=$Command;parameters=$Parameters}) +} +Add-Type -TypeDefinition @' +public class CreateProbe { + public bool failCurrent; + public CreateProbe Current { get { if(failCurrent) throw new System.InvalidOperationException("CONTROLLED_CURRENT_FAILURE"); return this; } } + public CreateProbe State { get { return this; } } + public string Name { get { return "Create"; } } + public int ProcessId { get { return 7; } } + public int NativeWindowHandle { get { return 123; } } + public CreateType ControlType { get { return new CreateType(); } } + public bool enabled, offscreen, fail; + public int enabledReads, offscreenReads; + public bool IsEnabled { get { enabledReads++; if(fail) throw new System.InvalidOperationException("CONTROLLED_PROPERTY_FAILURE"); return enabled; } } + public bool IsOffscreen { get { offscreenReads++; return offscreen; } } + public int[] GetRuntimeId() { return new int[] {1,123}; } +} +public class CreateType { public int Id = 50000; public string ProgrammaticName = "ControlType.Button"; } +'@ +function New-Candidate([bool]$Enabled=$true,[bool]$Offscreen=$false,[bool]$Fail=$false){ + $element=[CreateProbe]::new() + $element.enabled=$Enabled;$element.offscreen=$Offscreen;$element.fail=$Fail + return $element +} +function Check([string]$Name,[scriptblock]$Action){ + $script:trace=[Collections.Generic.List[object]]::new() + $script:traceFailure=$false + try{&$Action;$results.Add(@{name=$Name;passed=$true})} + catch{$results.Add(@{name=$Name;passed=$false;error=$_.Exception.Message})} +} +function Assert([bool]$Value){if(-not$Value){throw 'Assertion failed.'}} +foreach($count in @(0,2)){ + Check "reject count$count without guard property reads" { + $one=New-Candidate + $items=@(for($i=0;$i-lt$count;$i++){$one}) + $failure=$null + try{Assert-CapWireCreateAction -Creates $items}catch{$failure=$_} + Assert ($null-ne$failure-and$one.Current.State.enabledReads-eq0-and$one.Current.State.offscreenReads-eq0) + Assert ($script:trace[0].parameters.count-eq$count) + } +} +Check 'one enabled onscreen candidate read exactly once and records type identity' { + $one=New-Candidate + Assert-CapWireCreateAction -Creates @($one) + Assert ($one.Current.State.enabledReads-eq1-and$one.Current.State.offscreenReads-eq1) + $facts=$script:trace[0].parameters.candidates|ConvertFrom-Json + Assert ($facts.controlTypeId-eq50000-and$facts.nativeHandle-eq123-and$facts.name-ceq'Create') +} +Check 'disabled candidate refuses without offscreen or third state read' { + $one=New-Candidate $false + $failure=$null + try{Assert-CapWireCreateAction -Creates @($one)}catch{$failure=$_} + Assert ($null-ne$failure-and$one.Current.State.enabledReads-eq1-and$one.Current.State.offscreenReads-eq0) +} +Check 'offscreen candidate refuses with one read of each state' { + $one=New-Candidate $true $true + $failure=$null + try{Assert-CapWireCreateAction -Creates @($one)}catch{$failure=$_} + Assert ($null-ne$failure-and$one.Current.State.enabledReads-eq1-and$one.Current.State.offscreenReads-eq1) +} +Check 'property failure is preserved and evidence marked unavailable' { + $one=New-Candidate $true $false $true + $failure=$null + try{Assert-CapWireCreateAction -Creates @($one)}catch{$failure=$_} + if($null-eq$failure-or-not$failure.Exception.ToString().Contains('CONTROLLED_PROPERTY_FAILURE')){ + throw "Property primary not preserved: $($failure.Exception.ToString())" + } + $last=$script:trace[$script:trace.Count-1] + Assert ($last.command-ceq'WireCreatePredicateFailure'-and$last.parameters.enabled-ceq'UNAVAILABLE_NOT_READ') +} +Check 'source keeps exact predicate and no additional FindAll in guard' { + $text=[IO.File]::ReadAllText($path) + Assert ($text.Contains("[Windows.Automation.AutomationElement]::NameProperty,'Create'")-and + $text.Contains('[Windows.Automation.ControlType]::Button')-and + $text.Contains('Assert-CapWireCreateAction -Creates $creates')-and + $text.Contains('$c=$_.get_Current()')-and + $definition.Extent.Text.Contains('$current=$element.get_Current()')-and + $definition.Extent.Text.Contains("`$primary.Exception.Data['WireCreateTraceFailure']=`$_")-and + $definition.Extent.Text.Contains('throw $primary')-and + -not($definition.Extent.Text-match'FindAll|Start-Sleep|\.Invoke\(')) +} +Check 'Current getter failure retains its primary instead of missing-property fallback' { + $one=New-Candidate + $one.failCurrent=$true + $failure=$null + try{Assert-CapWireCreateAction -Creates @($one)}catch{$failure=$_} + Assert ($null-ne$failure-and$failure.Exception.ToString().Contains('CONTROLLED_CURRENT_FAILURE')) +} +Check 'trace failure is attached without replacing primary property error' { + $one=New-Candidate $true $false $true + $script:traceFailure=$true + $failure=$null + try{Assert-CapWireCreateAction -Creates @($one)}catch{$failure=$_} + Assert ($null-ne$failure-and$failure.Exception.ToString().Contains('CONTROLLED_PROPERTY_FAILURE')) + Assert ($failure.Exception.Data['WireCreateTraceFailure'].Exception.ToString().Contains('CONTROLLED_TRACE_FAILURE')) +} +$modulePath=Join-Path $PSScriptRoot '..\UiCaptureDriver.psm1' +$moduleAst=[Management.Automation.Language.Parser]::ParseFile($modulePath,[ref]$null,[ref]$null) +$initialize=$moduleAst.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and + $n.Name-ceq'Initialize-CapUiaClient'},$true) +if(-not$initialize){throw 'Actual client initializer missing.'} +# Substitute only the loader boundary; no runtime provider is registered by these tests. +$initializerText=$initialize.Extent.Text.Replace( + "(Join-Path `$PSScriptRoot 'Import-CompiledValidationAssembly.ps1')",'Invoke-ControlledUiaLoader') +# Extracted scriptblocks have no file directory; retain the real module's source directory as data. +$initializerText=$initializerText.Replace('$PSScriptRoot',"'"+(Split-Path -Parent ([IO.Path]::GetFullPath($modulePath))).Replace("'","''")+"'") +. ([scriptblock]::Create($initializerText)) +function Invoke-ControlledUiaLoader {param($AssemblyPath,$RequiredType) + $script:loaderCalls++ + $script:loaderPath=$AssemblyPath + if($script:loaderError){throw $script:loaderError} + if($script:loaderWrongShape){return 'INVALID'} + if($RequiredType-cne'GraphCodeUiaGateState'){throw 'Wrong required loader type.'} + return [string].Assembly +} +function Reset-Loader { + $script:loaderCalls=0;$script:loaderError=$null;$script:loaderWrongShape=$false +} +Check 'client uses existing guarded loader once and records successful identity' { + Reset-Loader + Initialize-CapUiaClient -AssemblyPath 'C:\controlled\current.dll' + Assert ($script:loaderCalls-eq1-and$script:trace[0].command-ceq'CaptureUiaClientInitialized') +} +Check 'client missing assembly refuses before loader or success trace' { + Reset-Loader + $failure=$null + try{Initialize-CapUiaClient -AssemblyPath ''}catch{$failure=$_} + Assert ($null-ne$failure-and$script:loaderCalls-eq0-and$script:trace.Count-eq0) +} +Check 'client provenance or registration failure propagates primary without defaults' { + Reset-Loader + $script:loaderError=[InvalidOperationException]::new('CONTROLLED_PROVENANCE_OR_REGISTRATION_FAILURE') + $failure=$null + try{Initialize-CapUiaClient -AssemblyPath 'C:\controlled\current.dll'}catch{$failure=$_.Exception.GetBaseException()} + Assert ([object]::ReferenceEquals($failure,$script:loaderError)-and$script:trace.Count-eq0) +} +Check 'client wrong loader output refuses before success trace' { + Reset-Loader;$script:loaderWrongShape=$true;$failure=$null + try{Initialize-CapUiaClient -AssemblyPath 'C:\controlled\current.dll'}catch{$failure=$_} + Assert ($null-ne$failure-and$script:trace.Count-eq0) +} +Check 'client startup precedes UIA use on both live entry paths and keeps managed register-once guard' { + $entry=[IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\Invoke-MatchedActionSupplement.ps1')) + $worker=[IO.File]::ReadAllText($path) + $managed=[IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\ValidationRunner\Ui\Production\GraphCodeUiaGateState.cs')) + Assert ($entry.Contains('if (-not $DryRun) {Initialize-CapUiaClient}')-and + $entry.IndexOf('Initialize-CapUiaClient')-lt$entry.IndexOf('$boundWindows=@(Assert-CapActionWindowContext)')-and + $worker.Contains('if (-not $rehearsal) {Initialize-CapUiaClient}')-and + $managed.Contains('if (RegisteredClientSideProvider == assemblyName.FullName) return;')-and + $managed.IndexOf('ClientSettings.RegisterClientSideProviderAssembly(assemblyName)')-lt + $managed.IndexOf('RegisteredClientSideProvider = assemblyName.FullName;')-and + -not($initialize.Extent.Text-match'Add-Type|RegisterClientSideProviderAssembly\(')) +} +Check 'generic caller preserves explicit assembly across scrub and default selects canonical without build' { + $present=[Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') + $saved=$env:GRAPHCODE_VALIDATION_ASSEMBLY + try{ + if([Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY')){ + Remove-Item -LiteralPath Env:\GRAPHCODE_VALIDATION_ASSEMBLY + } + Reset-Loader + Initialize-CapUiaClient + Assert ($script:loaderCalls-eq1-and$script:loaderPath.EndsWith('ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.dll')) + $env:GRAPHCODE_VALIDATION_ASSEMBLY='C:\explicit\current.dll' + Reset-Loader + Initialize-CapUiaClient + Assert ($script:loaderPath-ceq'C:\explicit\current.dll') + $supervisor=[IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\Invoke-UiCapture.ps1')) + Assert ($supervisor.Contains("`$workerEnvironment['GRAPHCODE_VALIDATION_ASSEMBLY']=`$env:GRAPHCODE_VALIDATION_ASSEMBLY")-and + $supervisor.Contains('-HoldStdin -Environment $workerEnvironment')-and + -not($initialize.Extent.Text-match'Build-CompiledValidationRunner|Copy-Item')) + }finally{ + if($present){$env:GRAPHCODE_VALIDATION_ASSEMBLY=$saved} + elseif([Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY')){ + Remove-Item -LiteralPath Env:\GRAPHCODE_VALIDATION_ASSEMBLY + } + } +} +$passed=@($results|Where-Object passed -EQ $true).Count +@{suite='WireCreateObservation';executed=$results.Count;passed=$passed;failed=$results.Count-$passed;skipped=0;nativeCalls=0; + evidence='Actual extracted guard with controlled property getters; not runtime UIA evidence';cases=@($results)}| + ConvertTo-Json -Depth 6|Set-Content (Join-Path $ScratchRoot 'report.json') -Encoding utf8NoBOM +@{suite='WireCreateObservation';executed=$results.Count;passed=$passed;failed=$results.Count-$passed;skipped=0;nativeCalls=0}|ConvertTo-Json -Compress +if($results.Count-ne15-or$passed-ne15){exit 1} diff --git a/Tools/windows/Tests/WireTargeting.Tests.ps1 b/Tools/windows/Tests/WireTargeting.Tests.ps1 index 6071fc12..d1974d4f 100644 --- a/Tools/windows/Tests/WireTargeting.Tests.ps1 +++ b/Tools/windows/Tests/WireTargeting.Tests.ps1 @@ -1,9 +1,11 @@ param( [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('wt' + [guid]::NewGuid().ToString('N').Substring(0,8))), - [switch]$LegacyBehavior + [switch]$LegacyBehavior, + [switch]$DispatchOnly ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' +if ($LegacyBehavior -and $DispatchOnly) {throw 'LegacyBehavior and DispatchOnly are mutually exclusive.'} $directory=Split-Path -Parent $PSScriptRoot $repository=Split-Path -Parent (Split-Path -Parent $directory) Import-Module (Join-Path $directory 'UiCaptureDriver.psm1') -Force -WarningAction SilentlyContinue @@ -30,6 +32,185 @@ function Reject-WireCase { } } +if (-not $LegacyBehavior) { + Test-WireCase 'actual worker dispatch presses source then holds moves and releases target' { + Add-Type -TypeDefinition @' +using System; +public static class WireDispatchRecorder { + public static object[] PostPointerClickScreen(IntPtr window,int x,int y) { + return new object[] { "click",window.ToInt64(),x,y }; + } + public static object[] PostPointerDragScreen(IntPtr window,int sx,int sy,int tx,int ty) { + return new object[] { "drag",window.ToInt64(),sx,sy,tx,ty }; + } +} +'@ + $tokens=$null;$parseErrors=$null + $worker=[Management.Automation.Language.Parser]::ParseFile( + (Join-Path $directory 'Invoke-UiCaptureWorker.ps1'),[ref]$tokens,[ref]$parseErrors) + Assert-WireCase ($parseErrors.Count -eq 0) 'Worker source did not parse.' + $functions=@($worker.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-CapWireCompletionSupplement' + },$false)) + Assert-WireCase ($functions.Count -eq 1) 'Expected the actual Wire supplement definition.' + $assignments=@($functions[0].Body.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -cin @('$sourceX','$sourceY','$targetX','$targetY','$dispatch') + },$false)) + $calls=@($assignments|Where-Object {$_.Left.Extent.Text -ceq '$dispatch'}) + Assert-WireCase ($calls.Count -eq 1 -and $calls[0].Right.Extent.Text.StartsWith('[CapNative]::')) ` + 'Expected one actual CapNative worker dispatch boundary.' + $app=[pscustomobject]@{Window=[IntPtr]123} + $reacquired=[pscustomobject]@{sourceScreenPoint=@(250,48);screenPoint=@(393,15)} + $dispatch=$null + foreach ($assignment in $assignments) { + $statement=$assignment.Extent.Text + if ($assignment.Left.Extent.Text -ceq '$dispatch') { + $statement=$statement.Replace('[CapNative]::','[WireDispatchRecorder]::') + } + . ([scriptblock]::Create($statement)) + } + Assert-WireCase ($dispatch[1] -eq 123) 'Worker dispatched to a different root HWND.' + $script:dragMessages=[Collections.Generic.List[string]]::new() + $poster=[Func[uint32,uint32,long,bool]]{ + param($message,$keys,$position) + $script:dragMessages.Add("$message/$keys/$position") + return $true + } + if ($dispatch[0] -ceq 'click') { + $receipt=[CapPointerReceipt]::new() + $receipt.PackedLParam=($dispatch[3] -shl 16) -bor $dispatch[2] + [CapNative]::DispatchPointerMessages($receipt,$poster) + } elseif ($dispatch[0] -ceq 'drag') { + $receipt=New-Object -TypeName CapPointerDragReceipt + $receipt.Start=[CapPointerReceipt]::new();$receipt.End=[CapPointerReceipt]::new() + $receipt.Start.PackedLParam=($dispatch[3] -shl 16) -bor $dispatch[2] + $receipt.End.PackedLParam=($dispatch[5] -shl 16) -bor $dispatch[4] + [CapNative]::DispatchPointerDragMessages($receipt,$poster) + } else {throw "Unexpected worker dispatch: $($dispatch[0])"} + $actual=$script:dragMessages -join ',' + Assert-WireCase ($actual -ceq '512/0/3145978,513/1/3145978,512/1/983433,514/0/983433') ` + "Worker gesture must press source, hold-move, release target; actual=$actual" + } + Test-WireCase 'actual worker reads exact locked endpoints before Create and refuses invalid fields' { + $tokens=$null;$parseErrors=$null + $worker=[Management.Automation.Language.Parser]::ParseFile( + (Join-Path $directory 'Invoke-UiCaptureWorker.ps1'),[ref]$tokens,[ref]$parseErrors) + Assert-WireCase ($parseErrors.Count -eq 0) 'Worker source did not parse.' + $function=$worker.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-CapWireCompletionSupplement' + },$false) + $body=@($function.Body.EndBlock.Statements|Where-Object {$_ -is [Management.Automation.Language.TryStatementAst]})[0].Body + $statements=@($body.Statements) + $createIndexes=@(for ($i=0;$i -lt $statements.Count;$i++) { + if ($statements[$i].Extent.Text -ceq + '$creates[0].GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke()') {$i} + }) + Assert-WireCase ($createIndexes.Count -eq 1) 'Expected the actual worker Create boundary.' + $createIndex=$createIndexes[0] + $readerIndexes=@(for ($i=0;$i -lt $createIndex;$i++) { + if ($statements[$i].Extent.Text -match '^\$endpoints\s*=\s*Read-UiaReadonlyEdgeEndpoints\b') {$i} + }) + Assert-WireCase ($readerIndexes.Count -le 1) 'Duplicate endpoint observations before Create.' + $first=if ($readerIndexes.Count -eq 1) {$readerIndexes[0]} else {$createIndex} + $transaction=[scriptblock]::Create(($statements[$first..$createIndex].Extent.Text -join "`n")) + $sourceId='60000000-0000-4000-8000-000000000001' + $targetId='60000000-0000-4000-8000-000000000002' + $app=[pscustomobject]@{Window=[IntPtr]123;Run=[pscustomobject]@{Identity=[pscustomobject]@{Pid=4242}}} + $formElement=[pscustomobject]@{Identity='owned-form'} + $invoker=[pscustomobject]@{} + $invoker|Add-Member ScriptMethod Invoke { + $script:wireEndpointState.calls.Add('create') + $script:wireEndpointState.creates++ + } + $create=[pscustomobject]@{Invoker=$invoker} + $create|Add-Member ScriptMethod GetCurrentPattern {param($pattern) return $this.Invoker} + $creates=@($create) + function Read-UiaReadonlyEdgeEndpoints { + param($Dialog,$ProcessId,$Owner,$Title,$ExpectedSource,$ExpectedTarget,$AssemblyPath) + $script:wireEndpointState.calls.Add('read') + Assert-WireCase ($Dialog -eq $formElement -and $ProcessId -eq 4242 -and $Owner -eq [IntPtr]123 -and + $ExpectedSource -ceq $sourceId -and $ExpectedTarget -ceq $targetId) 'Worker endpoint binding differs.' + if ($script:wireEndpointState.scenario -cin @('missing','writable','mutable')) { + throw "Endpoint refusal: $($script:wireEndpointState.scenario)" + } + return @{source=$(if ($script:wireEndpointState.scenario -ceq 'wrong-source') {$targetId} else {$sourceId}) + target=$(if ($script:wireEndpointState.scenario -ceq 'wrong-target') {$sourceId} else {$targetId}) + snapshot=@{fields=@(@{controlId=9100;readonly=$true},@{controlId=9101;readonly=$true})}} + } + foreach ($scenario in @('valid','wrong-source','wrong-target','missing','writable','mutable')) { + $script:wireEndpointState=@{scenario=$scenario;creates=0;calls=[Collections.Generic.List[string]]::new()} + $endpoints=$null;$failure=$null + try {. $transaction} catch {$failure=$_} + if ($scenario -ceq 'valid') { + Assert-WireCase ($null -eq $failure -and $script:wireEndpointState.creates -eq 1 -and + ($script:wireEndpointState.calls -join ',') -ceq 'read,create' -and + $null -ne $endpoints.snapshot) ` + "Actual worker must read exact locked endpoints before Create; actual=$($script:wireEndpointState.calls -join ','); failure=$failure" + } else { + Assert-WireCase ($null -ne $failure -and $script:wireEndpointState.creates -eq 0 -and + ($script:wireEndpointState.calls -join ',') -ceq 'read') ` + "Actual worker invoked Create after $scenario endpoint refusal." + } + } + } + Test-WireCase 'shared readonly endpoint decision preserves ordinary defaults and exact Wire identities' { + $reader=(Get-Command Read-UiaReadonlyEdgeEndpoints -CommandType Function).ScriptBlock.Ast + $defaults=@{} + foreach ($parameter in $reader.Body.ParamBlock.Parameters) { + if ($parameter.Name.VariablePath.UserPath -cin @('ExpectedSource','ExpectedTarget')) { + $defaults[$parameter.Name.VariablePath.UserPath]=$parameter.DefaultValue.SafeGetValue() + } + } + Assert-WireCase ($defaults.ExpectedSource -ceq '11111111-1111-4111-8111-111111111111' -and + $defaults.ExpectedTarget -ceq '22222222-2222-4222-8222-222222222222') 'Ordinary UIA fixture defaults changed.' + foreach ($ids in @( + @($defaults.ExpectedSource,$defaults.ExpectedTarget), + @('60000000-0000-4000-8000-000000000001','60000000-0000-4000-8000-000000000002'))) { + $sample=@{ + handle=10;processId=123;owner=5;class='GraphCodeSheetHost';title='Create or edit edge';captionless=$true + viewport=@{handle=11;parent=10;processId=123;class='GraphCodeSheetViewport';matches=1;stable=$true;runtime='11';owned=$true} + content=@{handle=12;parent=11;processId=123;class='GraphCodeNativeForm';matches=1;stable=$true;runtime='12';owned=$true} + fields=@( + @{handle=21;parent=12;processId=123;controlId=9100;class='Edit';matches=1;stable=$true;runtime='21';owned=$true + readonly=$true;nativeReadonly=$true;value=$ids[0];semanticValue=$ids[0];semanticProcessId=123;name='Source loop identity'}, + @{handle=22;parent=12;processId=123;controlId=9101;class='Edit';matches=1;stable=$true;runtime='22';owned=$true + readonly=$true;nativeReadonly=$true;value=$ids[1];semanticValue=$ids[1];semanticProcessId=123;name='Target loop identity'}) + } + Assert-WireCase (Get-UiaReadonlyEndpointsDecision $sample 123 5 'Create or edit edge' $ids[0] $ids[1]).ready ` + 'Shared reader rejected matching native and semantic endpoint values.' + foreach ($fault in @('missing','duplicate','wrong-id','wrong-pid','wrong-parent','wrong-sheet-owner', + 'writable','native-writable','missing-native-readonly','wrong-value','wrong-target','semantic-mismatch','stale','wrong-class')) { + $changed=$sample|ConvertTo-Json -Depth 10|ConvertFrom-Json -AsHashtable + switch ($fault) { + 'missing' {$changed.fields=@($changed.fields[1])} + 'duplicate' {$changed.fields[0].matches=2} + 'wrong-id' {$changed.fields[0].controlId=9101} + 'wrong-pid' {$changed.fields[0].processId=456} + 'wrong-parent' {$changed.fields[0].parent=11} + 'wrong-sheet-owner' {$changed.owner=6} + 'writable' {$changed.fields[0].readonly=$false} + 'native-writable' {$changed.fields[0].nativeReadonly=$false} + 'missing-native-readonly' {$changed.fields[0].Remove('nativeReadonly')} + 'wrong-value' {$changed.fields[0].value=$ids[1];$changed.fields[0].semanticValue=$ids[1]} + 'wrong-target' {$changed.fields[1].value=$ids[0];$changed.fields[1].semanticValue=$ids[0]} + 'semantic-mismatch' {$changed.fields[0].semanticValue=$ids[1]} + 'stale' {$changed.fields[0].stable=$false} + 'wrong-class' {$changed.fields[0].class='Static'} + } + Assert-WireCase (-not (Get-UiaReadonlyEndpointsDecision $changed 123 5 'Create or edit edge' $ids[0] $ids[1]).ready) ` + "Shared reader accepted $fault endpoints." + } + } + } +} + +if (-not $DispatchOnly) { # Harmless native hierarchy with the same parent/owner distinction as the sheet. Add-Type -TypeDefinition @' using System; @@ -49,6 +230,10 @@ public static class WireFixture { if(RegisterClassW(ref cls)==0) throw new Exception("Fixture class registration failed."); cls.Name="WireTargetFixtureContent"; if(RegisterClassW(ref cls)==0) throw new Exception("Fixture child class registration failed."); + foreach(var name in new[] { "GraphCodeSheetHost","GraphCodeSheetViewport","GraphCodeNativeForm" }) { + cls.Name=name; + if(RegisterClassW(ref cls)==0) throw new Exception("Endpoint fixture class registration failed."); + } } [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateWindowExW(uint ex, string cls, string name, uint style, @@ -64,6 +249,34 @@ public static class WireFixture { [DllImport("user32.dll")] static extern IntPtr WindowFromPoint(Point point); [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window,out uint processId); [DllImport("user32.dll")] static extern bool PeekMessageW(out Message message,IntPtr window,uint first,uint last,uint remove); + [DllImport("user32.dll")] static extern IntPtr SendMessageW(IntPtr window,uint message,IntPtr wp,IntPtr lp); + public static void SetReadonly(IntPtr window,bool value) { + if(SendMessageW(window,0x00CF,new IntPtr(value ? 1 : 0),IntPtr.Zero)==IntPtr.Zero) + throw new Exception("Endpoint fixture readonly update failed."); + } + public static IntPtr[] CreateEndpoints(IntPtr owner,string source,string target) { + IntPtr host=CreateWindowExW(0x08000008,"GraphCodeSheetHost","Create or edit edge",0x90000000, + 20,20,350,180,owner,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); + if(host==IntPtr.Zero) throw new Exception("Endpoint fixture host creation failed."); + try { + IntPtr viewport=CreateWindowExW(0,"GraphCodeSheetViewport","",0x50000000, + 0,0,350,180,host,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); + if(viewport==IntPtr.Zero) throw new Exception("Endpoint fixture viewport creation failed."); + IntPtr content=CreateWindowExW(0,"GraphCodeNativeForm","",0x50000000, + 0,0,350,180,viewport,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); + if(content==IntPtr.Zero) throw new Exception("Endpoint fixture content creation failed."); + IntPtr from=CreateWindowExW(0,"EDIT",source,0x50000800, + 5,5,320,25,content,new IntPtr(9100),IntPtr.Zero,IntPtr.Zero); + if(from==IntPtr.Zero) throw new Exception("Endpoint fixture source creation failed."); + IntPtr to=CreateWindowExW(0,"EDIT",target,0x50000800, + 5,40,320,25,content,new IntPtr(9101),IntPtr.Zero,IntPtr.Zero); + if(to==IntPtr.Zero) throw new Exception("Endpoint fixture target creation failed."); + return new[] { host,viewport,content,from,to }; + } catch(Exception primary) { + if(!DestroyWindow(host)) throw new AggregateException(primary,new Exception("Endpoint fixture failed-launch cleanup failed.")); + throw; + } + } public static IntPtr AtPoint(int x,int y) { return WindowFromPoint(new Point { X=x,Y=y }); } public static bool HasPointerMessage(IntPtr window) { Message message; return PeekMessageW(out message,window,0x0200,0x0202,0); @@ -88,7 +301,7 @@ try { $content=[WireFixture]::Create($hostWindow,$true,'wire fixture content') if ($LegacyBehavior) { Test-WireCase 'owned child form is observable, not just a top-level class' { - $actual=[GraphCodeUiaGateState]::FindTopLevel('WireTargetFixtureContent',[uint32]$PID) + $actual=[CapUiaGateState]::FindTopLevel('WireTargetFixtureContent',[uint32]$PID) Assert-WireCase ($actual -eq $content) 'Top-level-only oracle missed the owned child content.' } Test-WireCase 'target point excludes the inline Mark action' { @@ -103,7 +316,7 @@ try { Assert-WireCase (($binding.safeLogicalBounds -join ',') -ceq '11,0,176,31' -and $binding.sourceNodeId -cne $binding.targetNodeId) 'Invalid source-derived safe region.' } - foreach ($field in @('sourceNodeId','targetNodeId','sourceCardId','targetCardId','scene','phase','projectPath','wireId','sourceHashes')) { + foreach ($field in @('sourceNodeId','targetNodeId','sourceCardId','targetCardId','scene','phase','projectPath','wireId','sourceHashes','gesture','sourceConnector')) { $changed=$binding|ConvertTo-Json -Depth 20|ConvertFrom-Json $changed.$field='foreign' Reject-WireCase "reject planned $field drift" { @@ -130,7 +343,9 @@ try { Test-WireCase "production safe target excludes inline Mark at DPI$dpi" { $observation=Get-CapWireTargetObservation $binding $rows $PID $dpi Assert-WireCase ($observation.screenPoint[1] -lt 31*$dpi/96 -and - $observation.screenPoint[0] -gt 300*$dpi/96) 'Target point hits inline action.' + $observation.screenPoint[0] -gt 300*$dpi/96 -and + ($observation.sourceScreenPoint -join ',') -ceq "$([int](250*$dpi/96)),$([int](48*$dpi/96))") ` + 'Target point hits inline action or source point misses connector.' } } Test-WireCase 'production algorithm preflight is explicitly model-only with no launch' { @@ -142,7 +357,7 @@ try { Test-WireCase 'immutable reacquisition preserves exact target and root identity' { Assert-CapWireTargetStable $first $first @(1,2) @(1,2) } - foreach ($fault in @('source','target','runtime','bounds','mode','root')) { + foreach ($fault in @('source','target','runtime','bounds','mode','root','source-point')) { $second=$first|ConvertTo-Json -Depth 20|ConvertFrom-Json $rootAfter=@(1,2) switch ($fault) { @@ -152,12 +367,13 @@ try { 'bounds' {$second.target.Bounds[0]++} 'mode' {$second.mode='not armed'} 'root' {$rootAfter=@(1,3)} + 'source-point' {$second.sourceScreenPoint[0]++} } Reject-WireCase "reacquisition rejects $fault drift" { Assert-CapWireTargetStable $first $second @(1,2) $rootAfter } 'drifted before dispatch' } - foreach ($fault in @('source','target','mode','foreign-process','offscreen','disabled','bounds','NaN','duplicate','overlap')) { + foreach ($fault in @('source','target','mode','foreign-process','offscreen','disabled','bounds','NaN','duplicate','overlap','source-overlap')) { $rows=@($baseRows|ConvertTo-Json -Depth 20|ConvertFrom-Json) switch ($fault) { 'source' {$rows[0].AutomationId='foreign'} @@ -170,11 +386,13 @@ try { 'NaN' {$rows[1].Bounds[0]=[double]::NaN} 'duplicate' {$rows+= $rows[1]} 'overlap' {$rows[3].Bounds=@(300,0,550,31)} + 'source-overlap' {$rows[3].Bounds=@(240,40,260,60)} } $message=switch ($fault) { 'foreign-process' {'does not match owned PID'} {$_ -in @('offscreen','disabled','bounds','NaN')} {'bounds/scale'} 'overlap' {'Unsafe wire target point'} + 'source-overlap' {'Unsafe wire source connector'} default {'identity or active Wire mode'} } Reject-WireCase "reject observed $fault" { @@ -264,6 +482,14 @@ try { 'Unrelated same-process HWND was not refused by the actual recipient guard.' Assert-WireCase (-not [WireFixture]::HasPointerMessage($hostWindow) -and -not [WireFixture]::HasPointerMessage($overlay)) 'Recipient refusal posted a pointer message.' + $failure=$null + try {[CapNative]::PostPointerDragScreen($hostWindow,($b[0]+10),($b[1]+10),($b[0]+20),($b[1]+20))|Out-Null} + catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and + $failure.Exception.Message.Contains('Pointer recipient differs from intended owned HWND')) ` + 'Drag accepted an unrelated same-process source recipient.' + Assert-WireCase (-not [WireFixture]::HasPointerMessage($hostWindow) -and + -not [WireFixture]::HasPointerMessage($overlay)) 'Drag source refusal posted a pointer message.' } finally { try { if ($overlay -ne [IntPtr]::Zero -and -not [WireFixture]::DestroyWindow($overlay)) { @@ -272,6 +498,31 @@ try { } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($previous)} } } + Test-WireCase 'drag refuses a foreign end recipient before any dispatch and restores PMv2' { + $b=@($nativeRows|Where-Object Handle -EQ $hostWindow.ToInt64())[0].Bounds + $previous=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-4)) + Assert-WireCase ($previous -ne [IntPtr]::Zero) 'Drag recipient preflight PMv2 setup failed.' + try { + Assert-WireCase ([WireFixture]::AtPoint(($b[0]+10),($b[1]+10)) -eq $hostWindow -and + [WireFixture]::OwnerProcess($hostWindow) -eq $PID) 'Owned drag source recipient preflight refused.' + Assert-WireCase (-not [WireFixture]::HasPointerMessage($hostWindow)) 'Pointer message queued before end refusal.' + $before=[WireFixture]::GetThreadDpiAwarenessContext() + $failure=$null + try {[CapNative]::PostPointerDragScreen($hostWindow,($b[0]+10),($b[1]+10),-30000,-30000)|Out-Null} + catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and $failure.Exception.Message.Contains('recipient differs')) ` + 'Foreign drag endpoint was accepted.' + $cause=$failure.Exception + while ($cause.InnerException) {$cause=$cause.InnerException} + $partial=$cause.Data['PointerDragReceipt'] + Assert-WireCase ($null -ne $partial -and $null -ne $partial.Start -and + -not $partial.Start.MovePosted -and -not $partial.Start.DownPosted -and + -not $partial.HeldMovePosted -and $null -eq $partial.End -and + -not [WireFixture]::HasPointerMessage($hostWindow) -and + [WireFixture]::GetThreadDpiAwarenessContext() -eq $before) ` + 'End recipient refusal posted a partial gesture, lost its receipt, or leaked DPI awareness.' + } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($previous)} + } Test-WireCase 'native PMv2 conversion dispatch persists coordinates and restores caller awareness' { $b=@($nativeRows|Where-Object Handle -EQ $hostWindow.ToInt64())[0].Bounds $preflight=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-4)) @@ -293,6 +544,60 @@ try { Reject-WireCase 'invalid native HWND is not a successful click' { [CapNative]::PostPointerClickScreen([IntPtr]::Zero,0,0) } 'recipient differs' + Test-WireCase 'native drag validates both endpoints under restored PMv2 awareness' { + $b=@($nativeRows|Where-Object Handle -EQ $hostWindow.ToInt64())[0].Bounds + $preflight=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-4)) + Assert-WireCase ($preflight -ne [IntPtr]::Zero) 'Owned drag recipient PMv2 preflight failed.' + try { + # The child content starts at client20,20; both recipients must be the host. + foreach ($offset in @(10,15)) { + $recipient=[WireFixture]::AtPoint(($b[0]+$offset),($b[1]+$offset)) + $recipientPid=[WireFixture]::OwnerProcess($recipient) + Assert-WireCase ($recipient -eq $hostWindow -and $recipientPid -eq $PID) ` + "Owned drag recipient preflight refused: offset=$offset; intended=$hostWindow; recipient=$recipient; recipientPID=$recipientPid; expectedPID=$PID." + } + } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($preflight)} + $previous=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-1)) + try { + $before=[WireFixture]::GetThreadDpiAwarenessContext() + $receipt=[CapNative]::PostPointerDragScreen($hostWindow,($b[0]+10),($b[1]+10),($b[0]+15),($b[1]+15)) + Assert-WireCase ($receipt.Start.ClientX -eq 10 -and $receipt.Start.ClientY -eq 10 -and + $receipt.End.ClientX -eq 15 -and $receipt.End.ClientY -eq 15 -and + $receipt.Start.MovePosted -and $receipt.Start.DownPosted -and + $receipt.HeldMovePosted -and $receipt.End.UpPosted -and + $receipt.Start.Recipient -eq $hostWindow.ToInt64() -and + $receipt.End.Recipient -eq $hostWindow.ToInt64() -and + [WireFixture]::GetThreadDpiAwarenessContext() -eq $before) ` + "Drag receipt/DPI restore failed: $($receipt|ConvertTo-Json -Depth 10 -Compress); before=$before; after=$([WireFixture]::GetThreadDpiAwarenessContext())" + } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($previous)} + } + foreach ($failureIndex in 0..3) { + $script:dragMessages=[Collections.Generic.List[string]]::new() + $receipt=[CapPointerDragReceipt]::new() + $receipt.Start=[CapPointerReceipt]::new();$receipt.End=[CapPointerReceipt]::new() + $poster=[Func[uint32,uint32,long,bool]]{ + param($message,$keys,$position) + $script:dragMessages.Add("$message/$keys/$position") + return $script:dragMessages.Count -ne ($failureIndex+1) + } + Test-WireCase "drag dispatch reports failure at step $failureIndex" { + $failure=$null + try {[CapNative]::DispatchPointerDragMessages($receipt,$poster)} + catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and + $failure.Exception.Message.Contains('Native pointer drag dispatch failed')) 'Expected explicit drag refusal.' + $cause=$failure.Exception + while ($cause.InnerException) {$cause=$cause.InnerException} + Assert-WireCase ([object]::ReferenceEquals($cause.Data['PointerDragReceipt'],$receipt) -and + $receipt.Start.MovePosted -eq ($failureIndex -gt 0) -and + $receipt.Start.DownPosted -eq ($failureIndex -gt 1) -and + $receipt.HeldMovePosted -eq ($failureIndex -gt 2) -and -not $receipt.End.UpPosted) ` + 'Dispatch failure lost or misstated its partial receipt.' + } + Test-WireCase "drag dispatch stops without retry or synthetic UP after step $failureIndex" { + Assert-WireCase ($script:dragMessages.Count -eq ($failureIndex+1)) 'Drag continued after refusal.' + } + } Reject-WireCase 'native conversion error is explicit, never a successful coordinate fallback' { [CapNative]::ConvertPointerScreen([IntPtr]123,0,0) } 'ScreenToClient failed' @@ -314,6 +619,47 @@ try { $script:messages.Count -eq ($failedMessage-0x0200+1)) 'Dispatch continued after rejection.' } } + Test-WireCase 'shared native reader validates owned readonly exact endpoints and refuses changed fields' { + $handles=[WireFixture]::CreateEndpoints($root, + '60000000-0000-4000-8000-000000000001','60000000-0000-4000-8000-000000000002') + try { + $dialog=[Windows.Automation.AutomationElement]::FromHandle($handles[0]) + $proof=Read-UiaReadonlyEdgeEndpoints -Dialog $dialog -ProcessId $PID -Owner $root ` + -Title 'Create or edit edge' -ExpectedSource '60000000-0000-4000-8000-000000000001' ` + -ExpectedTarget '60000000-0000-4000-8000-000000000002' + Assert-WireCase ($proof.source -ceq '60000000-0000-4000-8000-000000000001' -and + $proof.target -ceq '60000000-0000-4000-8000-000000000002' -and + $proof.snapshot.fields[0].nativeReadonly -and $proof.snapshot.fields[0].readonly -and + $proof.snapshot.fields[1].nativeReadonly -and $proof.snapshot.fields[1].readonly) ` + 'Native fixture lost exact locked endpoint evidence.' + [WireFixture]::SetReadonly($handles[3],$false) + $failure=$null + try { + Read-UiaReadonlyEdgeEndpoints -Dialog $dialog -ProcessId $PID -Owner $root ` + -Title 'Create or edit edge' -ExpectedSource $proof.source -ExpectedTarget $proof.target|Out-Null + } catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and $failure.Exception.Message.Contains('Readonly native/semantic endpoint')) ` + 'Shared native reader accepted a mutable endpoint.' + [WireFixture]::SetReadonly($handles[3],$true) + $failure=$null + try { + Read-UiaReadonlyEdgeEndpoints -Dialog $dialog -ProcessId $PID -Owner $root ` + -Title 'Create or edit edge' -ExpectedSource $proof.target -ExpectedTarget $proof.source|Out-Null + } catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and $failure.Exception.Message.Contains('Readonly native/semantic endpoint')) ` + 'Shared native reader accepted swapped endpoint identities.' + if (-not [WireFixture]::DestroyWindow($handles[4])) {throw 'Endpoint fixture field cleanup failed.'} + $failure=$null + try { + Read-UiaReadonlyEdgeEndpoints -Dialog $dialog -ProcessId $PID -Owner $root ` + -Title 'Create or edit edge' -ExpectedSource $proof.source -ExpectedTarget $proof.target|Out-Null + } catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and $failure.Exception.Message.Contains('Exact owned readonly endpoint child missing')) ` + 'Shared native reader accepted an absent endpoint.' + } finally { + if (-not [WireFixture]::DestroyWindow($handles[0])) {throw 'Endpoint fixture host cleanup failed.'} + } + } Test-WireCase 'worker preserves original primary when stub cleanup also refuses' { & { . (Join-Path $directory 'Invoke-UiCaptureWorker.ps1') -OwnedRoot $ScratchRoot -DefinitionsOnly @@ -341,6 +687,7 @@ try { if (Get-Variable self -ErrorAction SilentlyContinue) {$self.Dispose()} } } +} $failed=@($results|Where-Object passed -EQ $false).Count $report=[ordered]@{suite='WireTargeting';executed=$results.Count;passed=($results.Count-$failed) failed=$failed;skipped=0;productLaunches=0;providerLaunches=0;cases=@($results)} @@ -350,4 +697,4 @@ $report|ConvertTo-Json -Depth 30 | Set-Content -LiteralPath (Join-Path $ScratchR [pscustomobject]$report|Select-Object suite,executed,passed,failed,skipped|ConvertTo-Json -Compress foreach ($result in @($results|Where-Object passed -EQ $false)) {$result|ConvertTo-Json -Compress|Write-Host} if ($failed -gt 0) {throw "$failed behavioral wire targeting contracts failed."} -if ($results.Count -ne $(if ($LegacyBehavior) {2} else {57})) {throw 'Wire targeting executed count differs.'} +if ($results.Count -ne $(if ($DispatchOnly) {3} elseif ($LegacyBehavior) {2} else {75})) {throw 'Wire targeting executed count differs.'} diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index c9740a64..47177768 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -107,10 +107,10 @@ A failed precondition is a bounded tooling/setup blocker, never permission to retry a product action. No prior W1 setup exception is reconstructed by this dry run. -`Tests\ActionWorkerImport.Tests.ps1` adds **18 separately counted contracts** +`Tests\ActionWorkerImport.Tests.ps1` has **20 separately counted contracts** (including two actual production-entry dry runs and null/reset/path/hash/run/ authority/deadline/replacement negatives). `ValidationRunner.Tests.ps1` requires -all 18 with zero skips, in addition to the unchanged **182 default / 185 +all 20 with zero skips, in addition to the unchanged **182 default / 185 real-zmx** UiCaptureDriver baseline. The new cases are not omitted or used to replace baseline cases. @@ -120,22 +120,69 @@ bindings, source/hash/run/authority/deadline/replacement failures, actual production startup refusal before launch, and initial/placed identity guard drift with modeled metrics. Its accepted startup intercepts the launcher without starting a process; it is not native-window evidence. ValidationRunner requires -all 57 with zero skips, alongside the 18 import and unchanged 182/185 baseline. +all 85 with zero skips, alongside the 20 import and unchanged 182/185 baseline. The W1 targeting repair expands that window-binding suite to **62** contracts: five additional startup refusals require an explicit nonnull, source/hash-bound -source/target plan before either the stub or app can launch. +source/target plan before either the stub or app can launch. The functional-only +Wire route adds 23 further contracts, for **85** current identity contracts. `Tests\ActionTopologyImport.Tests.ps1` adds **16 separately counted contracts** for production import, lexical top-level scope, exact topology copies, layout/ session hash refusals, missing layouts, prepare-only manifests and invocation, default launcher reachability (intercepted without a process), invalid session -inventories, and cleanup/idempotence with modeled launcher output. The 18 import +inventories, and cleanup/idempotence with modeled launcher output. The import contracts also exercise all five qualified topology/seed preparation paths through the unmodified production entry dry run. ValidationRunner requires all 16 with zero skips without replacing the existing suites or baseline cases. ### W1 target selection and native form observation +The explicit `-FunctionalWireOnly` opt-in is available on +`Prepare-FocusedUiCapture.ps1 -FocusedProfile PostFixCards -PlanOnly`, +`Prepare-MatchedActionSupplement.ps1 -Group WireCompletion`, and +`Invoke-MatchedActionSupplement.ps1` (including `-DryRun`). All three must agree; +other action groups and image-manifest arguments are refused in this mode. +Functional preparation also requires `-GraphCodeBuildSource` identifying the +binary's actual build commit. A harness-only new head may reuse that binary +only when its entire tracked `graphcode-windows` tree is identical; preparation +and runtime both check that equivalence and retain the actual build commit, +rather than claiming a new product build. +The copied daemon is not executed by functional W1; its functional job metadata +does not invent a daemon build-source commit. +It uses the same production Wire worker and locked native/semantic endpoint +reader, Create correlation, edge readback, and held-process/job/EOF cleanup. +It does not authorize screenshots or establish visual, glyph, higher-DPI, or +whole-application parity. + +The functional plan binds a fresh owned scene-**state** plan, the exact source/ +target IDs and source files, worker and stub hashes, rather than inventing an +existing image hash. This plan is explicitly not runtime evidence. A successful +actual action separately records the owned process generations, UIA state, +measured placement, locked endpoints, and correlated graph result. +Its `S_FUNCTIONAL_W1_V1` contract derives frame identity from the current +`MainWindow.zig`, measures the owned window's actual DPI and physical work area, +and fits a preferred 1280-by-720 logical client within that measured area. +After placement and on target reacquisition, DPI, source identity, frame and +client bounds, and work area must remain unchanged. A too-small or changed +window still fails normal visible-card/target admission; it is not resampled. +The functional dry run records measurement **pending** until an owned product +window exists, not a fabricated measured DPI. + +The default visual/matched-DPI route retains its existing image-manifest and +DPI192/2880-by-1800-client/2906-by-1910-work-area guards unchanged. Choosing +functional W1 does not lower those visual guards or authorize a capture +campaign. Prepare canonical owned offline backend/peer inputs and a fresh +current-controls 185-case report as usual; fixture compilation, injected DPI +component models, and harmless dry-run custody are not live W1 acceptance. + +Advisory descendant exclusions retain the original native failure/ErrorRecord +in memory for eligibility and refusal checks. Their diagnostic output is a +bounded scalar projection: native/CIM facts, error type, FQID, message and +script-stack text, with explicit graph omissions and per-field truncation +metadata. Logging never recursively serializes the retained ErrorRecord, +InvocationInfo, module, or process graphs. This changes observability only, +not access-denied eligibility, held-generation checks, ownership or cleanup. + The prepared action plan includes `wireTargetBinding` for WireCompletion and FinalBoundedActions. It binds the owned project path, exact source and target UUIDs and stable UIA IDs, source-derived title-band geometry, and hashes of @@ -146,34 +193,160 @@ the exact armed status and source-bound window metrics, and refuses identity, runtime-ID, bounds, scale, or overlap drift. The selected point is inside the title band above the source-defined inline actions, not the card midpoint. +The version-2 binding also requires `SOURCE_CONNECTOR_DRAG_DROP` from the +source card's source-derived right-centre connector. The worker posts a move +and left-button down at that connector, a move with the button held to the +safe target point, then left-button up at the target. It does **not** click +the target card: macOS completes edges in the connector's drag `onEnded`, +while a card tap opens the loop. Windows likewise routes a target-card down +through node activation before its edge-drop branch. The binding is +regenerated by the production preparer; old click-only plans are refused. + The physical screen-to-client conversion and posted mouse-message dispatch enter per-monitor-v2 awareness and restore the caller's context in `finally`. -W1 requires the native recipient to equal the held app's intended root HWND. +W1 requires both native recipients to equal the held app's intended root HWND +before posting any message. Dispatch stops at the first rejection, without +retry or a synthetic release; the primary failure trace retains the partial +drag receipt and ordinary owned-app cleanup remains responsible for teardown. The intent/result trace records source/target binding, safe bounds, screen and client points, packed LPARAM, recipient PID/HWND, foreground HWND, DPI context, -and individual message-post results. These are **posted window messages**, not +and individual message-post results for both endpoints and the held move. +These are **posted window messages**, not physical cursor movement or a W6 hover result. -Native modal observation now enumerates the held process's window hierarchy. +App's three `wire completion` tests use never-shown fixture owners and the +production message handler at injected DPI96/192: target clicks activate nodes; +source connector drags reach the actual owned native form and its exact endpoint +fields, and Create queues the expected command. They also cover ordinary +connector use, modal cancellation, self/empty/cancelled drops and inline Mark +priority. These are native-form/component contracts, not a live W1 campaign, +physical input, daemon edge readback, higher-DPI captures, or macOS visual parity. + +Native modal observation enumerates the held root's window family, not every +window in the process. + +The first modal UIA census and subsequent Button+Create predicate observation +are separately timestamped. Both retain actual control type, native handle and +runtime identity. The filtered guard records its exact count and caches its +existing enabled/offscreen reads for the same decision; disabled candidates +do not cause another offscreen read. Property errors retain the primary exception +and explicitly unavailable evidence, never a false or successful default. + +Current and child properties use explicit managed getters so property exceptions +remain primary. A diagnostic trace failure is attached to that original error, +not substituted for it. Fifteen managed/source observation/client-startup contracts run as a +separate owned CaptureSuite prerequisite before fresh fixtures; they do not +increase the32 hierarchy,182 capture or684 registry counts. + +Live capture clients initialize UIA through the existing +`Import-CompiledValidationAssembly.ps1` path for `GraphCodeUiaGateState` +before observations. The loader verifies current source-bound assemblies, +matching runtime dependencies and the signed Microsoft client-side provider, +then uses the existing idempotent managed registration helper. Import-only, +dry-run and no-desktop rehearsal paths do not register providers. Missing +assembly/provenance/registration failures refuse rather than falling back to +generic roles. Without an explicit selection, the client uses the existing +current-worktree canonical Release assembly path through that same guarded +loader; it does not build or copy an assembly. Explicit selection is forwarded +through the supervisor's scrubbed child environment; explicit empty selections +still refuse. This startup contract is not proof that an earlier Pane role +was caused by missing proxies; native Button/Edit roles still need evidence. + +Held-process image queries use a fixed 32768-character unmanaged Unicode buffer +instead of StringBuilder copyback. The actual BOOL and cached Win32 error are +handled before any output decoding; failure remains a Win32Exception. Successful +length and terminator/content checks refuse invalid output, and owned storage is +freed in finally. This hardens a documented output contract; it does not prove +the historical CI capacity error came from this API. Nine controlled decoder +contracts run as a separate prerequisite without native process queries or +inflating capture/registry counts. + +A pure topology selector requires the exact root and matching PID, follows +transitive top-level ownership, and includes every +descendant of the selected roots, including hidden and disabled controls of +arbitrary classes. A separate same-process family is excluded by its resolved +ownership/parent relationships, never by class or visibility. Unresolved +nonzero owners, cyclic or missing ancestry, conflicting duplicate handles, +missing/ambiguous roots and foreign selected processes refuse. Only selected +windows receive the unchanged 50ms text query within the 2s census deadline. +Immediately before each selected text query, live parent/owner values are +compared with the collected topology. Changed ancestry refuses without retry +or re-resolution; the failure trace retains the observed and expected scalar +relationships. Stable observations supply the success/text-failure rows. +Neither enumeration nor this check is atomic: a window can change after the +check, newly created children may be omitted, and HWND-generation identity is +not established. Existing held-root and endpoint revalidation remain mandatory. +This scope does not diagnose any earlier query timeout. +Normal post-dispatch modal acquisition first reads a distinct structure-only +observation with no Name/caption field, text messages or UIA text traversal. +It uses the same root-family selector, live ancestry checks and existing +owner/class/visible-content resolver. Hidden or not-yet-created required +content remains pending only within the original clipped 5s deadline and +at-most-100ms cadence; malformed ancestry, foreign identity, ambiguity and +native observation errors still refuse. +Once structurally ready, exactly one full post-dispatch observation must resolve +the same host/content before continuing. Failure or identity mismatch does not +re-poll. The deadline is checked before/after that transition without resetting +it. The full census still includes every rooted hidden/disabled member of every +class, with unchanged 50ms/2s guards. Before-arm/pre-Create full revalidation and +endpoint/Create guards remain separate and unchanged. Source creation-before-show +ordering supports this boundary, not a proof of message-pump responsiveness, +HWND generation, or the cause of an earlier timeout. It requires exactly one visible GraphCodeSheetHost owned by the app root and one visible GraphCodeNativeForm descendant through the viewport. The Create button belongs to the sheet footer, so UIA observation/invocation uses that -owned host rather than the child content. The trace retains pre/post hierarchy +owned host rather than the child content. Immediately before Create, the worker +uses the same readonly-endpoint reader as the ordinary UIA edge routes, with +Wire's exact bound source and target UUIDs instead of the ordinary fixture +defaults. The reader requires the owned host/viewport/native-form lineage, +unique direct Edit controls 9100/9101, stable runtime identities, native +`ES_READONLY` and semantic `ValuePattern.IsReadOnly`, and matching native-buffer +and semantic values. A missing, mutable, stale, swapped or foreign endpoint +refuses before Create. The Wire receipt retains the validated endpoint snapshot +alongside the correlated applied-edge request IDs. The trace retains pre/post hierarchy censuses and observed modal controls, including on timeout. Missing hosts or not-yet-created content are polled within the clipped action deadline; ambiguity, foreign identity, replacement, or native query errors refuse. +On a native text-query failure, the error trace also retains the already-read +HWND, class, process/thread IDs, bounds, parent/owner, visibility/enabled state +and cached Win32 error. These existing structural reads precede the text query +and are reused on success. No title/text payload or new native API is added; +parent/owner are re-read for the immediate ancestry validation described above. +The original 50ms query, PID filter, refusal and rethrow +remain unchanged. A zero native error is recorded as zero, not diagnosed as a +specific timeout cause. +The canonical capture suite runs 32 pure-selection/managed/source contracts +for this metadata as an owned prerequisite before fresh fixture preparation. +They are not added to the 182/185 capture-driver case inventory and do not +invoke the native text-query failure path. +For a separately authorized first-census diagnostic, the existing +`Invoke-MatchedActionSupplement.ps1` entry accepts `-WireCensusDiagnostic` +only with `-FunctionalWireOnly` and without `-DryRun`. It performs the normal +owned dispatch, then one post-dispatch window observation and stops before +sheet resolution, endpoint reads or Create. Query failures propagate unchanged; +success is labeled `DIAGNOSTIC_NOT_W1_ACCEPTANCE`, with zero action receipts +and no images. Both outcomes use the existing cleanup path. The default W1 +route is unchanged. Current source/control bindings and fresh capture preflight +remain mandatory; neither this diagnostic nor its clean release accepts W1. The original action exception is retained if cleanup also fails; launcher EOF is never relabeled target-private pipe EOF. -`Tests\WireTargeting.Tests.ps1` adds **56 separately counted contracts**, required +`Tests\WireTargeting.Tests.ps1` defines **75 separately counted contracts**, required by ValidationRunner with zero skips. They exercise the actual shared production target/oracle/stability/dispatch helpers with modeled negative cases and a harmless native owned popup/child fixture. The native fixture checks actual PMv2 conversion, message acceptance and awareness restoration on the current desktop; it is not GraphCode/provider or product-action evidence. Dispatch failure cases inject rejected native-post results into the same dispatch loop. +The default suite also records the actual worker's CapNative dispatch boundary +and runs its selected shared message dispatcher without native input. Run just +that headless regression with +`pwsh -NoProfile -File Tools\windows\Tests\WireTargeting.Tests.ps1 -DispatchOnly`; +its three executed cases are selected headless dispatch/endpoint contracts, not +a default-suite or live W1 pass. The preserved legacy behavioral run demonstrates the top-level-only child miss -and midpoint/Mark collision separately from passing results. +and midpoint/Mark collision separately from passing results; it is not the +target-click connector-gesture regression. The production `-DryRun` also validates `wireTargetBinding` before launching its harmless child and records `wireAlgorithmPreflight`, which exercises the shared diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 01def866..194d4598 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -19,7 +19,7 @@ $helperSource = @' using System; using System.Runtime.InteropServices; using System.Text; -public static class GraphCodeUiaGateState { +public static class CapUiaGateState { [StructLayout(LayoutKind.Sequential)] private struct Point { public int X, Y; } [StructLayout(LayoutKind.Sequential)] @@ -170,6 +170,14 @@ public sealed class CapWindowNode { public int[] Bounds; } +public sealed class CapWindowStructure { + public long Handle, Parent, Owner; + public int ProcessId; + public string ClassName; + public bool Visible, Enabled; + public int[] Bounds; +} + public sealed class CapPointerReceipt { public long Window, Recipient, Foreground, PackedLParam, CursorWindow; public int ProcessId, RecipientProcessId, ScreenX, ScreenY, ClientX, ClientY, CursorX, CursorY; @@ -178,6 +186,13 @@ public sealed class CapPointerReceipt { public string Delivery = "POSTED_WINDOW_MESSAGES_NOT_PHYSICAL_POINTER"; } +public sealed class CapPointerDragReceipt { + public CapPointerReceipt Start, End; + public bool HeldMovePosted; + public string Gesture = "SOURCE_CONNECTOR_DRAG_DROP"; + public string Delivery = "POSTED_WINDOW_MESSAGES_NOT_PHYSICAL_POINTER"; +} + public static class CapNative { private const uint WAIT_OBJECT_0 = 0; private const uint WAIT_TIMEOUT = 0x102; @@ -218,7 +233,7 @@ public static class CapNative { [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetProcessTimes(IntPtr process, out NativeFileTime creation, out NativeFileTime exit, out NativeFileTime kernel, out NativeFileTime user); [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] - private static extern bool QueryFullProcessImageNameW(IntPtr process, uint flags, StringBuilder image, ref uint size); + private static extern bool QueryFullProcessImageNameW(IntPtr process, uint flags, IntPtr image, ref uint size); [DllImport("kernel32.dll", SetLastError=true)] private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); [DllImport("kernel32.dll", SetLastError=true)] @@ -280,54 +295,233 @@ public static class CapNative { } } - public static CapWindowNode[] WindowHierarchy(int expectedPid) { + public static void AssertWindowAncestry(CapWindowNode expected, CapWindowNode observed) { + if (expected == null || observed == null) + throw new InvalidOperationException("Window family: missing selected ancestry."); + if (expected.Handle != observed.Handle || expected.ProcessId != observed.ProcessId || + expected.Parent != observed.Parent || expected.Owner != observed.Owner) { + var error = new InvalidOperationException("Window family: selected ancestry changed."); + error.Data["WindowAncestryFailure"] = new { Handle=observed.Handle, ProcessId=observed.ProcessId, + Parent=observed.Parent, Owner=observed.Owner, ExpectedHandle=expected.Handle, + ExpectedProcessId=expected.ProcessId, ExpectedParent=expected.Parent, ExpectedOwner=expected.Owner }; + throw error; + } + } + + public static CapWindowNode[] SelectWindowFamily(CapWindowNode[] topology, long[] topLevelHandles, + long rootHandle, int expectedPid) { + if (topology == null || topLevelHandles == null || rootHandle == 0 || expectedPid <= 0) + throw new InvalidOperationException("Window family: missing root or topology."); + var nodes = new System.Collections.Generic.Dictionary(); + var order = new System.Collections.Generic.List(); + foreach (var node in topology) { + if (node == null || node.Handle == 0) + throw new InvalidOperationException("Window family: invalid window."); + CapWindowNode prior; + if (nodes.TryGetValue(node.Handle, out prior)) { + if (node.Handle == rootHandle || prior.Parent != node.Parent || prior.Owner != node.Owner || + prior.ProcessId != node.ProcessId) + throw new InvalidOperationException("Window family: ambiguous or conflicting duplicate window."); + } else { nodes.Add(node.Handle, node); order.Add(node); } + } + var tops = new System.Collections.Generic.HashSet(); + foreach (long handle in topLevelHandles) + if (!nodes.ContainsKey(handle) || !tops.Add(handle)) + throw new InvalidOperationException("Window family: missing or duplicate top-level window."); + if (!tops.Contains(rootHandle) || nodes[rootHandle].ProcessId != expectedPid) + throw new InvalidOperationException("Window family: missing or foreign root."); + var included = new System.Collections.Generic.Dictionary(); + foreach (long handle in topLevelHandles) { + var path = new System.Collections.Generic.List(); + var seen = new System.Collections.Generic.HashSet(); + long cursor = handle; + while (cursor != 0 && !included.ContainsKey(cursor)) { + if (!tops.Contains(cursor)) + throw new InvalidOperationException("Window family: unresolved nonzero owner."); + if (!seen.Add(cursor)) + throw new InvalidOperationException("Window family: cyclic ownership."); + if (nodes[cursor].ProcessId != expectedPid) + throw new InvalidOperationException("Window family: foreign top-level process."); + path.Add(cursor); + cursor = nodes[cursor].Owner; + } + bool member = cursor != 0 && included[cursor]; + for (int index = path.Count - 1; index >= 0; index--) { + member = path[index] == rootHandle || member; + included.Add(path[index], member); + } + } + foreach (var node in order) { + if (!included.ContainsKey(node.Handle)) included.Add(node.Handle, false); + if (node.Owner != 0 && !tops.Contains(node.Owner)) + throw new InvalidOperationException("Window family: unresolved nonzero owner."); + if (!tops.Contains(node.Handle) && (node.Parent == 0 || !nodes.ContainsKey(node.Parent))) + throw new InvalidOperationException("Window family: missing parent ancestry."); + } + var states = new System.Collections.Generic.Dictionary(); + foreach (var node in order) { + var stack = new System.Collections.Generic.Stack<(long Handle, bool Exit)>(); + stack.Push((node.Handle, false)); + while (stack.Count != 0) { + var next = stack.Pop(); + if (next.Exit) { states[next.Handle] = 2; continue; } + int state; + if (states.TryGetValue(next.Handle, out state)) { + if (state == 1) throw new InvalidOperationException("Window family: cyclic ancestry."); + continue; + } + states.Add(next.Handle, 1); + stack.Push((next.Handle, true)); + var current = nodes[next.Handle]; + if (nodes.ContainsKey(current.Parent)) stack.Push((current.Parent, false)); + if (current.Owner != 0) stack.Push((current.Owner, false)); + } + } + bool changed; + do { + changed = false; + foreach (var node in order) { + bool parentIncluded, ownerIncluded; + if (!included[node.Handle] && + ((included.TryGetValue(node.Parent, out parentIncluded) && parentIncluded) || + (included.TryGetValue(node.Owner, out ownerIncluded) && ownerIncluded))) { + included[node.Handle] = true; + changed = true; + } + } + } while (changed); + var selected = new System.Collections.Generic.List(); + foreach (var node in order) { + if (!included[node.Handle]) continue; + if (node.ProcessId != expectedPid || (node.Owner != 0 && !included[node.Owner])) + throw new InvalidOperationException("Window family: foreign or conflicting selected window."); + selected.Add(node); + } + return selected.ToArray(); + } + + private static void CheckWindowCensusDeadline(System.Diagnostics.Stopwatch clock) { + if (clock.ElapsedMilliseconds > 2000) throw new InvalidOperationException("Owned window census exceeded 2000ms."); + } + + private static CapWindowNode ReadTopology(IntPtr window, uint pid) { + return new CapWindowNode { Handle=window.ToInt64(), Parent=GetAncestor(window, 1).ToInt64(), + Owner=GetWindow(window, 4).ToInt64(), ProcessId=(int)pid }; + } + + private static CapWindowNode[] CollectWindowFamily(IntPtr rootWindow, int expectedPid, + System.Diagnostics.Stopwatch clock) { + if (rootWindow == IntPtr.Zero || expectedPid <= 0) + throw new InvalidOperationException("Window family: missing root or process."); + Exception failure = null; + var topology = new System.Collections.Generic.List(); + var topHandles = new System.Collections.Generic.List(); + bool complete = EnumWindows(delegate(IntPtr window, IntPtr value) { + try { + CheckWindowCensusDeadline(clock); + uint pid; + GetWindowThreadProcessId(window, out pid); + if (pid != expectedPid) return true; + topHandles.Add(window.ToInt64()); + topology.Add(ReadTopology(window, pid)); + return true; + } catch (Exception error) { failure=error; return false; } + }, IntPtr.Zero); + if (failure != null) throw failure; + if (!complete) throw new InvalidOperationException("Owned EnumWindows failed: " + Marshal.GetLastWin32Error()); + var roots = SelectWindowFamily(topology.ToArray(), topHandles.ToArray(), rootWindow.ToInt64(), expectedPid); + CheckWindowCensusDeadline(clock); + foreach (var root in roots) { + EnumChildWindows(new IntPtr(root.Handle), delegate(IntPtr window, IntPtr value) { + try { + CheckWindowCensusDeadline(clock); + uint childPid; + if (GetWindowThreadProcessId(window, out childPid) == 0 || childPid != expectedPid) + throw new InvalidOperationException("Window family: missing or foreign descendant process."); + topology.Add(ReadTopology(window, childPid)); + return true; + } catch (Exception error) { failure=error; return false; } + }, IntPtr.Zero); + if (failure != null) throw failure; + } + var family = SelectWindowFamily(topology.ToArray(), topHandles.ToArray(), rootWindow.ToInt64(), expectedPid); + CheckWindowCensusDeadline(clock); + return family; + } + + public static CapWindowStructure[] WindowStructure(IntPtr rootWindow, int expectedPid) { + var rows = new System.Collections.Generic.List(); + IntPtr previous = EnterPerMonitorV2(); + Exception primary = null; + try { + var clock = System.Diagnostics.Stopwatch.StartNew(); + foreach (var node in CollectWindowFamily(rootWindow, expectedPid, clock)) { + CheckWindowCensusDeadline(clock); + IntPtr window = new IntPtr(node.Handle); + uint pid; + if (GetWindowThreadProcessId(window, out pid) == 0 || pid != expectedPid) + throw new InvalidOperationException("Window family: selected process changed."); + var cls = new StringBuilder(256); + if (GetClassName(window, cls, cls.Capacity) == 0) + throw new InvalidOperationException("Window census class query failed."); + NativeRect bounds; + if (!GetWindowRect(window, out bounds)) + throw new InvalidOperationException("Window census bounds query failed."); + bool visible = IsWindowVisible(window), enabled = IsWindowEnabled(window); + var liveTopology = ReadTopology(window, pid); + AssertWindowAncestry(node, liveTopology); + rows.Add(new CapWindowStructure { Handle=window.ToInt64(), Parent=liveTopology.Parent, + Owner=liveTopology.Owner, ProcessId=(int)pid, ClassName=cls.ToString(), Visible=visible, Enabled=enabled, + Bounds=new int[] {bounds.Left,bounds.Top,bounds.Right,bounds.Bottom} }); + } + CheckWindowCensusDeadline(clock); + return rows.ToArray(); + } catch (Exception error) { error.Data["ObservedRows"]=rows.ToArray(); primary=error; throw; } + finally { RestoreDpiAwareness(previous, primary); } + } + + public static CapWindowNode[] WindowHierarchy(IntPtr rootWindow, int expectedPid) { var rows = new System.Collections.Generic.List(); IntPtr previous = EnterPerMonitorV2(); Exception primary = null; try { - Exception failure = null; var clock = System.Diagnostics.Stopwatch.StartNew(); - WindowCallback record = delegate(IntPtr window, IntPtr value) { - try { - if (clock.ElapsedMilliseconds > 2000) throw new InvalidOperationException("Owned window census exceeded 2000ms."); + var family = CollectWindowFamily(rootWindow, expectedPid, clock); + foreach (var node in family) { + IntPtr window = new IntPtr(node.Handle); + CheckWindowCensusDeadline(clock); uint pid; - if (GetWindowThreadProcessId(window, out pid) == 0) + uint thread = GetWindowThreadProcessId(window, out pid); + if (thread == 0) throw new InvalidOperationException("Window census process query failed."); - if (pid != expectedPid) return true; + if (pid != expectedPid) throw new InvalidOperationException("Window family: selected process changed."); var cls = new StringBuilder(256); var name = new StringBuilder(512); if (GetClassName(window, cls, cls.Capacity) == 0) throw new InvalidOperationException("Window census class query failed."); - UIntPtr textResult; - if (SendMessageTimeoutW(window, 0x000D, (UIntPtr)name.Capacity, name, 2, 50, out textResult) == IntPtr.Zero) - throw new InvalidOperationException("Window census text query failed/timed out for HWND " + window); NativeRect bounds; if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Window census bounds query failed."); - rows.Add(new CapWindowNode { Handle=window.ToInt64(), Parent=GetAncestor(window, 1).ToInt64(), - Owner=GetWindow(window, 4).ToInt64(), ProcessId=(int)pid, ClassName=cls.ToString(), Name=name.ToString(), - Visible=IsWindowVisible(window), Enabled=IsWindowEnabled(window), + bool visible = IsWindowVisible(window), enabled = IsWindowEnabled(window); + var liveTopology = ReadTopology(window, pid); + AssertWindowAncestry(node, liveTopology); + long parent = liveTopology.Parent, owner = liveTopology.Owner; + UIntPtr textResult; + if (SendMessageTimeoutW(window, 0x000D, (UIntPtr)name.Capacity, name, 2, 50, out textResult) == IntPtr.Zero) { + int nativeError = Marshal.GetLastWin32Error(); + var error = new InvalidOperationException("Window census text query failed/timed out for HWND " + window); + error.Data["WindowTextQueryFailure"] = new { Handle=window.ToInt64(), ClassName=cls.ToString(), + ProcessId=pid, ThreadId=thread, Win32Error=nativeError, Parent=parent, Owner=owner, + Visible=visible, Enabled=enabled, Left=bounds.Left, Top=bounds.Top, Right=bounds.Right, Bottom=bounds.Bottom }; + throw error; + } + rows.Add(new CapWindowNode { Handle=window.ToInt64(), Parent=parent, Owner=owner, + ProcessId=(int)pid, ClassName=cls.ToString(), Name=name.ToString(), Visible=visible, Enabled=enabled, Bounds=new int[] {bounds.Left,bounds.Top,bounds.Right,bounds.Bottom} }); - return true; - } catch (Exception error) { failure=error; return false; } - }; - bool complete = EnumWindows(delegate(IntPtr window, IntPtr value) { - if (failure != null) return false; - uint pid; - GetWindowThreadProcessId(window, out pid); - if (pid == expectedPid) { - if (!record(window, value)) return false; - EnumChildWindows(window, record, value); - } - return failure == null; - }, IntPtr.Zero); - if (failure != null) { - failure.Data["ObservedRows"]=rows.ToArray(); - throw failure; } - if (!complete) throw new InvalidOperationException("Owned EnumWindows failed: " + Marshal.GetLastWin32Error()); + CheckWindowCensusDeadline(clock); return rows.ToArray(); - } catch (Exception error) { primary=error; throw; } + } catch (Exception error) { error.Data["ObservedRows"]=rows.ToArray(); primary=error; throw; } finally { RestoreDpiAwareness(previous, primary); } } @@ -352,6 +546,16 @@ public static class CapNative { IntPtr previous = EnterPerMonitorV2(); Exception primary=null; try { + var result=ObservePointer(window, screenX, screenY, requireRecipient); + DispatchPointerMessages(result, delegate(uint message, uint keys, long position) { + return PostMessage(window, message, (UIntPtr)keys, new IntPtr(position)); + }); + return result; + } catch (Exception error) { primary=error; throw; } + finally { RestoreDpiAwareness(previous, primary); } + } + + private static CapPointerReceipt ObservePointer(IntPtr window, int screenX, int screenY, bool requireRecipient) { NativePoint cursor; if (!GetCursorPos(out cursor)) throw new InvalidOperationException("Pointer cursor observation failed: " + Marshal.GetLastWin32Error()); @@ -369,19 +573,47 @@ public static class CapNative { var client=ConvertPointerScreen(window, screenX, screenY); point.X=client[0]; point.Y=client[1]; long packed = (point.Y & 0xffff) << 16 | (point.X & 0xffff); - var result = new CapPointerReceipt { Window=window.ToInt64(), Recipient=recipient.ToInt64(), + return new CapPointerReceipt { Window=window.ToInt64(), Recipient=recipient.ToInt64(), Foreground=GetForegroundWindow().ToInt64(), ProcessId=(int)pid, RecipientProcessId=(int)recipientPid, ScreenX=screenX, ScreenY=screenY, ClientX=point.X, ClientY=point.Y, PackedLParam=packed, DpiContext=GetThreadDpiAwarenessContext().ToInt64(), CursorX=cursor.X, CursorY=cursor.Y, CursorWindow=WindowFromPoint(cursor).ToInt64() }; - DispatchPointerMessages(result, delegate(uint message, uint keys, long position) { - return PostMessage(window, message, (UIntPtr)keys, new IntPtr(position)); + } + + public static CapPointerDragReceipt PostPointerDragScreen(IntPtr window, int startX, int startY, int endX, int endY) { + IntPtr previous=EnterPerMonitorV2(); + Exception primary=null; + var result=new CapPointerDragReceipt(); + try { + // Observe both actual recipients before posting any part of the gesture. + result.Start=ObservePointer(window,startX,startY,true); + result.End=ObservePointer(window,endX,endY,true); + DispatchPointerDragMessages(result, delegate(uint message,uint keys,long position) { + return PostMessage(window,message,(UIntPtr)keys,new IntPtr(position)); }); return result; - } catch (Exception error) { primary=error; throw; } + } catch (Exception error) { + error.Data["PointerDragReceipt"]=result; + primary=error; + throw; + } finally { RestoreDpiAwareness(previous, primary); } } + public static void DispatchPointerDragMessages(CapPointerDragReceipt result, Func poster) { + try { + result.Start.MovePosted=poster(0x0200,0,result.Start.PackedLParam); + if (result.Start.MovePosted) result.Start.DownPosted=poster(0x0201,1,result.Start.PackedLParam); + if (result.Start.DownPosted) result.HeldMovePosted=poster(0x0200,1,result.End.PackedLParam); + if (result.HeldMovePosted) result.End.UpPosted=poster(0x0202,0,result.End.PackedLParam); + if (!result.Start.MovePosted || !result.Start.DownPosted || !result.HeldMovePosted || !result.End.UpPosted) + throw new InvalidOperationException("Native pointer drag dispatch failed; no retry or synthetic release."); + } catch (Exception error) { + error.Data["PointerDragReceipt"]=result; + throw; + } + } + public static void DispatchPointerMessages(CapPointerReceipt result, Func poster) { result.MovePosted = poster(0x0200, 0, result.PackedLParam); if (result.MovePosted) result.DownPosted = poster(0x0201, 1, result.PackedLParam); @@ -456,14 +688,32 @@ public static class CapNative { }; } + public static string DecodeImagePath(bool success, int nativeError, IntPtr buffer, int capacity, uint length) { + if (!success) throw new Win32Exception(nativeError); + if (buffer == IntPtr.Zero || capacity <= 0 || length == 0 || length >= (uint)capacity) + throw new InvalidOperationException("Native image query returned invalid buffer or length."); + if (Marshal.ReadInt16(buffer, checked((int)length * 2)) != 0) + throw new InvalidOperationException("Native image query returned an unterminated path."); + string image = Marshal.PtrToStringUni(buffer, (int)length); + if (image == null || image.Length != (int)length || image.IndexOf('\0') >= 0) + throw new InvalidOperationException("Native image query returned inconsistent path content."); + return image; + } + public static string ImagePath(IntPtr handle, int expectedPid) { if (handle == IntPtr.Zero || GetProcessId(handle) != (uint)expectedPid) throw new InvalidOperationException("Process handle/PID mismatch."); - uint size = 32768; - var buffer = new StringBuilder((int)size); - if (!QueryFullProcessImageNameW(handle, 0, buffer, ref size)) - throw new Win32Exception(Marshal.GetLastWin32Error()); - return buffer.ToString(); + const int capacity = 32768; + IntPtr buffer = Marshal.AllocHGlobal(capacity * 2); + try { + var initialized = new short[capacity]; + Array.Fill(initialized, (short)-1); + Marshal.Copy(initialized, 0, buffer, capacity); + uint size = capacity; + bool success = QueryFullProcessImageNameW(handle, 0, buffer, ref size); + int nativeError = Marshal.GetLastWin32Error(); + return DecodeImagePath(success, nativeError, buffer, capacity, size); + } finally { Marshal.FreeHGlobal(buffer); } } public static CapProcessSnapshot Snapshot(IntPtr handle, int expectedPid) { @@ -1914,14 +2164,16 @@ function Get-CapWireTargetBinding { $sources['NativeForms.zig'] -notmatch 'c\.WS_CHILD \| c\.WS_VISIBLE \| c\.WS_CLIPCHILDREN' -or $sources['NativeForms.zig'] -notmatch 'sheet\.viewport,' -or $sources['NativeForms.zig'] -notmatch 'const class_name = .*"GraphCodeNativeForm"' -or - $sources['SheetFramework.zig'] -notmatch 'const host_class = .*"GraphCodeSheetHost"') { + $sources['SheetFramework.zig'] -notmatch 'const host_class = .*"GraphCodeSheetHost"' -or + $canvas -notmatch '(?s)fn connectorPositionForIndex\(.*?\.x = if \(outgoing\) bounds\.right else bounds\.left,.*?\.y = @divTrunc\(bounds\.top \+ bounds\.bottom, 2\),') { throw 'Wire target source geometry or sheet hierarchy changed; no guessed fallback.' } $project=Join-Path ([IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\')) 'fixtures\wire-project' $source='60000000-0000-4000-8000-000000000001' $target='60000000-0000-4000-8000-000000000002' return [pscustomobject]@{ - schemaVersion=1L;scene='s-goal-unwired-unselected';phase='wire-completion' + schemaVersion=2L;scene='s-goal-unwired-unselected';phase='wire-completion' + gesture='SOURCE_CONNECTOR_DRAG_DROP';sourceConnector='RIGHT_CENTRE' projectPath=$project;sourceNodeId=$source;targetNodeId=$target sourceCardId=(Get-CapMatchedAutomationId "project-card:$project`:$source") targetCardId=(Get-CapMatchedAutomationId "project-card:$project`:$target") @@ -1985,6 +2237,11 @@ function Get-CapWireTargetObservation { ($bounds[0]+$Binding.safeLogicalBounds[2]*$scale), ($bounds[1]+$Binding.safeLogicalBounds[3]*$scale)) $point=@([int][Math]::Floor(($safe[0]+$safe[2])/2),[int][Math]::Floor(($safe[1]+$safe[3])/2)) + if ($Binding.gesture -cne 'SOURCE_CONNECTOR_DRAG_DROP' -or $Binding.sourceConnector -cne 'RIGHT_CENTRE') { + throw 'Unsupported wire gesture or source connector binding.' + } + $start=@([int][Math]::Floor($source[0].Bounds[2]), + [int][Math]::Floor(($source[0].Bounds[1]+$source[0].Bounds[3])/2)) foreach ($row in $Rows) { if ($row.AutomationId -ceq $Binding.targetCardId -or $row.AutomationId -cin @('graph','status')) {continue} $b=@($row.Bounds) @@ -1993,10 +2250,136 @@ function Get-CapWireTargetObservation { throw "Unsafe wire target point overlaps another card/control: $($row.AutomationId)" } } + foreach ($row in $Rows) { + if ($row.AutomationId -ceq $Binding.sourceCardId -or $row.AutomationId -cin @('graph','status')) {continue} + $b=@($row.Bounds) + if (-not $row.IsOffscreen -and $b.Count -eq 4 -and $start[0] -ge $b[0] -and + $start[0] -lt $b[2] -and $start[1] -ge $b[1] -and $start[1] -lt $b[3]) { + throw "Unsafe wire source connector overlaps another card/control: $($row.AutomationId)" + } + } if ($point[0] -le $bounds[0] -or $point[0] -ge $bounds[2] -or $point[1] -le $bounds[1] -or $point[1] -ge $bounds[3]) {throw 'Unsafe wire target point outside card.'} return [pscustomobject]@{binding=$Binding;source=$source[0];target=$target[0] - mode=$status[0].Name;safeBounds=$safe;screenPoint=$point;dpi=$Dpi} + mode=$status[0].Name;safeBounds=$safe;screenPoint=$point;sourceScreenPoint=$start;dpi=$Dpi} +} + +function Get-UiaReadonlyEndpointsDecision($snapshot, [int] $processId, [long] $owner, + [string] $title, [string] $from, [string] $to) { + if ($null -eq $snapshot -or $processId -le 0 -or $owner -le 0 -or + $snapshot.handle -le 0 -or $snapshot.processId -ne $processId -or $snapshot.owner -ne $owner -or + $snapshot.class -cne 'GraphCodeSheetHost' -or $snapshot.title -cne $title -or + $snapshot.captionless -ne $true -or [string]::IsNullOrWhiteSpace($from) -or + [string]::IsNullOrWhiteSpace($to) -or $from -ieq $to) {return @{ready=$false}} + foreach ($child in @( + @{row=$snapshot.viewport;parent=$snapshot.handle;class='GraphCodeSheetViewport'}, + @{row=$snapshot.content;parent=$snapshot.viewport.handle;class='GraphCodeNativeForm'})) { + $row=$child.row + if ($null -eq $row -or $row.handle -le 0 -or $row.parent -ne $child.parent -or + $row.processId -ne $processId -or $row.class -cne $child.class -or $row.matches -ne 1 -or + $row.stable -ne $true -or $row.owned -ne $true -or + [string]::IsNullOrWhiteSpace($row.runtime)) {return @{ready=$false}} + } + $fields=@($snapshot.fields) + if ($fields.Count -ne 2) {return @{ready=$false}} + for ($index=0;$index -lt 2;$index++) { + $row=$fields[$index] + $expected=@($from,$to)[$index] + if ($null -eq $row -or $row.handle -le 0 -or $row.parent -ne $snapshot.content.handle -or + $row.processId -ne $processId -or $row.semanticProcessId -ne $processId -or + $row.controlId -ne (9100+$index) -or $row.class -cne 'Edit' -or $row.matches -ne 1 -or + $row.stable -ne $true -or $row.owned -ne $true -or $row.readonly -ne $true -or + -not $row.Contains('nativeReadonly') -or $row.nativeReadonly -ne $true -or + [string]::IsNullOrWhiteSpace($row.runtime) -or $row.value -ine $expected -or + $row.semanticValue -cne $row.value) {return @{ready=$false}} + } + if ($fields[0].handle -eq $fields[1].handle) {return @{ready=$false}} + return @{ready=$true;source=$fields[0].value;target=$fields[1].value} +} + +function Read-UiaReadonlyEdgeEndpoints { + param( + [Parameter(Mandatory)]$Dialog, + [Parameter(Mandatory)][int]$ProcessId, + [Parameter(Mandatory)][IntPtr]$Owner, + [Parameter(Mandatory)][string]$Title, + [string]$ExpectedSource='11111111-1111-4111-8111-111111111111', + [string]$ExpectedTarget='22222222-2222-4222-8222-222222222222', + [string]$AssemblyPath=$env:GRAPHCODE_VALIDATION_ASSEMBLY + ) + if ([string]::IsNullOrWhiteSpace($AssemblyPath)) { + $AssemblyPath=& (Join-Path $PSScriptRoot 'Build-CompiledValidationRunner.ps1') + } + foreach ($requiredType in @('GraphCodeUiaGateState','GraphCodeMultiProjectEditNative')) { + & (Join-Path $PSScriptRoot 'Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType $requiredType | Out-Null + } + $sheet=[IntPtr]::new([long]$Dialog.Current.NativeWindowHandle) + $snapshot=@{ + handle=$sheet.ToInt64();processId=[GraphCodeUiaGateState]::WindowProcessId($sheet) + owner=[GraphCodeUiaGateState]::WindowOwner($sheet).ToInt64() + class=[GraphCodeUiaGateState]::WindowClass($sheet);title=[GraphCodeUiaGateState]::WindowTitle($sheet) + captionless=[GraphCodeUiaGateState]::IsCaptionlessSheet($sheet) + } + if ($snapshot.processId -ne $ProcessId -or $snapshot.owner -ne $Owner.ToInt64() -or + $snapshot.class -cne 'GraphCodeSheetHost' -or $snapshot.title -cne $Title -or -not $snapshot.captionless) { + throw 'Readonly endpoint sheet identity changed before reads' + } + $raw=[Windows.Automation.TreeWalker]::RawViewWalker + function Read-EndpointChild([IntPtr]$parent,[string]$class,[int]$id) { + $handle=if ($id -eq 0) {[GraphCodeUiaGateState]::FindChild($parent,$class)} + else {[GraphCodeUiaGateState]::ControlById($parent,$id)} + if ($handle -eq [IntPtr]::Zero -or + [GraphCodeUiaGateState]::WindowProcessId($handle) -ne $ProcessId -or + [GraphCodeUiaGateState]::WindowClass($handle) -cne $class -or + -not [GraphCodeUiaGateState]::IsControlOwnedBy($sheet,$handle,$id)) { + throw 'Exact owned readonly endpoint child missing' + } + $element=[Windows.Automation.AutomationElement]::FromHandle($handle) + $runtime=$element.GetRuntimeId() -join ',' + $sibling=$raw.GetFirstChild([Windows.Automation.AutomationElement]::FromHandle($parent)) + $visited=0;$matches=0 + while ($null -ne $sibling) { + if ($visited -ge 256 -or $sibling.Current.ProcessId -ne $ProcessId) { + throw 'Readonly endpoint children exceeded their owned bounded census' + } + $candidate=[IntPtr]::new([long]$sibling.Current.NativeWindowHandle) + if ($candidate -ne [IntPtr]::Zero -and + [GraphCodeUiaGateState]::WindowClass($candidate) -ceq $class -and + [GraphCodeUiaGateState]::ControlIdOf($candidate) -eq $id) {$matches++} + $visited++;$sibling=$raw.GetNextSibling($sibling) + } + $again=if ($id -eq 0) {[GraphCodeUiaGateState]::FindChild($parent,$class)} + else {[GraphCodeUiaGateState]::ControlById($parent,$id)} + $row=@{ + handle=$handle.ToInt64();parent=[long]$raw.GetParent($element).Current.NativeWindowHandle + processId=[GraphCodeUiaGateState]::WindowProcessId($handle);semanticProcessId=$element.Current.ProcessId + class=[GraphCodeUiaGateState]::WindowClass($handle);controlId=[GraphCodeUiaGateState]::ControlIdOf($handle) + owned=[GraphCodeUiaGateState]::IsControlOwnedBy($sheet,$handle,$id) + runtime=$runtime;matches=$matches;stable=($again -eq $handle -and ($element.GetRuntimeId() -join ',') -ceq $runtime) + } + if ($row.parent -ne $parent.ToInt64() -or $row.matches -ne 1 -or -not $row.stable) { + throw 'Readonly endpoint child is duplicate, stale or not a direct owned descendant' + } + if ($id -ne 0) { + $pattern=$element.GetCurrentPattern([Windows.Automation.ValuePattern]::Pattern) + $row.readonly=$pattern.Current.IsReadOnly + $row.nativeReadonly=[GraphCodeUiaGateState]::IsReadonlyEdit($handle) + $row.semanticValue=$pattern.Current.Value + $row.value=[GraphCodeMultiProjectEditNative]::Buffer($handle,100) + $row.stable=$row.stable -and [GraphCodeUiaGateState]::ControlById($parent,$id) -eq $handle -and + ($element.GetRuntimeId() -join ',') -ceq $runtime + } + return $row + } + $snapshot.viewport=Read-EndpointChild $sheet 'GraphCodeSheetViewport' 0 + $snapshot.content=Read-EndpointChild ([IntPtr]::new($snapshot.viewport.handle)) 'GraphCodeNativeForm' 0 + $content=[IntPtr]::new($snapshot.content.handle) + $snapshot.fields=@((Read-EndpointChild $content 'Edit' 9100),(Read-EndpointChild $content 'Edit' 9101)) + $decision=Get-UiaReadonlyEndpointsDecision $snapshot $ProcessId $Owner.ToInt64() $Title $ExpectedSource $ExpectedTarget + if (-not $decision.ready) {throw 'Readonly native/semantic endpoint identity or value changed'} + Write-Host ('UIA_READONLY_ENDPOINT_EVIDENCE=' + ($snapshot|ConvertTo-Json -Depth 6 -Compress)) + return @{source=$decision.source;target=$decision.target;snapshot=$snapshot} } function Resolve-CapOwnedSheet { @@ -2062,16 +2445,58 @@ function Get-CapWireAlgorithmPreflight { target=$target;sheet=$sheet;runtimeWindowEvidence=$false;productLaunches=0;providerLaunches=0} } +function Initialize-CapUiaClient { + param([string]$AssemblyPath=$env:GRAPHCODE_VALIDATION_ASSEMBLY) + if (-not $PSBoundParameters.ContainsKey('AssemblyPath') -and + -not [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY')) { + $AssemblyPath=Join-Path $PSScriptRoot 'ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.dll' + } + if ([string]::IsNullOrWhiteSpace($AssemblyPath)) { + throw 'Capture UIA client requires the explicit current compiled validation assembly.' + } + $assembly=& (Join-Path $PSScriptRoot 'Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' + if ($assembly -isnot [Reflection.Assembly]) { + throw 'Capture UIA client initialization did not return the validated assembly.' + } + Write-CapTrace -Command 'CaptureUiaClientInitialized' -Parameters @{ + assemblyPath=$assembly.Location;assemblyMvid=$assembly.ManifestModule.ModuleVersionId.ToString() + providerPath=(Join-Path $PSHOME 'UIAutomationClientsideProviders.dll') + validation='EXISTING_SOURCE_BOUND_SIGNED_RUNTIME_PROVIDER_LOADER' + registration='EXISTING_MANAGED_IDEMPOTENT_REGISTRATION'} +} + +function Get-CapOwnedWindowStructure { + param([Parameter(Mandatory)][IntPtr]$RootHandle,[Parameter(Mandatory)]$ProcessIdentity) + $null=Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity + try {$rows=@([CapNative]::WindowStructure($RootHandle,$ProcessIdentity.Pid))} + catch { + $cause=$_.Exception + while ($cause.InnerException) {$cause=$cause.InnerException} + Write-CapTrace -Command 'OwnedWindowStructureFailure' -Parameters @{ + root=[long]$RootHandle;error=$cause.Message + observedRows=($cause.Data['ObservedRows']|ConvertTo-Json -Depth 10 -Compress) + ancestryChange=($cause.Data['WindowAncestryFailure']|ConvertTo-Json -Depth 3 -Compress)} + throw + } + $null=Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters @{ + root=[long]$RootHandle;rows=($rows|ConvertTo-Json -Depth 10 -Compress)} + return $rows +} + function Get-CapOwnedWindowHierarchy { param([Parameter(Mandatory)][IntPtr]$RootHandle,[Parameter(Mandatory)]$ProcessIdentity) $null=Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity - try {$rows=@([CapNative]::WindowHierarchy($ProcessIdentity.Pid))} + try {$rows=@([CapNative]::WindowHierarchy($RootHandle,$ProcessIdentity.Pid))} catch { $cause=$_.Exception while ($cause.InnerException) {$cause=$cause.InnerException} Write-CapTrace -Command 'OwnedWindowCensusFailure' -Parameters @{ root=[long]$RootHandle;error=$cause.Message - observedRows=($cause.Data['ObservedRows']|ConvertTo-Json -Depth 10 -Compress)} + observedRows=($cause.Data['ObservedRows']|ConvertTo-Json -Depth 10 -Compress) + failedWindow=($cause.Data['WindowTextQueryFailure']|ConvertTo-Json -Depth 3 -Compress) + ancestryChange=($cause.Data['WindowAncestryFailure']|ConvertTo-Json -Depth 3 -Compress)} throw } $null=Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity @@ -2219,7 +2644,7 @@ function Invoke-CapAction { if ($Action -eq 'file-exit') { if ($Invoker) { return & $Invoker $spec } $null = Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity -WindowProcessResolver $WindowProcessResolver - if (-not [GraphCodeUiaGateState]::PostCommand($RootHandle, [uint32]$spec.CommandId)) { + if (-not [CapUiaGateState]::PostCommand($RootHandle, [uint32]$spec.CommandId)) { throw 'Posting the native File > Exit command failed.' } return [pscustomobject]@{ Action = $Action; CommandId = $spec.CommandId; Invoked = $true } @@ -2665,7 +3090,7 @@ function Find-CapTopLevelWindow { while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds -and [DateTime]::UtcNow -lt $DeadlineUtc) { $verified = Assert-CapProcessIdentity -Identity $ProcessIdentity if ($verified.State -ne 'Live') { throw "Owned app PID $($ProcessIdentity.Pid) exited before its window appeared." } - $window = [GraphCodeUiaGateState]::FindTopLevel($ClassName, [uint32]$ProcessIdentity.Pid) + $window = [CapUiaGateState]::FindTopLevel($ClassName, [uint32]$ProcessIdentity.Pid) if ($window -ne [IntPtr]::Zero) { $null = Assert-CapRootProcess -RootHandle $window -ProcessIdentity $ProcessIdentity return $window @@ -3629,6 +4054,49 @@ function Get-CapAdvisoryOlderExclusion { } } +function Get-CapAdvisoryExclusionDiagnostic { + param([Parameter(Mandatory)]$Exclusion) + $errorRecord=$Exclusion.OriginalErrorRecord + $facts=[ordered]@{ + Pid=$Exclusion.Pid;ParentPid=$Exclusion.ParentPid + ParentCreationFileTimeUtc=$Exclusion.ParentCreationFileTimeUtc + ParentExitFileTimeUtc=$Exclusion.ParentExitFileTimeUtc + CandidateCreationFileTimeUtc=$Exclusion.CandidateCreationFileTimeUtc + CandidateCreationSource=$Exclusion.CandidateCreationSource + ParentCreationSource=$Exclusion.ParentCreationSource + NativeWin32Error=$Exclusion.NativeWin32Error;NativeFailure=$Exclusion.NativeFailure + Reason=$Exclusion.Reason;Disposition=$Exclusion.Disposition + CimProcessId=$Exclusion.RawCimRow.ProcessId;CimParentProcessId=$Exclusion.RawCimRow.ParentProcessId + CimSessionId=$Exclusion.RawCimRow.SessionId;CimCreationDate=$Exclusion.RawCimRow.CreationDate + CimExecutablePath=$Exclusion.RawCimRow.ExecutablePath;CimCommandLine=$Exclusion.RawCimRow.CommandLine + errorType=$errorRecord.Exception.GetType().FullName + fullyQualifiedErrorId=$errorRecord.FullyQualifiedErrorId + message=$errorRecord.Exception.Message;scriptStackTrace=$errorRecord.ScriptStackTrace + } + $truncated=[Collections.Generic.List[object]]::new() + $omitted=[Collections.Generic.List[object]]::new() + foreach ($name in @($facts.Keys)) { + $value=$facts[$name] + if ($value -is [DateTime] -or $value -is [DateTimeOffset]) {$facts[$name]=$value.ToString('o')} + elseif ($value -is [string]) { + if ($value.Length -gt 2048) { + $facts[$name]=$value.Substring(0,2048) + $truncated.Add([pscustomobject]@{field=$name;originalCharacters=$value.Length;retainedCharacters=2048}) + } + } elseif ($null -ne $value -and $value -isnot [int] -and $value -isnot [long] -and + $value -isnot [uint32] -and $value -isnot [uint64] -and $value -isnot [bool]) { + $omitted.Add([pscustomobject]@{field=$name;reason='NON_SCALAR_VALUE_NOT_TRAVERSED';type=$value.GetType().FullName}) + $facts[$name]=$null + } + } + $facts.diagnosticCompleteness='BOUNDED_SCALARS_WITH_EXPLICIT_GRAPH_OMISSIONS' + $facts.omittedGraphs=@('OriginalErrorRecord','OriginalErrorRecord.TargetObject', + 'OriginalErrorRecord.InvocationInfo','OriginalErrorRecord.Exception.Data','Module','Process') + $facts.omittedFields=@($omitted.ToArray()) + $facts.truncatedFields=@($truncated.ToArray()) + return [pscustomobject]$facts +} + function Get-CapOwnedDescendants { [CmdletBinding()] param( @@ -3694,7 +4162,7 @@ function Get-CapOwnedDescendants { if (-not $alreadyRecorded) { $script:CapExcludedDescendants.Add($exclusion) Write-CapTrace -Command 'CapDescendantAdvisoryExcluded' -Parameters @{ - diagnosticJson = ($exclusion | ConvertTo-Json -Depth 12 -Compress) + diagnosticJson = (Get-CapAdvisoryExclusionDiagnostic -Exclusion $exclusion | ConvertTo-Json -Depth 6 -Compress) } } continue @@ -3902,7 +4370,7 @@ function Test-CapPipeUnserved { } function Get-CapNativeBindingDeclarations { - $types = @([CapNative], [GraphCodeUiaGateState], [CapProcessJob]) + $types = @([CapNative], [CapUiaGateState], [CapProcessJob]) $declarations = [Collections.Generic.List[object]]::new() foreach ($type in $types) { foreach ($method in $type.GetMethods([Reflection.BindingFlags]'Static,Public,NonPublic')) { @@ -4101,9 +4569,92 @@ function Get-CapMatchedCapturePlan { } } +function Test-CapFunctionalWireJob { + param([Parameter(Mandatory)]$Job,[string]$Group='WireCompletion') + $enabled=$Job.PSObject.Properties['functionalWireOnly'] -and $Job.functionalWireOnly -ceq $true + $adapter=if ($Job.PSObject.Properties['sceneAdapter']) {$Job.sceneAdapter} else {''} + if (-not $enabled -and $adapter -cne 'S_FUNCTIONAL_W1_V1') {return $false} + if (-not $enabled -or $adapter -cne 'S_FUNCTIONAL_W1_V1' -or $Group -cne 'WireCompletion') { + throw 'Functional Wire opt-in permits only W1/WireCompletion.' + } + Assert-CapDataRecord $Job.functionalWireContract @{schemaVersion=[long];scene=[string] + windowSourcePath=[string];windowSourceSha256=[string];placement=[string] + preferredLogicalClientWH=[array];pngAllowed=[bool];visualParityClaimed=[bool] + applicationSourceTree=[string]} 'Functional Wire contract' + $contract=$Job.functionalWireContract + if ($contract.schemaVersion -ne 1 -or $contract.scene -cne 's-goal-unwired-unselected' -or + $contract.placement -cne 'MEASURED_OWNED_WINDOW_FIT_WORKAREA' -or + ($contract.preferredLogicalClientWH -join ',') -cne '1280,720' -or + $contract.pngAllowed -cne $false -or $contract.visualParityClaimed -cne $false -or + $contract.windowSourceSha256 -cnotmatch '^[A-F0-9]{64}$' -or + $Job.head -cnotmatch '^[a-f0-9]{40}$' -or $Job.binaries.after.source -cne $Job.head -or + $Job.binaries.after.binaryBuiltAtSource -cnotmatch '^[a-f0-9]{40}$' -or + $contract.applicationSourceTree -cnotmatch '^[a-f0-9]{40}$') { + throw 'Functional Wire contract/source/no-image boundary differs.' + } + return $true +} + +function Get-CapFunctionalWireStatePlan { + param([Parameter(Mandatory)]$WireBinding,[Parameter(Mandatory)][string]$SourceHead, + [Parameter(Mandatory)][string]$WorkerSha256,[Parameter(Mandatory)][string]$StubSha256) + return [pscustomobject][ordered]@{ + schemaVersion=1L + evidenceMode='FRESH_OWNED_W1_STATE_PLAN_NOT_RUNTIME_EVIDENCE' + sourceHead=$SourceHead + workerSha256=$WorkerSha256 + stubDaemonSha256=$StubSha256 + wireBinding=$WireBinding + expectedBefore=[pscustomobject]@{cards=2L;edges=0L;sourceTitle='Wire source';targetTitle='Wire target'} + requiredOutcome='OWNED_LOCKED_EXACT_ENDPOINTS_BEFORE_CREATE_AND_CORRELATED_APPLIED_EDGE' + imageEvidence=$false + visualParityClaimed=$false + } +} + +function Get-CapFunctionalWirePlacement { + param([Parameter(Mandatory)]$Identity,[Parameter(Mandatory)]$Metrics, + [Parameter(Mandatory)][int[]]$WorkArea) + if ($Identity.measurement -cne 'REQUIRED_AT_OWNED_WINDOW_STARTUP' -or + (Get-FileHash -LiteralPath $Identity.sourcePath -Algorithm SHA256).Hash -cne $Identity.sourceSha256 -or + $WorkArea.Count -ne 4 -or $WorkArea[2] -le $WorkArea[0] -or $WorkArea[3] -le $WorkArea[1]) { + throw 'Functional Wire source/work-area measurement differs.' + } + $sourceIdentity=Get-CapGraphCodeWindowIdentity -SourceText ([IO.File]::ReadAllText($Identity.sourcePath)) ` + -SourceCommit $Identity.sourceCommit -ExpectedDpi $Metrics.Dpi -LogicalClientWH @(96,96) + $null=Assert-CapDpiWindowIdentity -Metrics $Metrics -Identity $sourceIdentity -Initial + $frame=@(($sourceIdentity.expectedAdjustedOuterWH[0]-$Metrics.Dpi), + ($sourceIdentity.expectedAdjustedOuterWH[1]-$Metrics.Dpi)) + $client=@( + [long][Math]::Min([Math]::Round(1280.0*$Metrics.Dpi/96),$WorkArea[2]-$WorkArea[0]-$frame[0]), + [long][Math]::Min([Math]::Round(720.0*$Metrics.Dpi/96),$WorkArea[3]-$WorkArea[1]-$frame[1])) + if ($client[0] -le 0 -or $client[1] -le 0) {throw 'Functional Wire window cannot fit measured work area.'} + $outer=@(($client[0]+$frame[0]),($client[1]+$frame[1])) + $left=[long]($WorkArea[0]+[Math]::Floor(($WorkArea[2]-$WorkArea[0]-$outer[0])/2)) + $top=[long]($WorkArea[1]+[Math]::Floor(($WorkArea[3]-$WorkArea[1]-$outer[1])/2)) + $sourceIdentity.expectedPhysicalClientWH=$client + $sourceIdentity.expectedAdjustedOuterWH=$outer + return [pscustomobject]@{ + evidenceMode='MEASURED_OWNED_WINDOW_DPI_AND_WORKAREA_NOT_VISUAL_PARITY' + Identity=$sourceIdentity;InitialMetrics=$Metrics;WorkArea=@($WorkArea) + Geometry=[pscustomobject]@{OuterBounds=@($left,$top,($left+$outer[0]),($top+$outer[1])) + OuterWH=$outer;PhysicalClientWH=$client;Dpi=[long]$Metrics.Dpi} + } +} + function Get-CapActionWindowBindings { param([Parameter(Mandatory)]$Job, [Parameter(Mandatory)][ValidateSet('WireCompletion','TerminalActions','FinalBoundedActions',IgnoreCase=$false)][string]$Group) + if (Test-CapFunctionalWireJob -Job $Job -Group $Group) { + return [pscustomobject]@{ + actionPhase='wire-completion';capturePhase='after' + sceneNames=@('s-goal-unwired-unselected');actionIds=@('W1');sourceCommit=$Job.head + identity=[pscustomobject]@{sourceContract='GRAPHCODE_MAIN_WINDOW_V1';sourceCommit=$Job.head + sourcePath=$Job.functionalWireContract.windowSourcePath + sourceSha256=$Job.functionalWireContract.windowSourceSha256 + measurement='REQUIRED_AT_OWNED_WINDOW_STARTUP'} + } + } if ($Job.sceneAdapter -cne 'S_MATCHED_V12_DPI_V1') { throw 'Action window binding requires the matched DPI adapter.' } @@ -6484,8 +7035,11 @@ function Assert-CapNoWorkerTransientArtifacts { } Export-ModuleMember -Function @( + 'Get-CapAdvisoryExclusionDiagnostic', + 'Test-CapFunctionalWireJob','Get-CapFunctionalWireStatePlan','Get-CapFunctionalWirePlacement', + 'Get-UiaReadonlyEndpointsDecision','Read-UiaReadonlyEdgeEndpoints', 'Get-CapWireTargetBinding','Assert-CapWireTargetBinding','Get-CapWireTargetObservation', - 'Resolve-CapOwnedSheet','Get-CapOwnedWindowHierarchy','Assert-CapWireTargetStable','Get-CapWireAlgorithmPreflight', + 'Resolve-CapOwnedSheet','Initialize-CapUiaClient','Get-CapOwnedWindowStructure','Get-CapOwnedWindowHierarchy','Assert-CapWireTargetStable','Get-CapWireAlgorithmPreflight', 'Get-CapMatchedSceneNames','Get-CapMatchedFocusedSceneNames','Get-CapMatchedFocusedClassification', 'Get-CapMatchedCapturePlan', 'Get-CapActionWindowBindings','Assert-CapActionWindowBindings', diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index 5520b1dc..a1f10d30 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -23,13 +23,13 @@ public static IReadOnlyList Create(IReadOnlyList .Select(unit => unit.Id).ToArray()), new CaptureSuite(required.Single(unit => unit.Id == "ps.ui-capture")), new ScriptSuite("ps.action-worker", "ActionWorkerImport.Tests.ps1", - output => EvidenceParser.Structured("ps.action-worker", "ActionWorkerImport", output, 18, 18)), + output => EvidenceParser.Structured("ps.action-worker", "ActionWorkerImport", output, 20, 20)), new ScriptSuite("ps.action-identity", "ActionWindowIdentity.Tests.ps1", - output => EvidenceParser.Structured("ps.action-identity", "ActionWindowIdentity", output, 62, 62)), + output => EvidenceParser.Structured("ps.action-identity", "ActionWindowIdentity", output, 85, 85)), new ScriptSuite("ps.action-topology", "ActionTopologyImport.Tests.ps1", output => EvidenceParser.Structured("ps.action-topology", "ActionTopologyImport", output, 16, 16)), new ScriptSuite("ps.wire-targeting", "WireTargeting.Tests.ps1", - output => EvidenceParser.Structured("ps.wire-targeting", "WireTargeting", output, 57, 57)), + output => EvidenceParser.Structured("ps.wire-targeting", "WireTargeting", output, 75, 75)), new ScriptSuite("ps.agent-command", "Invoke-AgentCommand.Tests.ps1", EvidenceParser.AgentCommand), new HostTaskSuite("host.task-inventory", "inventory", 17), new HostTaskSuite("host.task-dry-run", "dry-run", 7), @@ -108,8 +108,31 @@ public async Task> RunAsync(ValidationContext context, var captureRoot = PowerShellPayload.CreateScratchRoot(context.RepositoryRoot); await File.WriteAllTextAsync(Path.Combine(context.ArtifactRoot, "capture-root.txt"), captureRoot, cancellationToken); var prebuilt = Path.Combine(captureRoot, "prebuilt"); + var compilerContracts = await PowerShellPayload.RunAsync(context, Id + ".compiler-input", + "Tools\\windows\\Tests\\FixtureCompiler.Tests.ps1", + ["-ScratchRoot", Path.Combine(captureRoot, "compiler-contracts")], cancellationToken); + if (EvidenceParser.Structured(Id, "FixtureCompiler", compilerContracts.Output, 27, 27).Status != UnitStatus.Passed) + throw new InvalidDataException("Fixture compiler input contracts failed"); + var hierarchyContracts = await PowerShellPayload.RunAsync(context, Id + ".window-hierarchy-diagnostic", + "Tools\\windows\\Tests\\WindowHierarchyDiagnostic.Tests.ps1", + ["-ScratchRoot", Path.Combine(captureRoot, "window-hierarchy-contracts")], cancellationToken); + if (EvidenceParser.Structured(Id, "WindowHierarchyDiagnostic", hierarchyContracts.Output, 32, 32).Status != UnitStatus.Passed) + throw new InvalidDataException("Window hierarchy diagnostic contracts failed"); + var createContracts = await PowerShellPayload.RunAsync(context, Id + ".wire-create-observation", + "Tools\\windows\\Tests\\WireCreateObservation.Tests.ps1", + ["-ScratchRoot", Path.Combine(captureRoot, "wire-create-contracts")], cancellationToken); + if (EvidenceParser.Structured(Id, "WireCreateObservation", createContracts.Output, 15, 15).Status != UnitStatus.Passed) + throw new InvalidDataException("Wire Create observation contracts failed"); + var imageContracts = await PowerShellPayload.RunAsync(context, Id + ".native-image-buffer", + "Tools\\windows\\Tests\\NativeImageBuffer.Tests.ps1", + ["-ScratchRoot", Path.Combine(captureRoot, "native-image-contracts")], cancellationToken); + if (EvidenceParser.Structured(Id, "NativeImageBuffer", imageContracts.Output, 9, 9).Status != UnitStatus.Passed) + throw new InvalidDataException("Native image buffer contracts failed"); + string[] compilerArguments = context.FixtureCompilerInput is null ? [] : + ["-FixtureCompilerInput", context.FixtureCompilerInput, + "-FixtureCompilerInputSha256", context.FixtureCompilerInputSha256!]; await PowerShellPayload.RunAsync(context, Id + ".prepare", script, - ["-PrepareFixturesOnly", "-ScratchRoot", prebuilt], cancellationToken); + ["-PrepareFixturesOnly", "-ScratchRoot", prebuilt, .. compilerArguments], cancellationToken); var manifest = Path.Combine(prebuilt, "fixtures.json"); var hash = Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(manifest, cancellationToken))); var suiteRoot = Path.Combine(captureRoot, "suite"); diff --git a/Tools/windows/ValidationRunner/Contracts.cs b/Tools/windows/ValidationRunner/Contracts.cs index 747817e7..7ee3070f 100644 --- a/Tools/windows/ValidationRunner/Contracts.cs +++ b/Tools/windows/ValidationRunner/Contracts.cs @@ -7,7 +7,8 @@ public interface IValidationSuite Task> RunAsync(ValidationContext context, CancellationToken cancellationToken); } -public sealed record ValidationContext(string RepositoryRoot, string ArtifactRoot); +public sealed record ValidationContext(string RepositoryRoot, string ArtifactRoot, + string? FixtureCompilerInput = null, string? FixtureCompilerInputSha256 = null); public enum UnitStatus { Passed, Failed, Skipped, Incomplete } diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 index 4eaff3d2..8c66f235 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 +++ b/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 @@ -140,7 +140,7 @@ try { }, $true)) Assert-Root ($calls.Count -eq 1 -and (@($calls[0].CommandElements | ForEach-Object { $_.Extent.Text }) -join ' ') -ceq - '$runner --repository-root $repoRoot --artifact-root $artifactRoot') 'Runtime scope changed the normal unfiltered apphost arguments' + '$runner --repository-root $repoRoot --artifact-root $artifactRoot @compilerArguments') 'Runtime scope changed the normal unfiltered apphost arguments or omitted compiler-input forwarding' } } finally { foreach ($name in $names) { diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 07948524..994e2e3b 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -50,10 +50,10 @@ "DPI matched logical zoom normalizes physical UIA width", "DPI matched manifest preserves raw logical physical metadata without resampling" ] }, - { "id": "ps.action-worker", "owner": "Host", "minimumCount": 18, "exactCount": 18 }, - { "id": "ps.action-identity", "owner": "Host", "minimumCount": 62, "exactCount": 62 }, + { "id": "ps.action-worker", "owner": "Host", "minimumCount": 20, "exactCount": 20 }, + { "id": "ps.action-identity", "owner": "Host", "minimumCount": 85, "exactCount": 85 }, { "id": "ps.action-topology", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, - { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 57, "exactCount": 57 }, + { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 75, "exactCount": 75 }, { "id": "ps.agent-command", "owner": "Host", "minimumCount": 6, "exactCount": 6 } ] } diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index 795aa23f..0ae5db01 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -19,6 +19,8 @@ static async Task RunAsync(string[] args) string? repositoryRoot = null; string? artifactRoot = null; + string? fixtureCompilerInput = null; + string? fixtureCompilerInputSha256 = null; var selectedSuites = new HashSet(StringComparer.Ordinal); for (var index = 0; index < args.Length; index++) { @@ -28,6 +30,12 @@ static async Task RunAsync(string[] args) { case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; + case "--fixture-compiler-input": + if (!Path.IsPathFullyQualified(value)) + throw new ArgumentException("Fixture compiler input requires an absolute JSON path"); + fixtureCompilerInput = Path.GetFullPath(value); + break; + case "--fixture-compiler-input-sha256": fixtureCompilerInputSha256 = value; break; case "--suite": if (!selectedSuites.Add(value)) throw new ArgumentException($"Duplicate suite selector: {value}"); break; @@ -36,6 +44,10 @@ static async Task RunAsync(string[] args) } if (repositoryRoot is null || artifactRoot is null) throw new ArgumentException("--repository-root and --artifact-root are required"); + if ((fixtureCompilerInput is null) != (fixtureCompilerInputSha256 is null) || + (fixtureCompilerInputSha256 is not null && + !System.Text.RegularExpressions.Regex.IsMatch(fixtureCompilerInputSha256, "\\A[A-F0-9]{64}\\z"))) + throw new ArgumentException("Fixture compiler input requires both a JSON path and uppercase SHA256"); if (!File.Exists(Path.Combine(repositoryRoot, "Tools", "windows", "validate.ps1"))) throw new ArgumentException("Repository root must contain Tools\\windows\\validate.ps1"); Directory.CreateDirectory(artifactRoot); @@ -71,7 +83,7 @@ static async Task RunAsync(string[] args) var registryErrors = Coverage.RegistryErrors(required, suites); if (registryErrors.Count != 0) throw new InvalidDataException("Registry admission failed: " + string.Join("; ", registryErrors)); - var context = new ValidationContext(repositoryRoot, artifactRoot); + var context = new ValidationContext(repositoryRoot, artifactRoot, fixtureCompilerInput, fixtureCompilerInputSha256); foreach (var suite in suites.Where(suite => selectedSuites.Count == 0 || selectedSuites.Contains(suite.Id))) { try diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md index 2c7eed1c..3d2392b8 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -32,6 +32,55 @@ are not compiled into the production binding library. `Build-CompiledValidationRunner.ps1` is a convenience for PowerShell consumers. CI builds the project and runs the normal executable directly. +### Explicit fixture compiler input + +Default capture-fixture preparation still discovers the installed MSVC with the +guarded `vswhere` child. To select a known installed toolchain explicitly, supply +`--fixture-compiler-input` and `--fixture-compiler-input-sha256` together to the +normal executable. The convenience contract script exposes the corresponding +`-FixtureCompilerInput` and `-FixtureCompilerInputSha256` parameters. This is an +explicit preparation route, not a fallback after failed discovery or an +external/prebuilt manifest override. + +The input is an absolute path to a UTF-8 JSON file, bound by its uppercase SHA256, +with exactly these fields: + +```json +{ + "schemaVersion": 1, + "installationPath": "C:\\Program Files\\Microsoft Visual Studio\\18\\Enterprise", + "msvcVersion": "14.51.36231", + "compilerSha256": "SHA256 of the selected Hostx64\\x64\\cl.exe", + "vcvarsSha256": "SHA256 of the installation's VC\\Auxiliary\\Build\\vcvars64.bat", + "sdkRoot": "C:\\Program Files (x86)\\Windows Kits\\10", + "sdkVersion": "10.0.28000.0" +} +``` + +Replace the illustrative paths/versions with the installed inputs and both hash +descriptions with actual 64-character digests. These versions are examples, not +new repository pins. Obtain the digests with `Get-FileHash -Algorithm SHA256`; +hash the final JSON separately. Installation and SDK paths must be batch-safe. +The compiler is derived from the installation and exact MSVC version; arbitrary +executables or wrappers are not accepted. + +The capture adapter first executes the 27 controlled compiler-input contracts, +then freshly prepares all 14 artifacts in its own new directory. Both compiler +recipes call the selected, hash-verified `vcvars64.bat` with the explicit SDK and +MSVC versions, retain the actual INCLUDE/LIB and tool/SDK observations in their +compile logs, and reject different effective inputs or changed compiler/vcvars +bytes. Native held compile-host identity, original deadlines, exit and independent +pipe EOF checks, original flags, current control-source hashes, and path-bound +artifact/log provenance remain required. No discovery process is launched on +this explicit route. Its selection and input digest are recorded in each new +provenance receipt; successful preparation alone is not native qualification or +full CI acceptance. Compiler raw log EOF remains explicitly unproven. + +For preparation alone, use `Tests\UiCaptureDriver.Tests.ps1 -PrepareFixturesOnly` +with the two PowerShell parameters and a fresh short `-ScratchRoot`. Supplying +compiler input to the fixture consumer without `-PrepareFixturesOnly` is refused. +The prepared manifest binds the compiler helper as a seventh control source. + ## Proof and diagnostic modes A successful full invocation exits zero, writes `coverage.json`, and emits diff --git a/Tools/windows/ValidationRunner/Ui/CardsPilotContracts.ps1 b/Tools/windows/ValidationRunner/Ui/CardsPilotContracts.ps1 index 9d10d05a..50f2206e 100644 --- a/Tools/windows/ValidationRunner/Ui/CardsPilotContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/CardsPilotContracts.ps1 @@ -4,6 +4,7 @@ param( [Parameter(Mandatory)][string] $AssemblyPath ) $ErrorActionPreference = 'Stop' +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\UiCaptureDriver.psm1') -WarningAction SilentlyContinue class CardsPilotExitWaitData { [int] $SignalAfterMilliseconds [int] $Calls @@ -487,39 +488,30 @@ Invoke-CardsContract 'ui.cards-pilot.default-source-boundary' { $node.GetCommandName() -ceq 'Read-UiaReadonlyEdgeEndpoints' },$true)) if ($endpointCalls.Count -ne 2) { throw 'Both ordinary edge routes must use the strict readonly reader' } - $endpointReader = $gate.Find({ - param($node) - $node -is [Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -ceq 'Read-UiaReadonlyEdgeEndpoints' - },$true) + $endpointReader = (Get-Command Read-UiaReadonlyEdgeEndpoints -CommandType Function).ScriptBlock.Ast if ($null -eq $endpointReader -or $endpointReader.Extent.Text -notmatch 'GraphCodeMultiProjectEditNative\]::Buffer\(\$handle,100\)' -or $endpointReader.Extent.Text -notmatch '\$pattern\.Current\.Value' -or $endpointReader.Extent.Text -match '\.Current\.Name') { throw 'Readonly endpoint reader must agree on native buffer and semantic ValuePattern without a Name fallback' } - $endpointDecision = $gate.Find({ - param($node) - $node -is [Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -ceq 'Get-UiaReadonlyEndpointsDecision' - },$true) - if ($null -eq $endpointDecision) { throw 'Strict readonly endpoint decision missing' } - . ([scriptblock]::Create($endpointDecision.Extent.Text)) + $endpointDecision = Get-Command Get-UiaReadonlyEndpointsDecision -CommandType Function + if ($endpointDecision.ModuleName -cne 'UiCaptureDriver') { throw 'Strict readonly endpoint decision is not shared' } $endpointSnapshot = @{ handle=10;processId=123;owner=5;class='GraphCodeSheetHost';title='Create or edit edge';captionless=$true viewport=@{handle=11;parent=10;processId=123;class='GraphCodeSheetViewport';matches=1;stable=$true;runtime='11';owned=$true} content=@{handle=12;parent=11;processId=123;class='GraphCodeNativeForm';matches=1;stable=$true;runtime='12';owned=$true} fields=@( @{handle=21;parent=12;processId=123;controlId=9100;class='Edit';matches=1;stable=$true;runtime='21';owned=$true - readonly=$true;value=$nodeId;semanticValue=$nodeId;semanticProcessId=123;name='Source loop identity'}, + readonly=$true;nativeReadonly=$true;value=$nodeId;semanticValue=$nodeId;semanticProcessId=123;name='Source loop identity'}, @{handle=22;parent=12;processId=123;controlId=9101;class='Edit';matches=1;stable=$true;runtime='22';owned=$true - readonly=$true;value=$peerId;semanticValue=$peerId;semanticProcessId=123;name='Target loop identity'} + readonly=$true;nativeReadonly=$true;value=$peerId;semanticValue=$peerId;semanticProcessId=123;name='Target loop identity'} ) } if (-not (Get-UiaReadonlyEndpointsDecision $endpointSnapshot 123 5 'Create or edit edge' $nodeId $peerId).ready) { throw 'Correct native/semantic readonly values were rejected because their Names are labels' } foreach ($negative in @('missing','duplicate','wrong-id','wrong-pid','wrong-parent','wrong-sheet-owner', - 'writable','wrong-value','semantic-mismatch','stale','wrong-class')) { + 'writable','native-writable','wrong-value','semantic-mismatch','stale','wrong-class')) { $changed = $endpointSnapshot | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable switch ($negative) { 'missing' { $changed.fields = @($changed.fields[1]) } @@ -529,6 +521,7 @@ Invoke-CardsContract 'ui.cards-pilot.default-source-boundary' { 'wrong-parent' { $changed.fields[0].parent = 11 } 'wrong-sheet-owner' { $changed.owner = 6 } 'writable' { $changed.fields[0].readonly = $false } + 'native-writable' { $changed.fields[0].nativeReadonly = $false } 'wrong-value' { $changed.fields[0].value = $peerId; $changed.fields[0].semanticValue = $peerId } 'semantic-mismatch' { $changed.fields[0].semanticValue = $peerId } 'stale' { $changed.fields[0].stable = $false } diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs index 7ceb6816..c4d190bb 100644 --- a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs @@ -534,6 +534,14 @@ public static bool IsControlOwnedBy(IntPtr parent, IntPtr control, int controlId public static int ControlIdOf(IntPtr control) { return control == IntPtr.Zero ? 0 : GetDlgCtrlID(control); } + public static bool IsReadonlyEdit(IntPtr window) { + if (window == IntPtr.Zero || !String.Equals(WindowClass(window), "Edit", StringComparison.Ordinal)) + throw new InvalidOperationException("Readonly endpoint requires a native Edit control."); + long style = GetWindowLongPtr(window, -16).ToInt64(); + if (style == 0) + throw new InvalidOperationException("Readonly endpoint style query failed: " + Marshal.GetLastWin32Error()); + return (style & 0x0800) != 0; + } public static bool HasVisibleBounds(IntPtr window) { RECT rect; return window != IntPtr.Zero && IsWindowVisible(window) && @@ -866,7 +874,7 @@ public static string FirstEditText(IntPtr parent) { private static extern IntPtr RealChildWindowFromPoint(IntPtr parent, ScreenPoint clientPoint); [DllImport("user32.dll")] private static extern IntPtr GetWindow(IntPtr window, uint command); - [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] private static extern IntPtr GetWindowLongPtr(IntPtr window, int index); public sealed class ControlClick { public int ControlId, Left, Top, Right, Bottom, ScreenX, ScreenY; diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index a10713c8..9d86074c 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -23,6 +23,7 @@ foreach ($requiredType in @( & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` -AssemblyPath $validationAssemblyPath -RequiredType $requiredType | Out-Null } +Import-Module -Name (Join-Path $PSScriptRoot 'UiCaptureDriver.psm1') -ErrorAction Stop -WarningAction SilentlyContinue $script:uiaAttributionDirectory = $null $script:uiaAttributionSequence = 0 @@ -3120,104 +3121,6 @@ function Wait-UiaSettingsControlVisible($dialog, [int] $processId, [IntPtr] $own throw "Settings visible viewport wait failed for '$name' within original 5000ms budget" } -function Get-UiaReadonlyEndpointsDecision($snapshot, [int] $processId, [long] $owner, - [string] $title, [string] $from, [string] $to) { - if ($null -eq $snapshot -or $processId -le 0 -or $owner -le 0 -or - $snapshot.handle -le 0 -or $snapshot.processId -ne $processId -or $snapshot.owner -ne $owner -or - $snapshot.class -cne 'GraphCodeSheetHost' -or $snapshot.title -cne $title -or - $snapshot.captionless -ne $true -or [string]::IsNullOrWhiteSpace($from) -or - [string]::IsNullOrWhiteSpace($to) -or $from -ieq $to) { return @{ready=$false} } - foreach ($child in @( - @{row=$snapshot.viewport;parent=$snapshot.handle;class='GraphCodeSheetViewport'}, - @{row=$snapshot.content;parent=$snapshot.viewport.handle;class='GraphCodeNativeForm'})) { - $row = $child.row - if ($null -eq $row -or $row.handle -le 0 -or $row.parent -ne $child.parent -or - $row.processId -ne $processId -or $row.class -cne $child.class -or $row.matches -ne 1 -or - $row.stable -ne $true -or $row.owned -ne $true -or - [string]::IsNullOrWhiteSpace($row.runtime)) { return @{ready=$false} } - } - $fields = @($snapshot.fields) - if ($fields.Count -ne 2) { return @{ready=$false} } - for ($index = 0; $index -lt 2; $index++) { - $row = $fields[$index] - $expected = @($from,$to)[$index] - if ($null -eq $row -or $row.handle -le 0 -or $row.parent -ne $snapshot.content.handle -or - $row.processId -ne $processId -or $row.semanticProcessId -ne $processId -or - $row.controlId -ne (9100+$index) -or $row.class -cne 'Edit' -or $row.matches -ne 1 -or - $row.stable -ne $true -or $row.owned -ne $true -or $row.readonly -ne $true -or - [string]::IsNullOrWhiteSpace($row.runtime) -or $row.value -ine $expected -or - $row.semanticValue -cne $row.value) { return @{ready=$false} } - } - if ($fields[0].handle -eq $fields[1].handle) { return @{ready=$false} } - return @{ready=$true;source=$fields[0].value;target=$fields[1].value} -} - -function Read-UiaReadonlyEdgeEndpoints($dialog, [int] $processId, [IntPtr] $owner, [string] $title) { - & (Join-Path $PSScriptRoot 'Import-CompiledValidationAssembly.ps1') ` - -AssemblyPath $validationAssemblyPath -RequiredType 'GraphCodeMultiProjectEditNative' | Out-Null - $sheet = [IntPtr]::new([long]$dialog.Current.NativeWindowHandle) - $snapshot = @{ - handle=$sheet.ToInt64();processId=[GraphCodeUiaGateState]::WindowProcessId($sheet) - owner=[GraphCodeUiaGateState]::WindowOwner($sheet).ToInt64() - class=[GraphCodeUiaGateState]::WindowClass($sheet);title=[GraphCodeUiaGateState]::WindowTitle($sheet) - captionless=[GraphCodeUiaGateState]::IsCaptionlessSheet($sheet) - } - Require ($snapshot.processId -eq $processId -and $snapshot.owner -eq $owner.ToInt64() -and - $snapshot.class -ceq 'GraphCodeSheetHost' -and $snapshot.title -ceq $title -and - $snapshot.captionless) 'Readonly endpoint sheet identity changed before reads' - $raw = [System.Windows.Automation.TreeWalker]::RawViewWalker - function Read-EndpointChild([IntPtr] $parent, [string] $class, [int] $id) { - $handle = if ($id -eq 0) { [GraphCodeUiaGateState]::FindChild($parent,$class) } - else { [GraphCodeUiaGateState]::ControlById($parent,$id) } - Require ($handle -ne [IntPtr]::Zero -and - [GraphCodeUiaGateState]::WindowProcessId($handle) -eq $processId -and - [GraphCodeUiaGateState]::WindowClass($handle) -ceq $class -and - [GraphCodeUiaGateState]::IsControlOwnedBy($sheet,$handle,$id)) 'Exact owned readonly endpoint child missing' - $element = [System.Windows.Automation.AutomationElement]::FromHandle($handle) - $runtime = $element.GetRuntimeId() -join ',' - $sibling = $raw.GetFirstChild([System.Windows.Automation.AutomationElement]::FromHandle($parent)) - $visited = 0; $matches = 0 - while ($null -ne $sibling) { - Require ($visited -lt 256 -and $sibling.Current.ProcessId -eq $processId) ` - 'Readonly endpoint children exceeded their owned bounded census' - $candidate = [IntPtr]::new([long]$sibling.Current.NativeWindowHandle) - if ($candidate -ne [IntPtr]::Zero -and - [GraphCodeUiaGateState]::WindowClass($candidate) -ceq $class -and - [GraphCodeUiaGateState]::ControlIdOf($candidate) -eq $id) { $matches++ } - $visited++; $sibling = $raw.GetNextSibling($sibling) - } - $again = if ($id -eq 0) { [GraphCodeUiaGateState]::FindChild($parent,$class) } - else { [GraphCodeUiaGateState]::ControlById($parent,$id) } - $row = @{ - handle=$handle.ToInt64();parent=[long]$raw.GetParent($element).Current.NativeWindowHandle - processId=[GraphCodeUiaGateState]::WindowProcessId($handle);semanticProcessId=$element.Current.ProcessId - class=[GraphCodeUiaGateState]::WindowClass($handle);controlId=[GraphCodeUiaGateState]::ControlIdOf($handle) - owned=[GraphCodeUiaGateState]::IsControlOwnedBy($sheet,$handle,$id) - runtime=$runtime;matches=$matches;stable=($again -eq $handle -and ($element.GetRuntimeId() -join ',') -ceq $runtime) - } - Require ($row.parent -eq $parent.ToInt64() -and $row.matches -eq 1 -and $row.stable) ` - 'Readonly endpoint child is duplicate, stale or not a direct owned descendant' - if ($id -ne 0) { - $pattern = $element.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern) - $row.readonly = $pattern.Current.IsReadOnly - $row.semanticValue = $pattern.Current.Value - $row.value = [GraphCodeMultiProjectEditNative]::Buffer($handle,100) - $row.stable = $row.stable -and [GraphCodeUiaGateState]::ControlById($parent,$id) -eq $handle -and - ($element.GetRuntimeId() -join ',') -ceq $runtime - } - return $row - } - $snapshot.viewport = Read-EndpointChild $sheet 'GraphCodeSheetViewport' 0 - $snapshot.content = Read-EndpointChild ([IntPtr]::new($snapshot.viewport.handle)) 'GraphCodeNativeForm' 0 - $content = [IntPtr]::new($snapshot.content.handle) - $snapshot.fields = @((Read-EndpointChild $content 'Edit' 9100),(Read-EndpointChild $content 'Edit' 9101)) - $decision = Get-UiaReadonlyEndpointsDecision $snapshot $processId $owner.ToInt64() $title ` - '11111111-1111-4111-8111-111111111111' '22222222-2222-4222-8222-222222222222' - Require $decision.ready 'Readonly native/semantic endpoint identity or value changed' - Write-Host ('UIA_READONLY_ENDPOINT_EVIDENCE=' + ($snapshot | ConvertTo-Json -Depth 6 -Compress)) - return $decision -} - function Get-CardsFixtureRestorationDecision([object[]] $before, [object[]] $after, [int] $processId) { if ($null -eq $before -or $null -eq $after -or $before.Count -eq 0 -or $before.Count -ne $after.Count -or $processId -le 0) { @@ -6996,7 +6899,8 @@ try { $edgeDialog = Wait-UiaOwnedSheetByContentClass $process.Id $shellWindow ` "GraphCodeNativeForm" "Create or edit edge" "connector drag Create or edit edge sheet" Require ($null -ne $edgeDialog) "dragging from the connector to the target card did not open the Create or edit edge dialog" - $edgeEndpoints = Read-UiaReadonlyEdgeEndpoints $edgeDialog $process.Id $shellWindow 'Create or edit edge' + $edgeEndpoints = Read-UiaReadonlyEdgeEndpoints $edgeDialog $process.Id $shellWindow 'Create or edit edge' ` + -AssemblyPath $validationAssemblyPath $edgeFromValue = $edgeEndpoints.source $edgeToValue = $edgeEndpoints.target Require ($edgeFromValue -eq "11111111-1111-4111-8111-111111111111") ` @@ -9460,7 +9364,8 @@ try { $canvasEdgeEditDialogOpened = $null -ne $canvasEdgeDialog Require $canvasEdgeEditDialogOpened ` "clicking Edit Edge from the canvas did not open the edge editor: $($editEdgeClickEvidence | ConvertTo-Json -Compress)" - $canvasEdgeEndpoints = Read-UiaReadonlyEdgeEndpoints $canvasEdgeDialog $process.Id $shellWindow 'Edit edge' + $canvasEdgeEndpoints = Read-UiaReadonlyEdgeEndpoints $canvasEdgeDialog $process.Id $shellWindow 'Edit edge' ` + -AssemblyPath $validationAssemblyPath $canvasEdgeFromId = $canvasEdgeEndpoints.source $canvasEdgeToId = $canvasEdgeEndpoints.target Require ($canvasEdgeFromId -eq "11111111-1111-4111-8111-111111111111") ` diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index c68e81de..17407f51 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)) | +| 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` | | Deterministic visual baseline | `pwsh Tools/windows/validate.ps1 -Task visual-baseline` | diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index dac0d3f8..ad4f3378 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -1,5 +1,19 @@ # GraphCode Windows shell +For a separately admitted receiver diagnostic, build with +`-Dwire-receiver-trace=true`. The default is false. This uses the existing stderr +logger for at most 64 received mouse move/down/up, capture-change, cancel-mode, +and activation events, recording before/after scalar state with monotonic +sequence/event numbers. Source IDs are hex-encoded and capped at 36 bytes. +It adds no input, retry, wait, foreground or capture action. Retain output privately; +this diagnostic build is not qualified functional or visual parity evidence. + +Connector drags retain an existing capture when Windows reports recapture by the +same non-null HWND and `GetCapture` confirms that it still owns capture. Release, +transfer to another HWND, cancel-mode and app deactivation still cancel the drag. +The receiver regression uses the production window procedure with hidden owned +windows and actual reentrant `SetCapture`; it is not physical-input or visual proof. + This is the production Zig/Win32 shell scaffold. It owns the top-level `HWND`, the single Win32 message loop, sidebar/project chrome, graph surface, workspace layout, and focus policy. GraphcodeKit and `graphcoded` remain the only owners of diff --git a/graphcode-windows/build.zig b/graphcode-windows/build.zig index aa1f108c..df020e1d 100644 --- a/graphcode-windows/build.zig +++ b/graphcode-windows/build.zig @@ -60,6 +60,7 @@ pub fn build(b: *std.Build) !void { module.addIncludePath(.{ .cwd_relative = winghostty_include }); const build_options = b.addOptions(); build_options.addOption([]const u8, "version", package_version); + build_options.addOption(bool, "wire_receiver_trace", b.option(bool, "wire-receiver-trace", "Enable bounded private Wire receiver diagnostics") orelse false); module.addOptions("build_options", build_options); const exe = b.addExecutable(.{ diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 6a5f354f..fef66829 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const build_options = if (builtin.is_test) struct { pub const version = "dev"; + pub const wire_receiver_trace = false; } else @import("build_options"); @@ -1609,6 +1610,7 @@ pub const App = struct { selected_edge_project_path: []u8 = &.{}, selected_edge_id: []u8 = &.{}, edge_drag_source_id: []u8 = &.{}, + wire_receiver_trace: Diagnostics.WireReceiverTrace = .{}, selection_initialized: bool = false, worktree_inspection: ?WorktreeStatus.Inspection = null, worktree_inspection_attempt_count: usize = 0, @@ -10507,6 +10509,23 @@ fn onContextAction(context: ?*anyopaque, action: GraphContextMenu.Action, target app.handleContextAction(action, target); } +fn logWireReceiver(app: *App, event: u32, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM, after: bool) void { + const point = if (message == c.WM_MOUSEMOVE or message == c.WM_LBUTTONDOWN or message == c.WM_LBUTTONUP) + CanvasInput.decodeMouseMessage(lparam) + else + CanvasInput.decodeMouseMessage(0); + var buffer: [512]u8 = undefined; + const line = app.wire_receiver_trace.project(&buffer, event, message, after, .{ + .x = point.x, .y = point.y, .buttons = wparam, .raw_lparam = lparam, .source = app.edge_drag_source_id, + .edge_dragging = app.canvas.edge_dragging, .node_dragging = app.canvas.node_dragging, + .panning = app.canvas.dragging, .surface = @tagName(app.surface), + }) catch |err| { + std.log.err("Wire receiver diagnostic projection failed: {s}", .{@errorName(err)}); + return; + }; + std.log.warn("{s}", .{line}); +} + fn onWindowMessage( context: ?*anyopaque, hwnd: c.HWND, @@ -10516,12 +10535,19 @@ fn onWindowMessage( result: *c.LRESULT, ) callconv(.c) bool { const app: *App = @ptrCast(@alignCast(context.?)); + const trace_event = if (build_options.wire_receiver_trace and Diagnostics.WireReceiverTrace.relevant(message)) + app.wire_receiver_trace.begin() + else + null; + if (trace_event) |event| logWireReceiver(app, event, message, wparam, lparam, false); + defer if (trace_event) |event| logWireReceiver(app, event, message, wparam, lparam, true); if (TrayModule.taskbar_created != 0 and message == TrayModule.taskbar_created) { app.tray.readd(); if (!app.tray.added) app.setStatus("System tray unavailable; retrying"); result.* = 0; return true; } + if (MainWindow.restore_message != 0 and message == MainWindow.restore_message) { if (app.ensureWorkspaceIdentity()) restoreShellWindow(hwnd); result.* = 0; @@ -11165,7 +11191,15 @@ fn onWindowMessage( result.* = 0; return true; }, - c.WM_CAPTURECHANGED, c.WM_CANCELMODE => { + c.WM_CAPTURECHANGED => { + const recipient = Win32.messagePointer(c.HWND, lparam); + // SetCapture can synchronously notify a window that already owns capture. + if (hwnd == null or recipient != hwnd or c.GetCapture() != hwnd) + app.cancelCanvasInteraction(); + result.* = 0; + return true; + }, + c.WM_CANCELMODE => { app.cancelCanvasInteraction(); result.* = 0; return true; @@ -23616,6 +23650,299 @@ test "closed cycle UIA separates the footer reason from Inspect and preserves se } } +const WireRouteSheet = struct { + var owner: c.HWND = null; + var timer: c.UINT_PTR = 0; + var observed: bool = false; + var endpoints_match: bool = false; + var accept: bool = true; + var ticks: usize = 0; + var timed_out: bool = false; + const source_id = "60000000-0000-4000-8000-000000000001"; + const target_id = "60000000-0000-4000-8000-000000000002"; + + fn arm(parent: c.HWND, create: bool) !void { + owner = parent; + observed = false; + endpoints_match = false; + accept = create; + ticks = 0; + timed_out = false; + timer = c.SetTimer(null, 0, 100, &fire); + if (timer == 0) return error.TestTimerUnavailable; + } + + fn disarm() void { + if (timer != 0) _ = c.KillTimer(null, timer); + timer = 0; + owner = null; + } + + fn textIs(hwnd: c.HWND, comptime expected: []const u8) bool { + var buffer: [128]u16 = undefined; + const length = c.GetWindowTextW(hwnd, &buffer, buffer.len); + const wide = std.unicode.utf8ToUtf16LeStringLiteral(expected); + return length == wide.len and std.mem.eql(u16, buffer[0..@intCast(length)], wide); + } + + fn child(hwnd: c.HWND, _: c.LPARAM) callconv(.winapi) c.BOOL { + var name: [64]u16 = undefined; + const length = c.GetClassNameW(hwnd, &name, name.len); + const expected = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNativeForm"); + if (length != expected.len or !std.mem.eql(u16, name[0..@intCast(length)], expected)) return 1; + observed = true; + endpoints_match = textIs(c.GetDlgItem(hwnd, 9100), source_id) and + textIs(c.GetDlgItem(hwnd, 9101), target_id); + return 0; + } + + fn fire(_: c.HWND, _: c.UINT, _: c.UINT_PTR, _: c.DWORD) callconv(.winapi) void { + var sheet: c.HWND = null; + while (true) { + sheet = c.FindWindowExW(null, sheet, std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeSheetHost"), null); + if (sheet == null) return; + var pid: c.DWORD = 0; + _ = c.GetWindowThreadProcessId(sheet, &pid); + if (pid != c.GetCurrentProcessId() or c.GetWindow(sheet, c.GW_OWNER) != owner) continue; + ticks += 1; + if (ticks >= 50) { + timed_out = true; + _ = c.PostMessageW(sheet, c.WM_CLOSE, 0, 0); + return; + } + _ = c.EnumChildWindows(sheet, &child, 0); + _ = c.PostMessageW(sheet, c.WM_COMMAND, if (accept and endpoints_match) 1 else 2, 0); + return; + } + } +}; + +fn wireRouteTestApp(dpi: u32) !App { + var app = try overviewTestApp(dpi); + errdefer deinitOverviewTestApp(&app); + app.surface = .project; + app.workspace_controls.rail_visible = false; + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\wire-route","name":"Wire route"},"nodes":[{"id":"60000000-0000-4000-8000-000000000001","title":"Wire source","loopType":"turnBased","state":"idle"},{"id":"60000000-0000-4000-8000-000000000002","title":"Wire target","loopType":"turnBased","state":"idle"}],"edges":[]}}} + ); + try std.testing.expect(app.model.selectProject("C:\\wire-route")); + try std.testing.expect(app.refreshCanvasLayout(logicalClientRect(app.window.hwnd, dpi))); + return app; +} + +fn wireRouteTitlePoint(app: *App, index: usize) c.POINT { + const card = GraphCanvas.nodeBounds(index, &app.canvas); + const actions = GraphCanvas.unwiredActionBounds(card, &app.canvas); + return .{ + .x = @divTrunc(actions.wire.left + actions.mark_entry.right, 2), + .y = @divTrunc(card.top + actions.wire.top, 2), + }; +} + +test "wire capture real window procedure preserves reentrant same-window SetCapture on connector down" { + var app = try wireRouteTestApp(96); + defer deinitOverviewTestApp(&app); + defer app.cancelCanvasInteraction(); + const original = app.window.hwnd; + const instance = c.GetModuleHandleW(null); + 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 { + app.window.hwnd = original; + return error.WindowCreationFailed; + }; + defer { + app.window.callback = null; + if (c.GetCapture() == hwnd) _ = c.ReleaseCapture(); + _ = c.DestroyWindow(hwnd); + app.window.hwnd = original; + } + app.window.callback = &onWindowMessage; + try std.testing.expect(c.IsWindowVisible(hwnd) == 0); + const foreground = c.GetForegroundWindow(); + app.beginWireSelectedNode(0); + try std.testing.expectEqual(hwnd, c.GetCapture()); + try std.testing.expect(app.canvas.edge_dragging); + const source = GraphCanvas.nodeBounds(0, &app.canvas); + const position: c.LPARAM = @intCast(source.right | (@divTrunc(source.top + source.bottom, 2) << 16)); + _ = c.SendMessageW(hwnd, c.WM_LBUTTONDOWN, c.MK_LBUTTON, position); + try std.testing.expect(app.canvas.edge_dragging); + try std.testing.expectEqualStrings(WireRouteSheet.source_id, app.edge_drag_source_id); + try std.testing.expectEqual(hwnd, c.GetCapture()); + try std.testing.expectEqual(foreground, c.GetForegroundWindow()); + _ = c.ReleaseCapture(); + try std.testing.expect(!app.canvas.edge_dragging); + try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); + app.beginWireSelectedNode(0); + _ = c.SendMessageW(hwnd, c.WM_CANCELMODE, 0, @intCast(@intFromPtr(hwnd))); + try std.testing.expect(!app.canvas.edge_dragging); + try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); + app.beginWireSelectedNode(0); + _ = 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; + defer _ = c.DestroyWindow(foreign); + app.beginWireSelectedNode(0); + _ = c.SetCapture(foreign); + try std.testing.expect(!app.canvas.edge_dragging); + try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); + try std.testing.expect(c.GetCapture() != hwnd); + app.edge_drag_source_id = try app.allocator.dupe(u8, WireRouteSheet.source_id); + app.canvas.beginEdgeDrag(app.edge_drag_source_id, 10, 10); + _ = c.SendMessageW(hwnd, c.WM_CAPTURECHANGED, 0, @intCast(@intFromPtr(hwnd))); + try std.testing.expect(!app.canvas.edge_dragging); + try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); + try std.testing.expect(c.IsWindowVisible(hwnd) == 0 and c.IsWindowVisible(foreign) == 0); + try std.testing.expectEqual(foreground, c.GetForegroundWindow()); +} + +test "wire completion target press is node activation while connector drag owns edge completion" { + for ([_]u32{ 96, 192 }) |dpi| { + var app = try wireRouteTestApp(dpi); + defer deinitOverviewTestApp(&app); + defer app.cancelCanvasInteraction(); + const source = GraphCanvas.nodeBounds(0, &app.canvas); + const source_id = "60000000-0000-4000-8000-000000000001"; + const target_id = "60000000-0000-4000-8000-000000000002"; + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload( + "wire-node:C:\\wire-route:" ++ source_id, + ))); + try std.testing.expectEqualStrings(source_id, app.edge_drag_source_id); + const target_point = wireRouteTitlePoint(&app, 1); + const target_message: c.LPARAM = @intCast( + physicalCoordinate(target_point.x, dpi) | (physicalCoordinate(target_point.y, dpi) << 16), + ); + var result: c.LRESULT = 0; + _ = onWindowMessage(&app, app.window.hwnd, c.WM_MOUSEMOVE, 0, target_message, &result); + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, c.MK_LBUTTON, target_message, &result)); + try std.testing.expect(app.canvas.node_dragging); + try std.testing.expectEqualStrings(target_id, app.canvas_press_node_id); + try std.testing.expect(app.canvas.edge_dragging); + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONUP, 0, target_message, &result)); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expectEqual(@as(usize, 0), app.model.graph.?.edges.items.len); + try std.testing.expectEqual(@as(usize, 1), app.client.outbound_count); + try std.testing.expect(std.mem.indexOf(u8, app.client.outbound[app.client.outbound_head], "resumeSession") != null); + app.cancelCanvasInteraction(); + + app.surface = .project; + app.workspace_controls.panel_visible = false; + const source_message: c.LPARAM = @intCast( + physicalCoordinate(source.right, dpi) | + (physicalCoordinate(@divTrunc(source.top + source.bottom, 2), dpi) << 16), + ); + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload( + "wire-node:C:\\wire-route:" ++ source_id, + ))); + _ = onWindowMessage(&app, app.window.hwnd, c.WM_MOUSEMOVE, 0, source_message, &result); + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, c.MK_LBUTTON, source_message, &result)); + try std.testing.expect(!app.canvas.node_dragging); + try std.testing.expect(app.canvas.edge_dragging); + try std.testing.expectEqualStrings(source_id, app.edge_drag_source_id); + _ = onWindowMessage(&app, app.window.hwnd, c.WM_MOUSEMOVE, c.MK_LBUTTON, target_message, &result); + try std.testing.expectEqual(target_point.x, app.canvas.edge_drag_x); + try std.testing.expectEqual(target_point.y, app.canvas.edge_drag_y); + } +} + +test "wire completion harness connector gesture reaches owned native form and Create command" { + for ([_]u32{ 96, 192 }) |dpi| { + for ([_]bool{ false, true }) |armed| { + for ([_]bool{ false, true }) |accept| { + var app = try wireRouteTestApp(dpi); + defer deinitOverviewTestApp(&app); + defer app.cancelCanvasInteraction(); + if (armed) try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload( + "wire-node:C:\\wire-route:" ++ WireRouteSheet.source_id, + ))); + const source = GraphCanvas.nodeBounds(0, &app.canvas); + const source_message: c.LPARAM = @intCast( + physicalCoordinate(source.right, dpi) | + (physicalCoordinate(@divTrunc(source.top + source.bottom, 2), dpi) << 16), + ); + const target = wireRouteTitlePoint(&app, 1); + const target_message: c.LPARAM = @intCast( + physicalCoordinate(target.x, dpi) | (physicalCoordinate(target.y, dpi) << 16), + ); + try WireRouteSheet.arm(app.window.hwnd, accept); + defer WireRouteSheet.disarm(); + var result: c.LRESULT = 0; + _ = onWindowMessage(&app, app.window.hwnd, c.WM_MOUSEMOVE, 0, source_message, &result); + _ = onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, c.MK_LBUTTON, source_message, &result); + _ = onWindowMessage(&app, app.window.hwnd, c.WM_MOUSEMOVE, c.MK_LBUTTON, target_message, &result); + _ = onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONUP, 0, target_message, &result); + try std.testing.expect(WireRouteSheet.observed); + try std.testing.expect(WireRouteSheet.endpoints_match); + try std.testing.expect(!WireRouteSheet.timed_out); + try std.testing.expect(!app.canvas.edge_dragging and !app.canvas.node_dragging); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); + if (!accept) { + try std.testing.expectEqual(@as(usize, 0), app.client.outbound_count); + continue; + } + try std.testing.expectEqual(@as(usize, 1), app.client.outbound_count); + const frame = app.client.outbound[app.client.outbound_head]; + try std.testing.expect(std.mem.indexOf(u8, frame, "\"createEdge\"") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "\"from\":\"" ++ WireRouteSheet.source_id ++ "\"") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "\"to\":\"" ++ WireRouteSheet.target_id ++ "\"") != null); + } + } + } +} + +test "wire completion self empty and cancelled drops create nothing and preserve inline action priority" { + for ([_]u32{ 96, 192 }) |dpi| { + for ([_]enum { self, empty, cancel, mark }{ .self, .empty, .cancel, .mark }) |drop| { + var app = try wireRouteTestApp(dpi); + defer deinitOverviewTestApp(&app); + defer app.cancelCanvasInteraction(); + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload( + "wire-node:C:\\wire-route:" ++ WireRouteSheet.source_id, + ))); + const source = GraphCanvas.nodeBounds(0, &app.canvas); + const target = GraphCanvas.nodeBounds(1, &app.canvas); + const start: c.LPARAM = @intCast( + physicalCoordinate(source.right, dpi) | + (physicalCoordinate(@divTrunc(source.top + source.bottom, 2), dpi) << 16), + ); + const point = switch (drop) { + .self => wireRouteTitlePoint(&app, 0), + .empty, .cancel => c.POINT{ .x = 1100, .y = 800 }, + .mark => c.POINT{ .x = target.left + 100, .y = target.top + 40 }, + }; + const end: c.LPARAM = @intCast( + physicalCoordinate(point.x, dpi) | (physicalCoordinate(point.y, dpi) << 16), + ); + var result: c.LRESULT = 0; + if (drop == .mark) { + _ = onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, c.MK_LBUTTON, end, &result); + try std.testing.expect(app.nodeIsDeclaredEntry(WireRouteSheet.target_id)); + try std.testing.expect(!app.canvas.node_dragging); + app.cancelCanvasInteraction(); + for (app.declared_entry_ids.items) |id| app.allocator.free(id); + app.declared_entry_ids.clearRetainingCapacity(); + } else { + _ = onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, c.MK_LBUTTON, start, &result); + _ = onWindowMessage(&app, app.window.hwnd, c.WM_MOUSEMOVE, c.MK_LBUTTON, end, &result); + if (drop == .cancel) { + _ = onWindowMessage(&app, app.window.hwnd, c.WM_CANCELMODE, 0, 0, &result); + } + _ = onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONUP, 0, end, &result); + } + try std.testing.expectEqual(@as(usize, 0), app.client.outbound_count); + try std.testing.expectEqual(@as(usize, 0), app.model.graph.?.edges.items.len); + try std.testing.expect(!app.canvas.edge_dragging and !app.canvas.node_dragging and !app.canvas.dragging); + try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); + } + } +} + test "unwired inline recovery controls route pointer and stable UIA invokes to existing effects" { const Sink = struct { expected_wire: c.RECT, diff --git a/graphcode-windows/src/Diagnostics.zig b/graphcode-windows/src/Diagnostics.zig index ac314b24..bdcb5508 100644 --- a/graphcode-windows/src/Diagnostics.zig +++ b/graphcode-windows/src/Diagnostics.zig @@ -1,71 +1,145 @@ -const std = @import("std"); - -const max_bytes = 2 * 1024 * 1024; -var mutex: std.Thread.Mutex = .{}; - -pub fn record(allocator: std.mem.Allocator, event: []const u8, detail: []const u8) void { - mutex.lock(); - defer mutex.unlock(); - - const directory = supportDirectory(allocator) catch return; - defer allocator.free(directory); - std.fs.cwd().makePath(directory) catch return; - const path = std.fs.path.join(allocator, &.{ directory, "graphcode-windows.log" }) catch return; - defer allocator.free(path); - - var file = std.fs.cwd().createFile(path, .{ .truncate = false }) catch return; - defer file.close(); - const size = file.getEndPos() catch 0; - if (size >= max_bytes) { - file.setEndPos(0) catch return; - file.seekTo(0) catch return; - } else { - file.seekFromEnd(0) catch return; - } - - const line = std.fmt.allocPrint( - allocator, - "{d} event={s} detail={s}\r\n", - .{ std.time.timestamp(), event, detail }, - ) catch return; - defer allocator.free(line); - file.writeAll(line) catch {}; -} - -pub fn startupFatalDetail(err: anyerror) []const u8 { - return switch (err) { - error.UserProfileMissing => "operation=resolve_support_directory missing_environment=USERPROFILE", - error.WorkspaceUserUnavailable => "operation=reserve_workspace windows_identity=GetUserNameW", - error.EnvironmentVariableNotFound => "operation=startup_environment missing_environment=unexpected", - else => @errorName(err), - }; -} - -fn supportDirectory(allocator: std.mem.Allocator) ![]u8 { - if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { - return value; - } else |_| {} - const profile = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); - defer allocator.free(profile); - return std.fs.path.join(allocator, &.{ profile, ".graphcode" }); -} - -test "diagnostic log has a bounded filename and size" { - try std.testing.expectEqualStrings("graphcode-windows.log", std.fs.path.basename("x\\graphcode-windows.log")); - try std.testing.expect(max_bytes >= 1024 * 1024); -} - -test "startup fatal diagnostics name the bounded operation without environment values" { - try std.testing.expectEqualStrings( - "operation=resolve_support_directory missing_environment=USERPROFILE", - startupFatalDetail(error.UserProfileMissing), - ); - try std.testing.expectEqualStrings( - "operation=reserve_workspace windows_identity=GetUserNameW", - startupFatalDetail(error.WorkspaceUserUnavailable), - ); - try std.testing.expectEqualStrings( - "operation=startup_environment missing_environment=unexpected", - startupFatalDetail(error.EnvironmentVariableNotFound), - ); -} +const std = @import("std"); + +const max_bytes = 2 * 1024 * 1024; +var mutex: std.Thread.Mutex = .{}; + +pub const WireReceiverTrace = struct { + pub const max_events = 64; + events: u32 = 0, + sequence: u32 = 0, + + pub const State = struct { + x: i32, + y: i32, + buttons: usize, + raw_lparam: i64 = 0, + source: []const u8, + edge_dragging: bool, + node_dragging: bool, + panning: bool, + surface: []const u8, + }; + + pub fn relevant(message: u32) bool { + return switch (message) { + 0x0200, 0x0201, 0x0202, 0x0215, 0x001f, 0x001c => true, + else => false, + }; + } + + pub fn begin(self: *WireReceiverTrace) ?u32 { + if (self.events >= max_events) return null; + self.events += 1; + return self.events; + } + + pub fn project(self: *WireReceiverTrace, buffer: []u8, event: u32, message: u32, after: bool, state: State) ![]const u8 { + self.sequence += 1; + const source = state.source[0..@min(state.source.len, 36)]; + return std.fmt.bufPrint(buffer, "WIRE_RECEIVER seq={d} event={d} phase={s} msg=0x{x} x={d} y={d} flags=0x{x} lp=0x{x} edge={} node={} pan={} surface={s} source_hex={x} source_bytes={d} truncated={}", .{ self.sequence, event, if (after) "after" else "before", message, state.x, state.y, state.buttons, state.raw_lparam, state.edge_dragging, state.node_dragging, state.panning, state.surface[0..@min(state.surface.len, 16)], source, state.source.len, state.source.len > source.len }); + } +}; + +pub fn record(allocator: std.mem.Allocator, event: []const u8, detail: []const u8) void { + mutex.lock(); + defer mutex.unlock(); + + const directory = supportDirectory(allocator) catch return; + defer allocator.free(directory); + std.fs.cwd().makePath(directory) catch return; + const path = std.fs.path.join(allocator, &.{ directory, "graphcode-windows.log" }) catch return; + defer allocator.free(path); + + var file = std.fs.cwd().createFile(path, .{ .truncate = false }) catch return; + defer file.close(); + const size = file.getEndPos() catch 0; + if (size >= max_bytes) { + file.setEndPos(0) catch return; + file.seekTo(0) catch return; + } else { + file.seekFromEnd(0) catch return; + } + + const line = std.fmt.allocPrint( + allocator, + "{d} event={s} detail={s}\r\n", + .{ std.time.timestamp(), event, detail }, + ) catch return; + defer allocator.free(line); + file.writeAll(line) catch {}; +} + +pub fn startupFatalDetail(err: anyerror) []const u8 { + return switch (err) { + error.UserProfileMissing => "operation=resolve_support_directory missing_environment=USERPROFILE", + error.WorkspaceUserUnavailable => "operation=reserve_workspace windows_identity=GetUserNameW", + error.EnvironmentVariableNotFound => "operation=startup_environment missing_environment=unexpected", + else => @errorName(err), + }; +} + +fn supportDirectory(allocator: std.mem.Allocator) ![]u8 { + if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { + return value; + } else |_| {} + const profile = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(profile); + return std.fs.path.join(allocator, &.{ profile, ".graphcode" }); +} + +test "diagnostic log has a bounded filename and size" { + try std.testing.expectEqualStrings("graphcode-windows.log", std.fs.path.basename("x\\graphcode-windows.log")); + try std.testing.expect(max_bytes >= 1024 * 1024); +} + +test "startup fatal diagnostics name the bounded operation without environment values" { + try std.testing.expectEqualStrings( + "operation=resolve_support_directory missing_environment=USERPROFILE", + startupFatalDetail(error.UserProfileMissing), + ); + try std.testing.expectEqualStrings( + "operation=reserve_workspace windows_identity=GetUserNameW", + startupFatalDetail(error.WorkspaceUserUnavailable), + ); + try std.testing.expectEqualStrings( + "operation=startup_environment missing_environment=unexpected", + startupFatalDetail(error.EnvironmentVariableNotFound), + ); +} + +test "wire receiver trace is bounded scalar projection with monotonic nested event ordering" { + var trace = WireReceiverTrace{}; + var buffer: [512]u8 = undefined; + const state = WireReceiverTrace.State{ .x = 632, .y = -182, .buttons = 1, .source = "source\n\tid", .edge_dragging = true, .node_dragging = false, .panning = false, .surface = "project" }; + const drag = trace.begin().?; + const before = try trace.project(&buffer, drag, 0x0202, false, state); + try std.testing.expect(std.mem.startsWith(u8, before, "WIRE_RECEIVER seq=1 event=1 phase=before")); + try std.testing.expect(std.mem.indexOf(u8, before, "\n") == null); + const cancel = trace.begin().?; + _ = try trace.project(&buffer, cancel, 0x0215, false, state); + _ = try trace.project(&buffer, cancel, 0x0215, true, state); + const after = try trace.project(&buffer, drag, 0x0202, true, state); + try std.testing.expect(std.mem.startsWith(u8, after, "WIRE_RECEIVER seq=4 event=1 phase=after")); + while (trace.events < WireReceiverTrace.max_events) _ = trace.begin(); + try std.testing.expect(trace.begin() == null); + for ([_]u32{ 0x0200, 0x0201, 0x0202, 0x0215, 0x001f, 0x001c }) |message| + try std.testing.expect(WireReceiverTrace.relevant(message)); + try std.testing.expect(!WireReceiverTrace.relevant(0x000f)); +} + +test "wire receiver trace truncates private source bytes without unbounded output" { + var trace = WireReceiverTrace{}; + var buffer: [512]u8 = undefined; + const line = try trace.project(&buffer, trace.begin().?, 0x0200, false, .{ + .x = 0, + .y = 0, + .buttons = 0, + .source = &([_]u8{'x'} ** 2048), + .edge_dragging = false, + .node_dragging = false, + .panning = false, + .surface = "project", + }); + try std.testing.expect(line.len < buffer.len); + try std.testing.expect(std.mem.endsWith(u8, line, "source_bytes=2048 truncated=true")); +} diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index 3aa5b2a7..4bcd7785 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -3252,7 +3252,7 @@ fn windowFromHandle(hwnd: c.HWND) ?*Window { return @ptrFromInt(@as(usize, @bitCast(raw))); } -fn registerClass(instance: c.HINSTANCE) !void { +pub fn registerClass(instance: c.HINSTANCE) !void { var window_class: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); window_class.lpfnWndProc = @ptrCast(&windowProc); window_class.hInstance = instance; diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index c76dac53..94b7f5bc 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -1497,9 +1497,87 @@ fn nodePrimaryLabel(state: *const DialogState) []const u8 { fn statePrimaryLabel(state: *const DialogState) []const u8 { if (state.kind == .node) return nodePrimaryLabel(state); if (state.kind == .promotion and state.promotion_source != .sketch) return "Change"; + if (state.kind == .edge and state.edge_initial == null) return "Create"; return primaryLabel(state.kind); } +test "edge creation primary caption pure: new edge uses Create" { + const state = DialogState{ .allocator = std.testing.allocator, .kind = .edge, .parent = null }; + try std.testing.expectEqualStrings("Create", statePrimaryLabel(&state)); +} + +test "edge creation primary caption pure: edited edge keeps OK" { + const state = DialogState{ + .allocator = std.testing.allocator, + .kind = .edge, + .parent = null, + .edge_initial = .{ .from = "source", .to = "target" }, + }; + try std.testing.expectEqualStrings("OK", statePrimaryLabel(&state)); +} + +test "edge creation primary caption pure: unrelated labels stay unchanged" { + for ([_]Kind{ .update, .settings, .jump, .template_picker, .worktree_policy, .worktree_sweep }) |kind| { + const state = DialogState{ .allocator = std.testing.allocator, .kind = kind, .parent = null }; + try std.testing.expectEqualStrings(primaryLabel(kind), statePrimaryLabel(&state)); + } + var node_state = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null }; + node_state.values[1] = try std.testing.allocator.dupe(u8, "goalBased"); + defer std.testing.allocator.free(node_state.values[1]); + try std.testing.expectEqualStrings(NewLoopSheet.primaryLabel("goalBased"), statePrimaryLabel(&node_state)); + const sketch = DialogState{ .allocator = std.testing.allocator, .kind = .promotion, .parent = null, .promotion_source = .sketch }; + const timed = DialogState{ .allocator = std.testing.allocator, .kind = .promotion, .parent = null, .promotion_source = .timed }; + try std.testing.expectEqualStrings("Promote", statePrimaryLabel(&sketch)); + try std.testing.expectEqualStrings("Change", statePrimaryLabel(&timed)); +} + +test "dialog framework: new edge primary caption native footer roles default and validation remain" { + const owner = try hiddenNativeControlHost(); + defer _ = c.DestroyWindow(owner); + var state = DialogState{ .allocator = std.testing.allocator, .kind = .edge, .parent = owner }; + defer freeValues(&state); + try initializeEdge(&state, .{ + .from = "60000000-0000-4000-8000-000000000001", + .to = "60000000-0000-4000-8000-000000000002", + }); + var host: SheetFramework.Host = .{}; + defer host.deinit(); + try initHiddenForm(owner, &state, &host); + const primary = c.GetDlgItem(host.hwnd, ok_id) orelse return error.TestControlCreationFailed; + const cancel = c.GetDlgItem(host.hwnd, cancel_id) orelse return error.TestControlCreationFailed; + try std.testing.expectEqual(@as(c_int, ok_id), c.GetDlgCtrlID(primary)); + try std.testing.expect(c.GetParent(primary) == host.hwnd and c.GetParent(cancel) == host.hwnd); + var wide: [32]u16 = undefined; + var text: [32]u8 = undefined; + var length = c.GetWindowTextW(primary, &wide, @intCast(wide.len)); + var written = try std.unicode.utf16LeToUtf8(&text, wide[0..@intCast(length)]); + try std.testing.expectEqualStrings("Create", text[0..written]); + length = c.GetWindowTextW(cancel, &wide, @intCast(wide.len)); + written = try std.unicode.utf16LeToUtf8(&text, wide[0..@intCast(length)]); + try std.testing.expectEqualStrings("Cancel", text[0..written]); + const actions = footerActions(&state, true); + try std.testing.expectEqual(SheetFramework.ActionRole.primary, actions[2].role); + try std.testing.expectEqual(SheetFramework.ActionRole.cancel, actions[0].role); + try std.testing.expect(actions[2].return_hint and c.IsWindowEnabled(primary) != 0); + try std.testing.expectEqual(measureTextWidth(host.hwnd, &state, "Create"), actions[2].measured_text_width); + const previous_focus = c.GetFocus(); + defer _ = c.SetFocus(previous_focus); + _ = c.SetFocus(state.edits[0]); + const default_primary: usize = @bitCast(c.SendMessageW(host.hwnd, c.DM_GETDEFID, 0, 0)); + try std.testing.expectEqual(@as(u16, ok_id), @as(u16, @truncate(default_primary))); + try std.testing.expectEqual(@as(u16, c.DC_HASDEFID), @as(u16, @truncate(default_primary >> 16))); + _ = c.SetFocus(cancel); + const default_cancel: usize = @bitCast(c.SendMessageW(host.hwnd, c.DM_GETDEFID, 0, 0)); + try std.testing.expectEqual(@as(u16, cancel_id), @as(u16, @truncate(default_cancel))); + try std.testing.expectEqual(@as(u16, c.DC_HASDEFID), @as(u16, @truncate(default_cancel >> 16))); + state.allocator.free(state.values[7]); + state.values[7] = try state.allocator.dupe(u8, "-7"); + syncValidation(&state); + try std.testing.expect(validationReason(&state) != null and c.IsWindowEnabled(primary) == 0); + _ = c.SetFocus(state.edits[0]); + try std.testing.expectEqual(@as(c.LRESULT, 0), c.SendMessageW(host.hwnd, c.DM_GETDEFID, 0, 0)); +} + fn newLoopFieldLabel(loop_type: []const u8, index: usize) []const u8 { return switch (index) { 0 => if (std.mem.eql(u8, loop_type, "sketch")) "" else "Name (optional)",